feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,15 +1,13 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/app/login/page.tsx
|
||||
// OAuth 로그인 페이지
|
||||
// D3RO-VOICE Canonical Login Page — Precision Voice Intelligence Design
|
||||
// Design Reference: docs/v3/designs/login.html
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Button, Stack, Alert } from '@mui/material'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import GitHubIcon from '@mui/icons-material/GitHub'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { Box, Stack, Alert } from '@mui/material'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||
import { useAuth } from '@/components/providers/auth-provider'
|
||||
|
|
@ -20,18 +18,19 @@ export default function LoginPage(): React.ReactElement {
|
|||
const { t } = useI18n()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const configured = isSupabaseConfigured()
|
||||
|
||||
// 이미 로그인이면 대시보드로
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
router.replace('/dashboard')
|
||||
}
|
||||
}, [loading, user, router])
|
||||
|
||||
async function handleOAuth(provider: 'google' | 'github'): Promise<void> {
|
||||
async function handleOAuth(provider: 'google' | 'github' | 'apple'): Promise<void> {
|
||||
if (!configured) {
|
||||
setError('Supabase 환경변수가 설정되지 않았습니다. apps/web/env.example.txt를 참고하세요.')
|
||||
setError('Supabase 환경변수가 설정되지 않았습니다.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
|
|
@ -41,14 +40,37 @@ export default function LoginPage(): React.ReactElement {
|
|||
const supabase = getSupabaseBrowserClient()
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
const { error: err } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
provider: provider === 'apple' ? 'apple' : provider,
|
||||
options: { redirectTo }
|
||||
})
|
||||
if (err) {
|
||||
setError(err.message)
|
||||
setSigningIn(false)
|
||||
}
|
||||
// 성공 시 브라우저가 provider로 리다이렉트되므로 여기서 끝
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
async function handleEmailSignIn(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault()
|
||||
if (!configured) {
|
||||
setError('Supabase 환경변수가 설정되지 않았습니다.')
|
||||
return
|
||||
}
|
||||
if (!email || !password) return
|
||||
setError(null)
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { error: err } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (err) {
|
||||
setError(err.message)
|
||||
setSigningIn(false)
|
||||
} else {
|
||||
router.replace('/dashboard')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
setSigningIn(false)
|
||||
|
|
@ -58,65 +80,367 @@ export default function LoginPage(): React.ReactElement {
|
|||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
p: 4
|
||||
p: 2,
|
||||
fontFamily: '"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'
|
||||
}}
|
||||
>
|
||||
<MetalCard sx={{ maxWidth: 420, width: '100%', p: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<PhosphorText variant="title">
|
||||
D3RO VOICE
|
||||
</PhosphorText>
|
||||
<Box sx={{ mt: 1, ...typoSx("label"), color: d3roPalette.text.secondary }}>
|
||||
{t('login.subtitle') ?? 'AI 음성 어시스턴트'}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '24px',
|
||||
p: { xs: 3.5, sm: 4.5 },
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7)',
|
||||
color: d3roPalette.text.secondary
|
||||
}}
|
||||
>
|
||||
{/* Header & LED Indicators */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
boxShadow: '0 0 10px var(--d3-accent-main)',
|
||||
animation: 'pulse 2s infinite ease-in-out',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.6, transform: 'scale(0.92)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
bgcolor: d3roPalette.tag.green,
|
||||
boxShadow: '0 0 10px var(--d3-tag-green)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={{
|
||||
m: 0,
|
||||
fontSize: '22px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
letterSpacing: '0.25em',
|
||||
color: d3roPalette.text.primary,
|
||||
textIndent: '0.25em'
|
||||
}}
|
||||
>
|
||||
D3RO-VOICE
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
fontSize: '9px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
letterSpacing: '0.18em',
|
||||
color: d3roPalette.text.label,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{t('login.subtitle') ?? 'Precision Voice Intelligence'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 3,
|
||||
bgcolor: 'color-mix(in srgb, var(--d3-tag-red) 8%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--d3-tag-red) 30%, transparent)',
|
||||
color: d3roPalette.tag.red,
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* OAuth Buttons */}
|
||||
<Stack spacing={1.5} sx={{ mb: 3 }}>
|
||||
{/* Google Button */}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={signingIn}
|
||||
onClick={() => void handleOAuth('google')}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '12px',
|
||||
py: 1.6,
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: d3roPalette.text.primary,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: signingIn ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
borderColor: d3roPalette.border.strong
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.99)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
{t('login.google') ?? 'Google로 로그인'}
|
||||
</Box>
|
||||
|
||||
{!configured && (
|
||||
<Alert severity="warning" variant="outlined">
|
||||
Supabase 환경변수가 설정되지 않았습니다. 로그인은 env 설정 후 가능합니다.
|
||||
</Alert>
|
||||
)}
|
||||
{/* Apple Button */}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={signingIn}
|
||||
onClick={() => void handleOAuth('apple')}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '12px',
|
||||
py: 1.6,
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: d3roPalette.text.primary,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: signingIn ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
borderColor: d3roPalette.border.strong
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.99)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.4,8.3c-0.4-2.2,1.3-4.1,3.2-4.5c-0.5-2.2-2.3-3.6-4.5-3.6c-1.8-0.2-3.8,1.2-4.8,1.2c-1,0-2.6-1.1-4.1-1.1c-2,0-4,1.1-5,2.9C-1.8,7.2,0.6,13,2.6,15.9c1,1.4,2.2,3.1,3.8,3c1.5-0.1,2.1-1,3.9-1c1.8,0,2.3,1,3.9,1c1.6,0,2.6-1.5,3.6-2.9C18.9,14.4,19.3,13,19.3,12.9C19.2,12.8,15.9,11.5,15.4,8.3z M12.8,3.2c0.8-1,1.4-2.5,1.2-3.9C12.8,0.1,11.3,0.8,10.5,1.8C9.8,2.7,9.2,4.2,9.4,5.6C10.8,5.7,12,4.5,12.8,3.2z" />
|
||||
</svg>
|
||||
Apple로 로그인
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<GoogleIcon />}
|
||||
disabled={signingIn || !configured}
|
||||
onClick={() => void handleOAuth('google')}
|
||||
>
|
||||
{t('login.google') ?? 'Google로 계속하기'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<GitHubIcon />}
|
||||
disabled={signingIn || !configured}
|
||||
onClick={() => void handleOAuth('github')}
|
||||
>
|
||||
{t('login.github') ?? 'GitHub로 계속하기'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ textAlign: 'center', color: d3roPalette.text.label, fontSize: 12 }}>
|
||||
{t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'}
|
||||
{/* GitHub Button */}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={signingIn}
|
||||
onClick={() => void handleOAuth('github')}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '12px',
|
||||
py: 1.6,
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: d3roPalette.text.primary,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: signingIn ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
borderColor: d3roPalette.border.strong
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.99)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
{t('login.github') ?? 'GitHub로 로그인'}
|
||||
</Box>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
|
||||
{/* Divider */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.default }} />
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
color: d3roPalette.text.label,
|
||||
letterSpacing: '0.1em'
|
||||
}}
|
||||
>
|
||||
{t('login.or') ?? '또는'}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.default }} />
|
||||
</Box>
|
||||
|
||||
{/* Email & Password Form */}
|
||||
<Box component="form" onSubmit={handleEmailSignIn}>
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<Box>
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
letterSpacing: '0.15em',
|
||||
color: d3roPalette.text.label,
|
||||
mb: 0.75,
|
||||
pl: 0.5
|
||||
}}
|
||||
>
|
||||
EMAIL
|
||||
</Box>
|
||||
<Box
|
||||
component="input"
|
||||
type="email"
|
||||
required
|
||||
placeholder="user@studio.com"
|
||||
value={email}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '12px',
|
||||
py: 1.6,
|
||||
px: 2,
|
||||
fontSize: '14px',
|
||||
color: d3roPalette.text.primary,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s ease',
|
||||
'&:focus': {
|
||||
borderColor: d3roPalette.accent.main
|
||||
},
|
||||
'&::placeholder': {
|
||||
color: 'color-mix(in srgb, var(--d3-text-label) 40%, transparent)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
letterSpacing: '0.15em',
|
||||
color: d3roPalette.text.label,
|
||||
mb: 0.75,
|
||||
pl: 0.5
|
||||
}}
|
||||
>
|
||||
PASSWORD
|
||||
</Box>
|
||||
<Box
|
||||
component="input"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
borderRadius: '12px',
|
||||
py: 1.6,
|
||||
px: 2,
|
||||
fontSize: '14px',
|
||||
color: d3roPalette.text.primary,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s ease',
|
||||
'&:focus': {
|
||||
borderColor: d3roPalette.accent.main
|
||||
},
|
||||
'&::placeholder': {
|
||||
color: 'color-mix(in srgb, var(--d3-text-label) 40%, transparent)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Primary Orange CTA Button */}
|
||||
<Box
|
||||
component="button"
|
||||
type="submit"
|
||||
disabled={signingIn}
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
py: 1.75,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.05em',
|
||||
color: d3roPalette.bg.app,
|
||||
cursor: signingIn ? 'not-allowed' : 'pointer',
|
||||
boxShadow: '0 0 25px rgba(59, 130, 246, 0.35)',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
filter: 'brightness(1.08)',
|
||||
boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)'
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.99)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{signingIn ? '로그인 중...' : '로그인'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box sx={{ mt: 3.5, textAlign: 'center', fontSize: '11px', color: d3roPalette.text.label }}>
|
||||
계정이 없으신가요?{' '}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: d3roPalette.accent.main,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
회원가입
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue