feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,151 +1,360 @@
// apps/web/src/app/(app)/billing/page.tsx
// 구독 및 결제 페이지 — Payple 결제 연동 (Phase 3.2-B)
import { Box, Grid, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { PaypleCheckoutButton } from '@/components/billing/payple-checkout-button'
import Link from 'next/link'
import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material'
import { Check } from 'lucide-react'
import type { Subscription, SubscriptionTier } from '@d3ro/api-client'
import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roFontMono } from '@d3ro/ui/theme'
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
import { PaypleManageButton } from '@/components/billing/payple-manage-button'
import { PortalButton } from '@/components/billing/portal-button'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import {
formatPlanCatalogPrice,
parseBillingCatalog,
type BillingCatalog,
type BillingCatalogPrice
} from '@/lib/billing-catalog'
type BillingProvider = Subscription['provider']
interface BillingSubscription {
tier: SubscriptionTier
status: string | null
provider: BillingProvider
current_period_end: string | null
cancel_at: string | null
auto_renewing: boolean | null
}
interface BillingState {
email: string
subscription: BillingSubscription
catalog: BillingCatalog | null
}
interface BillingPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
interface Plan {
tier: 'free' | 'pro' | 'pro_plus'
tier: SubscriptionTier
name: string
price: string
features: string[]
highlight?: boolean
}
const VALID_TIERS = new Set<SubscriptionTier>(['free', 'pro', 'pro_plus'])
const VALID_PROVIDERS = new Set<BillingProvider>([
'none',
'stripe',
'payple',
'google_play',
'app_store',
'admin'
])
const PLANS: Plan[] = [
{
tier: 'free',
name: 'Free',
price: '₩0',
features: [
'로컬 STT/LLM 무제한',
'Haiku 250회/주간',
'히스토리 3일 보존',
],
name: 'FREE',
features: ['기본 음성 인식', '로컬 AI 무제한', '무료 클라우드 쿼터']
},
{
tier: 'pro',
name: 'Pro',
price: '₩9,900/월',
name: 'PRO',
highlight: true,
features: [
'로컬 STT/LLM 무제한',
'Haiku 1,500회/일',
'Sonnet 300회/일',
'Opus 50회/일',
'히스토리 무제한',
'클라우드 동기화',
],
'Claude Sonnet / Opus 다듬기',
'실시간 음성 대화',
'기기 간 전사 동기화',
'맞춤형 프롬프트와 자동 서식'
]
},
{
tier: 'pro_plus',
name: 'Pro+',
price: '₩29,900/월',
name: 'PRO+',
features: [
'Pro 모든 기능',
'Haiku 무제한',
'Sonnet 1,500회/일',
'Opus 300회/일',
'팀 협업 (회의 공유)',
'우선 지원',
],
},
'PRO 모든 기능',
'프리미엄 AI 확장 쿼터',
'고급 회의·요약 기능',
'우선 지원'
]
}
]
async function loadCurrentTier(): Promise<string> {
try {
const supabase = await getSupabaseServerClient()
const { data } = await supabase.from('subscriptions').select('tier').maybeSingle()
return ((data as { tier?: string } | null)?.tier as string) ?? 'free'
} catch {
return 'free'
function tierLabel(tier: SubscriptionTier): string {
return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()
}
function providerLabel(provider: BillingProvider): string {
const labels: Record<BillingProvider, string> = {
none: '없음',
payple: 'Payple',
stripe: 'Stripe',
google_play: 'Google Play',
app_store: 'App Store',
admin: '관리자 부여'
}
return labels[provider]
}
function formatDate(value: string | null): string | null {
if (!value) return null
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return null
return new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium' }).format(date)
}
async function loadBillingState(): Promise<BillingState> {
const supabase = await getSupabaseServerClient()
const { data: userData, error: userError } = await supabase.auth.getUser()
if (userError || !userData.user) throw new Error('authenticated_session_required')
const { data, error } = await supabase
.from('subscriptions')
.select('tier, status, provider, payment_provider, current_period_end, cancel_at, auto_renewing')
.eq('user_id', userData.user.id)
.maybeSingle()
if (error) throw new Error('subscription_lookup_failed')
if (!data) throw new Error('subscription_not_initialized')
if (!VALID_TIERS.has(data.tier)) throw new Error('subscription_tier_invalid')
const providerCandidate = data.provider === 'none' && data.payment_provider !== 'none'
? data.payment_provider
: data.provider
if (!VALID_PROVIDERS.has(providerCandidate)) throw new Error('subscription_provider_invalid')
const { data: catalogData, error: catalogError } = await supabase.functions.invoke('billing-catalog', {
body: {}
})
const catalog = catalogError ? null : parseBillingCatalog(catalogData)
return {
email: userData.user.email ?? '이메일 없음',
subscription: {
tier: data.tier,
status: data.status,
provider: providerCandidate,
current_period_end: data.current_period_end,
cancel_at: data.cancel_at,
auto_renewing: data.auto_renewing
},
catalog
}
}
export default async function BillingPage(): Promise<React.ReactElement> {
const currentTier = await loadCurrentTier()
export default async function BillingPage({ searchParams }: BillingPageProps): Promise<React.ReactElement> {
const params = await searchParams
let state: BillingState
try {
state = await loadBillingState()
} catch {
return <BillingLoadError />
}
const { subscription } = state
const isPaid = subscription.tier !== 'free'
const canPurchase = !isPaid && subscription.provider === 'none'
const cancellationDate = formatDate(subscription.cancel_at)
const periodEnd = formatDate(subscription.current_period_end)
const stripeCanceled = params['canceled'] === '1'
const stripeReturned = params['success'] === '1'
return (
<Box sx={{ p: 4 }}>
<Stack direction="row" alignItems="flex-end" justifyContent="space-between" sx={{ mb: 4 }}>
<Box>
<PhosphorText variant="title" sx={{ mb: 1 }}>
BILLING
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
: <strong>{currentTier === 'pro_plus' ? 'PRO+' : currentTier.toUpperCase()}</strong>
</Box>
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TactileBadge ledColor="amber" tone="accent">D3RO VOICE PRO</TactileBadge>
</Box>
{currentTier !== 'free' && <PaypleManageButton />}
</Stack>
<Grid container spacing={3}>
{PLANS.map((plan) => {
const active = currentTier === plan.tier
return (
<Grid size={{ xs: 12, md: 4 }} key={plan.tier}>
<MetalCard
sx={{
p: 4,
height: '100%',
border: active
? `2px solid ${d3roPalette.accent.main}`
: plan.highlight
? `2px solid ${d3roPalette.tag.purple}`
: undefined,
}}
>
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 1 }}>
{plan.tier === 'pro_plus' ? 'PRO+' : plan.tier.toUpperCase()}
</Box>
<PhosphorText variant="title" sx={{ mb: 1 }}>
{plan.name}
</PhosphorText>
<Box sx={{ ...typoSx('value'), color: d3roPalette.text.primary, mb: 3 }}>
{plan.price}
</Box>
<Box component="ul" sx={{ pl: 2, mb: 3, color: d3roPalette.text.secondary }}>
{plan.features.map((f) => (
<Box component="li" key={f} sx={{ fontSize: 13, mb: 0.5 }}>
{f}
</Box>
))}
</Box>
{active ? (
<Box
sx={{
textAlign: 'center',
p: 1.5,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
color: d3roPalette.accent.main,
...typoSx('label'),
}}
>
</Box>
) : plan.tier === 'free' ? (
<Box sx={{ textAlign: 'center', color: d3roPalette.text.muted, fontSize: 12 }}>
</Box>
) : (
<PaypleCheckoutButton tier={plan.tier} />
)}
</MetalCard>
</Grid>
)
})}
</Grid>
<Box sx={{ mt: 4, color: d3roPalette.text.muted, fontSize: 11 }}>
Payple로 . .
<Typography component="h1" sx={{ fontSize: 28, fontWeight: 500, color: '#fff', letterSpacing: '-0.02em', mb: 1 }}>
</Typography>
<Typography sx={{ fontSize: 14, color: 'var(--d3-text-label)' }}>
provider를 .
</Typography>
</Box>
{stripeCanceled && (
<Alert severity="info" sx={{ mb: 2 }} data-testid="stripe-canceled-message">
Stripe Checkout을 . .
</Alert>
)}
{stripeReturned && (
<Alert severity={isPaid ? 'success' : 'warning'} sx={{ mb: 2 }} data-testid="stripe-return-message">
{isPaid
? 'Stripe 결제가 확인되어 구독 정보가 갱신되었습니다.'
: 'Stripe 결제 결과를 확인 중입니다. 잠시 후 이 페이지를 새로고침해 주세요.'}
</Alert>
)}
<MetalCard sx={{ p: { xs: 2.5, md: 3.5 }, mb: 4 }}>
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" gap={3}>
<Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, letterSpacing: 1.5, color: 'var(--d3-text-label)', mb: 1 }}>
CURRENT SUBSCRIPTION
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography data-testid="billing-current-tier" sx={{ fontSize: 28, fontWeight: 600, color: '#fff' }}>
{tierLabel(subscription.tier)}
</Typography>
<Chip
data-testid="billing-current-provider"
size="small"
label={providerLabel(subscription.provider)}
color={subscription.provider === 'none' ? 'default' : 'primary'}
/>
{subscription.status && <Chip size="small" variant="outlined" label={subscription.status} />}
</Box>
<Typography data-testid="billing-account" sx={{ mt: 1.5, color: 'var(--d3-text-label)', fontSize: 13 }}>
: {state.email}
</Typography>
{cancellationDate ? (
<Typography data-testid="billing-cancel-at" sx={{ mt: 1, color: '#ffb000', fontSize: 12 }}>
{cancellationDate} .
</Typography>
) : periodEnd && isPaid ? (
<Typography sx={{ mt: 1, color: 'var(--d3-text-label)', fontSize: 12 }}>
: {periodEnd}
</Typography>
) : null}
</Box>
<SubscriptionManagement subscription={subscription} />
</Stack>
</MetalCard>
{!canPurchase && !isPaid && (
<Alert severity="warning" sx={{ mb: 3 }}>
provider . .
</Alert>
)}
{!state.catalog && (
<Alert severity="error" sx={{ mb: 3 }} data-testid="billing-catalog-unavailable">
. .
</Alert>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 2.5 }}>
{PLANS.map((plan) => (
<PlanCard
key={plan.tier}
plan={plan}
currentTier={subscription.tier}
canPurchase={canPurchase}
catalog={state.catalog}
/>
))}
</Box>
<Typography sx={{ mt: 4, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 11 }}>
Payple, Stripe가 . provider에서만 .
</Typography>
</Box>
)
}
function PlanCard({
plan,
currentTier,
canPurchase,
catalog
}: {
plan: Plan
currentTier: SubscriptionTier
canPurchase: boolean
catalog: BillingCatalog | null
}): React.ReactElement {
const active = plan.tier === currentTier
const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog
? []
: catalog.plans[plan.tier]
const priceLabel = formatPlanCatalogPrice(plan.tier, catalog) ?? '가격 정보 이용 불가'
return (
<MetalCard
data-testid={`billing-plan-${plan.tier}`}
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
minHeight: 390,
border: active
? '2px solid var(--d3-accent-main)'
: plan.highlight
? '1px solid rgba(59,130,246,0.45)'
: undefined
}}
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: '#fff' }}>{plan.name}</Typography>
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>
{priceLabel}
</Typography>
<Stack spacing={1.5} sx={{ mb: 3 }}>
{plan.features.map((feature) => (
<Box key={feature} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Check size={15} color="var(--d3-accent-main)" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ color: 'var(--d3-text-secondary)', fontSize: 13 }}>{feature}</Typography>
</Box>
))}
</Stack>
</Box>
{active ? (
<Box sx={{ py: 1.5, textAlign: 'center', border: '1px solid var(--d3-border-default)', borderRadius: 2, color: 'var(--d3-text-label)', fontSize: 12 }}>
</Box>
) : plan.tier === 'free' ? (
<Typography sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 12 }}>
.
</Typography>
) : canPurchase && catalogPrices.length > 0 ? (
<BillingCheckoutOptions tier={plan.tier} prices={catalogPrices} />
) : canPurchase ? (
<Typography data-testid={`billing-price-unavailable-${plan.tier}`} sx={{ py: 1.5, textAlign: 'center', color: '#ffb000', fontSize: 12 }}>
.
</Typography>
) : (
<Typography sx={{ py: 1.5, textAlign: 'center', color: 'var(--d3-text-label)', fontSize: 12 }}>
.
</Typography>
)}
</MetalCard>
)
}
function SubscriptionManagement({ subscription }: { subscription: BillingSubscription }): React.ReactElement {
if (subscription.tier === 'free') {
return <Typography sx={{ color: 'var(--d3-text-label)', fontSize: 12 }}> .</Typography>
}
if (subscription.cancel_at || subscription.auto_renewing === false) {
return <Typography sx={{ color: '#ffb000', fontSize: 12 }}> .</Typography>
}
if (subscription.provider === 'payple') return <PaypleManageButton />
if (subscription.provider === 'stripe') return <PortalButton />
if (subscription.provider === 'google_play') {
return <Alert severity="info">Google Play .</Alert>
}
if (subscription.provider === 'app_store') {
return <Alert severity="info">App Store .</Alert>
}
if (subscription.provider === 'admin') {
return <Alert severity="info"> .</Alert>
}
return <Alert severity="warning"> provider를 .</Alert>
}
function BillingLoadError(): React.ReactElement {
return (
<Box sx={{ maxWidth: 720, mx: 'auto', p: 4 }}>
<Alert severity="error" data-testid="billing-load-error" sx={{ mb: 2 }}>
. .
</Alert>
<Button component={Link} href="/billing" variant="outlined"> </Button>
</Box>
)
}

View file

@ -1,5 +1,5 @@
// apps/web/src/app/(app)/chat/page.tsx
// AI 채팅 페이지 — auth/Sidebar는 (app)/layout.tsx가 제공
// apps/web/src/app/(app)/chat/page.tsx
// D3RO-VOICE 음성 대화 (Talk) 페이지
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
@ -7,11 +7,13 @@ import { ChatPanel } from '@/components/chat/chat-panel'
export default function ChatPage(): React.ReactElement {
return (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 3 }}>
CHAT
</PhosphorText>
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 6 }}>
<Box sx={{ mb: 3 }}>
<PhosphorText variant="title">
VOICE INTELLIGENCE TALK
</PhosphorText>
</Box>
<ChatPanel />
</Box>
)
}
}

View file

@ -0,0 +1,496 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Tooltip,
Typography
} from '@mui/material'
import {
ArrowDown,
ArrowRight,
ArrowUp,
Braces,
CheckCircle2,
ClipboardCopy,
Cpu,
Pencil,
Play,
Plus,
RefreshCw,
RotateCcw,
Sparkles,
Square,
Trash2,
Zap
} from 'lucide-react'
import type { SupabaseClient } from '@supabase/supabase-js'
import { DoubleBezelCard, MetalCard, PhosphorText, PhysicalButton, TactileBadge, Led } from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
import { useAuth } from '@/components/providers/auth-provider'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
bootstrapInstructionState,
COMMAND_INPUT_MAX_CHARS,
CommandClientError,
createCustomInstruction,
deleteCustomInstruction,
executeInstruction,
normalizeInstructionDraft,
reorderCustomInstruction,
setActiveInstruction,
sortInstructions,
updateCustomInstruction,
type CustomInstruction,
type InstructionDraft,
type InstructionState
} from '@/lib/command-client'
function commandMessage(error: unknown): string {
if (error instanceof CommandClientError) {
if (error.code === 'auth') return '세션이 만료되었거나 이 명령에 접근할 권한이 없습니다.'
if (error.code === 'cancelled') return '명령 실행을 취소했습니다.'
if (error.code === 'conflict') return '다른 기기에서 변경된 명령입니다. 새로고침한 뒤 다시 시도해 주세요.'
if (error.code === 'duplicate') return '같은 이름의 명령이 이미 있습니다.'
if (error.code === 'invalid-request') return '이름, 설명, 프롬프트 또는 입력 길이를 확인해 주세요.'
if (error.code === 'not-found') return '명령을 찾을 수 없습니다.'
if (error.code === 'quota-exceeded') return '오늘 사용할 수 있는 AI 쿼터를 모두 사용했습니다.'
if (error.code === 'model-not-allowed') return '현재 요금제에서 사용할 수 없는 모델입니다.'
if (error.code === 'provider-unavailable') return 'AI 제공자가 설정되지 않았거나 현재 응답할 수 없습니다.'
if (error.code === 'timeout') return 'AI 응답 시간이 초과되었습니다.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
if (error.code === 'invalid-response') return '서버 응답 형식이 올바르지 않습니다.'
}
return '명령을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function webClient(): SupabaseClient {
return getSupabaseBrowserClient() as unknown as SupabaseClient
}
const EMPTY_DRAFT: InstructionDraft = { name: '', description: '', prompt: '' }
export default function CommandsPage(): React.ReactElement {
const { user, session, loading: authLoading } = useAuth()
const [state, setState] = useState<InstructionState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<CustomInstruction | null>(null)
const [draft, setDraft] = useState<InstructionDraft>(EMPTY_DRAFT)
const [saving, setSaving] = useState(false)
const [testInput, setTestInput] = useState('')
const [testOutput, setTestOutput] = useState<string | null>(null)
const [testError, setTestError] = useState<string | null>(null)
const [retryable, setRetryable] = useState(false)
const [testing, setTesting] = useState(false)
const controllerRef = useRef<AbortController | null>(null)
const requestGeneration = useRef(0)
const initializationRef = useRef<Promise<InstructionState> | null>(null)
const activeInstruction = state?.instructions.find((instruction) => instruction.id === state.activeInstructionId) ?? null
const load = useCallback(async (): Promise<void> => {
const generation = ++requestGeneration.current
if (!user) {
if (!authLoading) {
setState(null)
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
setLoading(false)
}
return
}
setLoading(true)
setError(null)
try {
const client = webClient()
let initialization = initializationRef.current
if (!initialization) {
initialization = (async (): Promise<InstructionState> => {
let loaded = await bootstrapInstructionState(client, user.id)
if (!loaded.activeInstructionId && loaded.instructions.length > 0) {
const defaultInstruction = loaded.instructions.find((instruction) => instruction.builtinKey === 'translate_en') ?? loaded.instructions[0]
const activated = await setActiveInstruction(client, user.id, defaultInstruction.id)
loaded = { ...loaded, ...activated }
}
return loaded
})()
initializationRef.current = initialization
}
const loaded = await initialization
if (generation === requestGeneration.current) setState(loaded)
} catch (requestError) {
if (generation === requestGeneration.current) {
setState(null)
setError(commandMessage(requestError))
}
} finally {
initializationRef.current = null
if (generation === requestGeneration.current) setLoading(false)
}
}, [authLoading, user])
useEffect(() => {
void load()
}, [load])
useEffect(() => () => controllerRef.current?.abort(), [])
const markMutating = (id: string, active: boolean): void => {
setMutatingIds((current) => {
const next = new Set(current)
if (active) next.add(id)
else next.delete(id)
return next
})
}
const activate = async (instruction: CustomInstruction): Promise<void> => {
if (!user || !state || mutatingIds.has(instruction.id) || state.activeInstructionId === instruction.id) return
const previousId = state.activeInstructionId
markMutating(instruction.id, true)
setError(null)
setState((current) => current ? { ...current, activeInstructionId: instruction.id } : current)
try {
const activated = await setActiveInstruction(webClient(), user.id, instruction.id)
setState((current) => current ? { ...current, ...activated } : current)
setTestOutput(null)
setTestError(null)
} catch (requestError) {
setState((current) => current ? { ...current, activeInstructionId: previousId } : current)
setError(commandMessage(requestError))
} finally {
markMutating(instruction.id, false)
}
}
const openAdd = (): void => {
setEditing(null)
setDraft(EMPTY_DRAFT)
setDialogOpen(true)
}
const openEdit = (instruction: CustomInstruction): void => {
if (instruction.builtinKey !== null) return
setEditing(instruction)
setDraft({ name: instruction.name, description: instruction.description, prompt: instruction.prompt })
setDialogOpen(true)
}
const save = async (): Promise<void> => {
if (!user || !state || saving) return
let normalized: InstructionDraft
try {
normalized = normalizeInstructionDraft(draft)
} catch (requestError) {
setError(commandMessage(requestError))
return
}
setSaving(true)
setError(null)
const original = editing
const now = new Date().toISOString()
const optimisticId = original?.id ?? crypto.randomUUID()
const nextSortOrder = state.instructions.reduce((maximum, instruction) => Math.max(maximum, instruction.sortOrder), 0) + 10
const optimistic: CustomInstruction = {
id: optimisticId,
userId: user.id,
builtinKey: null,
name: normalized.name,
description: normalized.description,
prompt: normalized.prompt,
icon: original?.icon ?? 'sparkles',
sortOrder: original?.sortOrder ?? nextSortOrder,
revision: (original?.revision ?? 0) + 1,
createdAt: original?.createdAt ?? now,
updatedAt: now
}
setState((current) => current ? {
...current,
instructions: sortInstructions(original
? current.instructions.map((instruction) => instruction.id === original.id ? optimistic : instruction)
: [...current.instructions, optimistic])
} : current)
setDialogOpen(false)
try {
const saved = original
? await updateCustomInstruction(webClient(), user.id, original, normalized)
: await createCustomInstruction(webClient(), user.id, normalized, nextSortOrder)
setState((current) => current ? {
...current,
instructions: sortInstructions([
...current.instructions.filter((instruction) => instruction.id !== optimisticId && instruction.id !== saved.id),
saved
])
} : current)
} catch (requestError) {
setState((current) => current ? {
...current,
instructions: sortInstructions([
...current.instructions.filter((instruction) => instruction.id !== optimisticId),
...(original ? [original] : [])
])
} : current)
setError(commandMessage(requestError))
} finally {
setSaving(false)
}
}
const remove = async (instruction: CustomInstruction): Promise<void> => {
if (!user || !state || instruction.builtinKey !== null || mutatingIds.has(instruction.id)) return
if (!window.confirm(`${instruction.name}” 명령을 삭제할까요?`)) return
markMutating(instruction.id, true)
setError(null)
let nextActiveId = state.activeInstructionId
try {
if (state.activeInstructionId === instruction.id) {
const fallback = state.instructions.find((candidate) => candidate.builtinKey === 'translate_en')
if (!fallback) throw new CommandClientError('invalid-response', true)
const activated = await setActiveInstruction(webClient(), user.id, fallback.id)
nextActiveId = activated.activeInstructionId
setState((current) => current ? { ...current, ...activated } : current)
}
setState((current) => current ? {
...current,
activeInstructionId: nextActiveId,
instructions: current.instructions.filter((candidate) => candidate.id !== instruction.id)
} : current)
await deleteCustomInstruction(webClient(), user.id, instruction)
} catch (requestError) {
setState((current) => current ? {
...current,
activeInstructionId: nextActiveId,
instructions: current.instructions.some((candidate) => candidate.id === instruction.id)
? current.instructions
: sortInstructions([...current.instructions, instruction])
} : current)
setError(commandMessage(requestError))
} finally {
markMutating(instruction.id, false)
}
}
const move = async (instruction: CustomInstruction, direction: 'up' | 'down'): Promise<void> => {
if (!user || !state || instruction.builtinKey !== null || mutatingIds.has(instruction.id)) return
const customs = state.instructions.filter((candidate) => candidate.builtinKey === null)
const index = customs.findIndex((candidate) => candidate.id === instruction.id)
const adjacent = customs[index + (direction === 'up' ? -1 : 1)]
if (!adjacent) return
const reorderedCustoms = [...customs]
reorderedCustoms[index] = adjacent
reorderedCustoms[index + (direction === 'up' ? -1 : 1)] = instruction
const now = new Date().toISOString()
const optimisticCustoms = reorderedCustoms.map((candidate, position) => ({
...candidate,
sortOrder: 1_000 + (position + 1) * 10,
revision: candidate.revision + 1,
updatedAt: now
}))
const previousInstructions = state.instructions
markMutating(instruction.id, true)
setError(null)
setState((current) => current ? {
...current,
instructions: sortInstructions([
...current.instructions.filter((candidate) => candidate.builtinKey !== null),
...optimisticCustoms
])
} : current)
try {
const instructions = await reorderCustomInstruction(webClient(), user.id, instruction.id, direction)
setState((current) => current ? { ...current, instructions } : current)
} catch (requestError) {
setState((current) => current ? { ...current, instructions: previousInstructions } : current)
setError(commandMessage(requestError))
} finally {
markMutating(instruction.id, false)
}
}
const run = async (): Promise<void> => {
if (testing || !testInput.trim() || !activeInstruction) return
if (!session?.access_token) {
setTestError('세션이 만료되었습니다. 다시 로그인해 주세요.')
setRetryable(false)
return
}
const controller = new AbortController()
controllerRef.current?.abort()
controllerRef.current = controller
setTesting(true)
setTestOutput(null)
setTestError(null)
setRetryable(false)
try {
const output = await executeInstruction(activeInstruction.prompt, testInput, {
accessToken: session.access_token,
signal: controller.signal
})
if (!controller.signal.aborted) setTestOutput(output)
} catch (requestError) {
setTestError(commandMessage(requestError))
setRetryable(requestError instanceof CommandClientError && requestError.retryable)
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null
setTesting(false)
}
}
}
if (authLoading || loading) {
return <Box role="status" sx={{ minHeight: 360, display: 'grid', placeItems: 'center' }}><CircularProgress aria-label="명령 불러오는 중" /></Box>
}
if (!state) {
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4 }}>
<Alert severity="error" action={<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}> </Button>}>
{error ?? '명령을 불러오지 못했습니다.'}
</Alert>
</Box>
)
}
const customInstructions = state.instructions.filter((instruction) => instruction.builtinKey === null)
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<DoubleBezelCard innerPadding={3} sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 44, height: 44, borderRadius: '12px', bgcolor: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)', display: 'grid', placeItems: 'center', color: 'var(--d3-accent-main)' }}><Zap size={22} /></Box>
<Box>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 10, color: 'var(--d3-text-label)' }}>ACTIVE SYNCED INSTRUCTION</Typography>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: '#fff' }}>{activeInstruction?.name ?? '활성 명령 없음'}</Typography>
</Box>
</Box>
<TactileBadge ledColor="green" tone="success">SUPABASE SSOT · REV {state.settingsRevision}</TactileBadge>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(4, 1fr)' }, gap: 1.5, p: 2, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid #1a1a1c' }}>
<PipelineStep icon={<Braces size={15} />} step="01" label="텍스트 입력" />
<PipelineStep icon={<CheckCircle2 size={15} />} step="02" label="활성 명령 적용" />
<PipelineStep icon={<Cpu size={15} />} step="03" label="llm-proxy" accent />
<PipelineStep icon={<ClipboardCopy size={15} />} step="04" label="검증된 응답" last />
</Box>
</DoubleBezelCard>
{error && <Alert severity="error" action={<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}></Button>} sx={{ mb: 3 }}>{error}</Alert>}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 2 }}>
<PhosphorText variant="label">SYNCED COMMANDS ({state.instructions.length})</PhosphorText>
<PhysicalButton tone="accent" size="small" onClick={openAdd} trailingIcon={<Plus size={14} />}> </PhysicalButton>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mb: 4 }}>
{state.instructions.map((instruction) => {
const active = instruction.id === state.activeInstructionId
const busy = mutatingIds.has(instruction.id)
const customIndex = customInstructions.findIndex((candidate) => candidate.id === instruction.id)
return (
<Box
key={instruction.id}
role="button"
tabIndex={busy ? -1 : 0}
aria-pressed={active}
aria-label={`${instruction.name} 명령 활성화`}
data-testid={`command-card-${instruction.id}`}
onClick={() => { if (!busy) void activate(instruction) }}
onKeyDown={(event) => {
if (!busy && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
void activate(instruction)
}
}}
sx={{ cursor: busy ? 'default' : 'pointer', border: active ? '1px solid var(--d3-accent-main)' : '1px solid transparent', borderRadius: '16px' }}
>
<MetalCard sx={{ p: 2.5, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<Box sx={{ pt: 0.5 }}><Led color={active ? 'green' : 'amber'} size={8} /></Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
<Typography sx={{ fontSize: 16, fontWeight: 500, color: active ? 'var(--d3-accent-main)' : '#fff' }}>{instruction.name}</Typography>
<TactileBadge mono tone={instruction.builtinKey ? 'mono' : 'accent'}>{instruction.builtinKey ? 'BUILT-IN · READ ONLY' : 'CUSTOM'}</TactileBadge>
{active && <TactileBadge mono tone="success">ACTIVE</TactileBadge>}
</Box>
<Typography sx={{ fontSize: 13, color: 'var(--d3-text-label)', lineHeight: 1.5 }}>{instruction.description || '설명 없음'}</Typography>
</Box>
{instruction.builtinKey === null && (
<Box sx={{ display: 'flex', gap: 0.1 }} onClick={(event) => event.stopPropagation()}>
<Tooltip title="위로"><span><IconButton aria-label={`${instruction.name} 위로`} disabled={busy || customIndex <= 0} size="small" onClick={() => void move(instruction, 'up')}><ArrowUp size={14} /></IconButton></span></Tooltip>
<Tooltip title="아래로"><span><IconButton aria-label={`${instruction.name} 아래로`} disabled={busy || customIndex === customInstructions.length - 1} size="small" onClick={() => void move(instruction, 'down')}><ArrowDown size={14} /></IconButton></span></Tooltip>
<Tooltip title="편집"><span><IconButton aria-label={`${instruction.name} 편집`} disabled={busy} size="small" onClick={() => openEdit(instruction)}><Pencil size={14} /></IconButton></span></Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label={`${instruction.name} 삭제`} disabled={busy} size="small" onClick={() => void remove(instruction)}><Trash2 size={14} /></IconButton></span></Tooltip>
</Box>
)}
</Box>
</MetalCard>
</Box>
)
})}
</Box>
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block' }}>PIPELINE TEST BENCH</PhosphorText>
<MetalCard sx={{ p: 3 }}>
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, alignItems: 'flex-start', flexDirection: { xs: 'column', sm: 'row' } }}>
<TextField
value={testInput}
onChange={(event) => setTestInput(event.target.value)}
placeholder="처리할 텍스트를 입력하세요..."
multiline
minRows={3}
fullWidth
inputProps={{ 'aria-label': '명령 테스트 입력', maxLength: COMMAND_INPUT_MAX_CHARS }}
helperText={`${testInput.length.toLocaleString()} / ${COMMAND_INPUT_MAX_CHARS.toLocaleString()} · ${activeInstruction?.name ?? '활성 명령 없음'}`}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'var(--d3-bg-inset)', color: 'var(--d3-text-secondary)', borderRadius: '12px' } }}
/>
{testing ? (
<PhysicalButton tone="glass" onClick={() => controllerRef.current?.abort()}><Square size={14} style={{ marginRight: 4 }} /> </PhysicalButton>
) : (
<PhysicalButton tone="accent" onClick={() => void run()} disabled={!testInput.trim() || !activeInstruction}><Play size={14} style={{ marginRight: 4 }} /> </PhysicalButton>
)}
</Box>
{testing && <PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}> AI ...</PhosphorText>}
{testError && <Alert severity="error" data-testid="command-error" action={retryable ? <Button color="inherit" size="small" startIcon={<RotateCcw size={14} />} onClick={() => void run()}> </Button> : undefined}>{testError}</Alert>}
{testOutput && (
<Box data-testid="command-output" sx={{ p: 2.5, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid var(--d3-border-default)', display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<Sparkles size={16} color="var(--d3-accent-main)" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: 14, color: 'var(--d3-text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{testOutput}</Typography>
</Box>
)}
</MetalCard>
<Dialog open={dialogOpen} onClose={() => { if (!saving) setDialogOpen(false) }} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}>
<DialogTitle sx={{ color: '#fff', fontWeight: 500 }}>{editing ? '사용자 명령 편집' : '사용자 명령 추가'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField label="명령 이름" value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} inputProps={{ maxLength: 80 }} autoFocus fullWidth size="small" sx={{ mt: 1 }} />
<TextField label="명령 설명" value={draft.description} onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))} inputProps={{ maxLength: 240 }} fullWidth size="small" />
<TextField label="명령 프롬프트" value={draft.prompt} onChange={(event) => setDraft((current) => ({ ...current, prompt: event.target.value }))} inputProps={{ maxLength: 4_000 }} helperText="{{text}}를 넣으면 해당 위치에 입력문이 삽입됩니다. 없으면 프롬프트 뒤에 붙습니다." multiline minRows={5} fullWidth />
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<PhysicalButton tone="glass" disabled={saving} onClick={() => setDialogOpen(false)}></PhysicalButton>
<PhysicalButton tone="accent" disabled={saving || !draft.name.trim() || !draft.prompt.trim()} onClick={() => void save()}>{saving ? '저장 중...' : '저장'}</PhysicalButton>
</DialogActions>
</Dialog>
</Box>
)
}
function PipelineStep({ icon, step, label, accent = false, last = false }: { icon: React.ReactNode; step: string; label: string; accent?: boolean; last?: boolean }): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 32, height: 32, borderRadius: '50%', bgcolor: accent ? 'rgba(59,130,246,0.2)' : 'var(--d3-bg-elevated)', display: 'grid', placeItems: 'center', color: accent ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)' }}>{icon}</Box>
<Box sx={{ flex: 1 }}><Typography sx={{ fontFamily: d3roFontMono, fontSize: 9, color: 'var(--d3-text-label)' }}>STEP {step}</Typography><Typography sx={{ fontSize: 12, fontWeight: 600, color: accent ? 'var(--d3-accent-main)' : '#fff' }}>{label}</Typography></Box>
{!last && <ArrowRight size={14} color="var(--d3-text-label)" />}
</Box>
)
}

View file

@ -1,182 +1,281 @@
// apps/web/src/app/dashboard/page.tsx
// 대시보드 — 요약 카드 4개 + 최근 회의
'use client'
import Link from 'next/link'
import { Box, Grid, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { useCallback, useEffect, useState } from 'react'
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
import {
MeetingsTrendChart,
type TrendPoint
} from '@/components/dashboard/meetings-trend-chart'
ArrowUpRight,
BookOpen,
CalendarDays,
Clock3,
Database,
Flame,
Mic,
RefreshCw,
ShieldCheck,
Star
} from 'lucide-react'
import type { D3roSupabaseClient } from '@d3ro/api-client'
import {
DoubleBezelCard,
MetalCard,
PhosphorText,
PhysicalButton,
StatRing,
TactileBadge,
TiltCard
} from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roFontSans, d3roPalette } from '@d3ro/ui/theme'
import { useAuth } from '@/components/providers/auth-provider'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
DashboardClientError,
loadDashboardSnapshot,
type DashboardSnapshot
} from '@/lib/dashboard-client'
interface Stat {
label: string
value: string
hint?: string
}
const TREND_DAYS = 14
function buildEmptyTrend(days: number): TrendPoint[] {
const out: TrendPoint[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(now)
d.setDate(now.getDate() - i)
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
out.push({ date: `${m}/${day}`, meetings: 0, documents: 0 })
function dashboardMessage(error: unknown): string {
if (error instanceof DashboardClientError) {
if (error.code === 'auth') return '세션이 만료되었습니다. 다시 로그인해 주세요.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
if (error.code === 'not-initialized') return '계정 구독 정보가 아직 초기화되지 않았습니다.'
if (error.code === 'invalid-response') return '서버 데이터 형식이 올바르지 않습니다.'
}
return out
return '대시보드 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function bumpTrend(points: TrendPoint[], isoTimestamp: string, key: 'meetings' | 'documents'): void {
const created = new Date(isoTimestamp)
const m = String(created.getMonth() + 1).padStart(2, '0')
const d = String(created.getDate()).padStart(2, '0')
const label = `${m}/${d}`
const point = points.find((p) => p.date === label)
if (point) point[key] += 1
function tierLabel(tier: DashboardSnapshot['subscription']['tier']): string {
if (tier === 'pro_plus') return 'PRO+'
return tier.toUpperCase()
}
async function loadDashboardData(): Promise<{
stats: Stat[]
recentMeetings: Array<{ id: string; title: string; started_at: string }>
trend: TrendPoint[]
}> {
const trend = buildEmptyTrend(TREND_DAYS)
try {
const supabase = await getSupabaseServerClient()
const since = new Date()
since.setDate(since.getDate() - (TREND_DAYS - 1))
since.setHours(0, 0, 0, 0)
const sinceIso = since.toISOString()
const [
{ count: meetingCount },
{ data: recent },
{ data: meetingsTrendRows },
{ data: documentsTrendRows }
] = await Promise.all([
supabase.from('meetings').select('*', { count: 'exact', head: true }),
supabase
.from('meetings')
.select('id, title, started_at')
.order('started_at', { ascending: false })
.limit(5),
supabase.from('meetings').select('started_at').gte('started_at', sinceIso),
supabase.from('meeting_documents').select('created_at').gte('created_at', sinceIso)
])
;(meetingsTrendRows ?? []).forEach((row) => {
if (row.started_at) bumpTrend(trend, row.started_at, 'meetings')
})
;(documentsTrendRows ?? []).forEach((row) => {
if (row.created_at) bumpTrend(trend, row.created_at, 'documents')
})
const thisWeek = trend.slice(-7).reduce((acc, p) => acc + p.meetings, 0)
const stats: Stat[] = [
{ label: '총 회의', value: String(meetingCount ?? 0), hint: '전체 기간' },
{ label: '이번 주', value: String(thisWeek), hint: '최근 7일' },
{ label: '구독 티어', value: 'Free', hint: '업그레이드 가능' },
{ label: '쿼터 사용', value: '0 / 50', hint: '오늘' }
]
return {
stats,
recentMeetings: (recent ?? []).map((m) => ({
id: m.id,
title: m.title ?? '(제목 없음)',
started_at: m.started_at
})),
trend
}
} catch {
return {
stats: [
{ label: '총 회의', value: '—' },
{ label: '이번 주', value: '—' },
{ label: '구독 티어', value: '—' },
{ label: '쿼터 사용', value: '—' }
],
recentMeetings: [],
trend
}
function providerLabel(provider: DashboardSnapshot['subscription']['provider']): string {
const labels: Record<DashboardSnapshot['subscription']['provider'], string> = {
none: '미연결',
stripe: 'Stripe',
payple: '페이플',
google_play: 'Google Play',
app_store: 'App Store',
admin: '관리자 부여'
}
return labels[provider]
}
export default async function DashboardPage(): Promise<React.ReactElement> {
const { stats, recentMeetings, trend } = await loadDashboardData()
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const remaining = total % 60
if (hours > 0) return `${hours}시간 ${minutes}`
if (minutes > 0) return `${minutes}${remaining}`
return `${remaining}`
}
export default function DashboardPage(): React.ReactElement {
const { user, loading: authLoading } = useAuth()
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async (): Promise<void> => {
if (!user) {
if (!authLoading) {
setSnapshot(null)
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
setLoading(false)
}
return
}
setLoading(true)
setError(null)
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
setSnapshot(await loadDashboardSnapshot(client, user.id))
} catch (requestError) {
setSnapshot(null)
setError(dashboardMessage(requestError))
} finally {
setLoading(false)
}
}, [authLoading, user])
useEffect(() => {
void load()
}, [load])
if (authLoading || loading) {
return (
<Box role="status" sx={{ minHeight: 360, display: 'grid', placeItems: 'center' }}>
<CircularProgress aria-label="대시보드 불러오는 중" />
</Box>
)
}
if (error || !snapshot) {
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4 }}>
<Alert
severity="error"
action={(
<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}>
</Button>
)}
>
{error ?? '대시보드 데이터를 불러오지 못했습니다.'}
</Alert>
</Box>
)
}
const { stats, subscription } = snapshot
const generatedAt = new Date(stats.generatedAt)
const tiles = [
{ label: '오늘 녹음 시간', value: formatDuration(stats.todayRecordingSeconds), icon: <Mic size={20} />, ring: 'blue' as const },
{ label: '전체 단어 수', value: stats.totalWordCount.toLocaleString(), icon: <BookOpen size={20} />, ring: 'accent' as const },
{ label: '오늘 세션', value: stats.todaySessions.toLocaleString(), icon: <CalendarDays size={20} />, ring: 'green' as const },
{ label: '연속 사용', value: `${stats.streakDays}`, icon: <Flame size={20} />, ring: 'orange' as const }
]
return (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 4 }}>
DASHBOARD
</PhosphorText>
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<DoubleBezelCard innerPadding={3.5}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3, gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<TactileBadge ledColor="green" tone="success">SUPABASE SSOT</TactileBadge>
<TactileBadge mono tone="mono">RLS ACCOUNT DATA</TactileBadge>
</Box>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }} data-testid="dashboard-generated-at">
{generatedAt.toLocaleString('ko-KR')}
</PhosphorText>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
{stats.map((stat) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
<MetalCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ color: d3roPalette.text.label, ...typoSx("label"), mb: 1 }}>
{stat.label}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr auto' }, gap: 3, alignItems: 'center' }}>
<Box>
<PhosphorText variant="hero" sx={{ mb: 1, letterSpacing: '-0.02em', lineHeight: 1.15 }}>
</PhosphorText>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 2.5 }}>
.
</PhosphorText>
<Link href="/record" style={{ textDecoration: 'none' }}>
<PhysicalButton tone="accent" trailingIcon={<ArrowUpRight size={13} />}>
<Mic size={15} />
</PhysicalButton>
</Link>
</Box>
<Box sx={{ display: 'flex', gap: 4 }}>
{[
{ label: '전체 세션', value: stats.totalSessions.toLocaleString() },
{ label: '전체 녹음', value: formatDuration(stats.totalRecordingSeconds) }
].map((item) => (
<Box key={item.label}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.75 }}>
{item.label}
</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: { xs: 24, md: 30 }, fontWeight: 500, color: d3roPalette.text.primary }}>
{item.value}
</Typography>
</Box>
<PhosphorText variant="title">
{stat.value}
</PhosphorText>
{stat.hint && (
<Box sx={{ mt: 1, color: d3roPalette.text.muted, fontSize: 11 }}>{stat.hint}</Box>
)}
</MetalCard>
</Grid>
))}
</Box>
</Box>
</DoubleBezelCard>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2, mt: 3 }}>
{tiles.map((tile) => (
<TiltCard key={tile.label} maxTilt={6} sx={{ p: 2.5 }} data-testid={`dashboard-stat-${tile.label}`}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color={tile.ring} size={54} thickness={2.5}>{tile.icon}</StatRing>
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
{tile.label}
</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: 24, fontWeight: 500, color: d3roPalette.text.primary }}>
{tile.value}
</Typography>
</Box>
</Box>
</TiltCard>
))}
</Grid>
</Box>
<MetalCard sx={{ p: 3, mb: 4 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
ACTIVITY TREND ({TREND_DAYS}D)
</PhosphorText>
<MeetingsTrendChart points={trend} />
</MetalCard>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mt: 3 }}>
<MetalCard sx={{ p: 2.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Database size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading"> </PhosphorText>
</Box>
<Box sx={{ display: 'grid', gap: 1.25 }}>
<DataLine label="기록 범위" value="완료된 내 전사 기록" />
<DataLine label="일자 기준" value="UTC" />
<DataLine label="오늘 단어" value={stats.todayWordCount.toLocaleString()} />
</Box>
</MetalCard>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
RECENT MEETINGS
</PhosphorText>
<Stack spacing={2}>
{recentMeetings.length === 0 ? (
<MetalCard sx={{ p: 4, textAlign: 'center', color: d3roPalette.text.muted }}>
.
<MetalCard sx={{ p: 2.75 }} data-testid="dashboard-subscription">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Star size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading"> </PhosphorText>
</Box>
<TactileBadge tone={subscription.tier === 'free' ? 'mono' : 'accent'}>
{tierLabel(subscription.tier)}
</TactileBadge>
</Box>
<Box sx={{ display: 'grid', gap: 1.25 }}>
<DataLine label="상태" value={subscription.status ?? '미설정'} />
<DataLine label="결제 제공자" value={providerLabel(subscription.provider)} />
<DataLine label="추가 크레딧" value={subscription.overageCredits.toLocaleString()} />
{subscription.currentPeriodEnd && <DataLine label="현재 기간 종료" value={new Date(subscription.currentPeriodEnd).toLocaleDateString('ko-KR')} />}
{subscription.cancelAt && <DataLine label="해지 예정" value={new Date(subscription.cancelAt).toLocaleDateString('ko-KR')} />}
</Box>
<Button component={Link} href="/billing" size="small" endIcon={<ArrowUpRight size={13} />} sx={{ mt: 2 }}>
</Button>
</MetalCard>
</Box>
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1.5, mb: 2, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldCheck size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading"> </PhosphorText>
</Box>
<Button component={Link} href="/history" size="small"> </Button>
</Box>
{stats.recentHistory.length === 0 ? (
<MetalCard sx={{ p: 5, textAlign: 'center', color: d3roPalette.text.muted }} data-testid="dashboard-empty-history">
.
</MetalCard>
) : (
recentMeetings.map((meeting) => (
<Link
key={meeting.id}
href={`/meetings/${meeting.id}`}
style={{ textDecoration: 'none' }}
>
<MetalCard
sx={{
p: 3,
cursor: 'pointer',
transition: 'transform 120ms ease, box-shadow 120ms ease',
'&:hover': { transform: 'translateY(-1px)' }
}}
>
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')}
</Box>
</MetalCard>
</Link>
))
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' }, gap: 1.5 }}>
{stats.recentHistory.map((entry) => (
<Link key={entry.id} href={`/history/${entry.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
<MetalCard sx={{ p: 2.5, height: '100%' }} data-testid={`dashboard-history-${entry.id}`}>
{entry.title && <Typography sx={{ color: d3roPalette.text.primary, fontWeight: 500, mb: 0.75 }}>{entry.title}</Typography>}
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: 14, lineHeight: 1.6, overflowWrap: 'anywhere' }}>
{entry.text}
</Typography>
<Box sx={{ mt: 1.5, display: 'flex', gap: 1.5, color: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 11 }}>
<span>{new Date(entry.createdAt).toLocaleString('ko-KR')}</span>
<span><Clock3 size={11} style={{ verticalAlign: 'middle' }} /> {formatDuration(entry.durationSeconds)}</span>
</Box>
</MetalCard>
</Link>
))}
</Box>
)}
</Stack>
</Box>
</Box>
)
}
function DataLine({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>{label}</PhosphorText>
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.primary, textAlign: 'right' }}>{value}</PhosphorText>
</Box>
)
}

View file

@ -0,0 +1,419 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Select,
TextField,
Tooltip,
Typography
} from '@mui/material'
import { Pencil, Plus, RefreshCw, Search, Trash2 } from 'lucide-react'
import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client'
import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
import { useAuth } from '@/components/providers/auth-provider'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
createDictionaryEntry,
deleteDictionaryEntry,
DictionaryClientError,
listDictionaryPage,
normalizeDictionaryDraft,
sanitizeDictionarySearch,
updateDictionaryEntry,
type DictionaryCategory,
type DictionaryCursor,
type DictionaryDraft,
type DictionaryFilter
} from '@/lib/dictionary-client'
const PAGE_SIZE = 20
const SEARCH_DELAY_MS = 300
const CATEGORY_LABELS: Record<DictionaryCategory, string> = {
user: '사용자',
technical: '기술',
auto: '자동'
}
function dictionaryMessage(error: unknown): string {
if (error instanceof DictionaryClientError) {
if (error.code === 'auth') return '세션이 만료되었습니다. 다시 로그인해 주세요.'
if (error.code === 'conflict') return '다른 기기에서 변경된 단어입니다. 목록을 새로고침한 뒤 다시 시도해 주세요.'
if (error.code === 'duplicate') return '같은 분류에 동일한 단어가 이미 있습니다.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
if (error.code === 'validation') return error.message
}
return '사전을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function sortDictionary(entries: DictionaryEntry[]): DictionaryEntry[] {
return [...entries].sort((left, right) => {
const byDate = right.updated_at.localeCompare(left.updated_at)
return byDate !== 0 ? byDate : right.id.localeCompare(left.id)
})
}
function matchesView(entry: DictionaryEntry, category: DictionaryFilter, search: string): boolean {
if (category !== 'all' && entry.category !== category) return false
const normalized = sanitizeDictionarySearch(search).toLocaleLowerCase()
if (!normalized) return true
return entry.word.toLocaleLowerCase().includes(normalized)
|| (entry.pronunciation?.toLocaleLowerCase().includes(normalized) ?? false)
}
export default function DictionaryPage(): React.ReactElement {
const { user, loading: authLoading } = useAuth()
const [entries, setEntries] = useState<DictionaryEntry[]>([])
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [category, setCategory] = useState<DictionaryFilter>('all')
const [nextCursor, setNextCursor] = useState<DictionaryCursor | null>(null)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<DictionaryEntry | null>(null)
const [draft, setDraft] = useState<DictionaryDraft>({ word: '', pronunciation: null, category: 'user' })
const [saving, setSaving] = useState(false)
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
const requestGeneration = useRef(0)
useEffect(() => {
const timer = window.setTimeout(() => setSearch(searchInput), SEARCH_DELAY_MS)
return () => window.clearTimeout(timer)
}, [searchInput])
const loadFirstPage = useCallback(async (): Promise<void> => {
const generation = ++requestGeneration.current
if (!user) {
if (!authLoading) {
setLoading(false)
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
}
return
}
setLoading(true)
setError(null)
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
const result = await listDictionaryPage(client, {
userId: user.id,
search,
category,
pageSize: PAGE_SIZE,
cursor: null
})
if (generation !== requestGeneration.current) return
setEntries(result.entries)
setNextCursor(result.nextCursor)
setTotal(result.total)
} catch (requestError) {
if (generation !== requestGeneration.current) return
setEntries([])
setNextCursor(null)
setTotal(0)
setError(dictionaryMessage(requestError))
} finally {
if (generation === requestGeneration.current) setLoading(false)
}
}, [authLoading, category, search, user])
useEffect(() => {
void loadFirstPage()
}, [loadFirstPage])
const loadMore = async (): Promise<void> => {
if (!user || loading || loadingMore || !nextCursor) return
const generation = requestGeneration.current
setLoadingMore(true)
setError(null)
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
const result = await listDictionaryPage(client, {
userId: user.id,
search,
category,
pageSize: PAGE_SIZE,
cursor: nextCursor
})
if (generation !== requestGeneration.current) return
setEntries((current) => {
const rows = new Map(current.map((entry) => [entry.id, entry]))
for (const entry of result.entries) rows.set(entry.id, entry)
return sortDictionary([...rows.values()])
})
setNextCursor(result.nextCursor)
} catch (requestError) {
if (generation === requestGeneration.current) setError(dictionaryMessage(requestError))
} finally {
if (generation === requestGeneration.current) setLoadingMore(false)
}
}
const openAdd = (): void => {
setEditing(null)
setDraft({ word: '', pronunciation: null, category: 'user' })
setDialogOpen(true)
}
const openEdit = (entry: DictionaryEntry): void => {
setEditing(entry)
setDraft({ word: entry.word, pronunciation: entry.pronunciation, category: entry.category })
setDialogOpen(true)
}
const save = async (): Promise<void> => {
if (!user || saving) return
let normalized: DictionaryDraft
try {
normalized = normalizeDictionaryDraft(draft)
} catch (requestError) {
setError(dictionaryMessage(requestError))
return
}
setSaving(true)
setError(null)
const now = new Date().toISOString()
const original = editing
const optimisticId = original?.id ?? crypto.randomUUID()
const optimistic: DictionaryEntry = {
id: optimisticId,
user_id: user.id,
word: normalized.word,
pronunciation: normalized.pronunciation,
category: normalized.category,
usage_count: original?.usage_count ?? 0,
last_used_at: original?.last_used_at ?? null,
created_at: original?.created_at ?? now,
updated_at: now
}
const wasVisible = original ? matchesView(original, category, search) : false
const willBeVisible = matchesView(optimistic, category, search)
setEntries((current) => {
if (original) {
const without = current.filter((entry) => entry.id !== original.id)
return willBeVisible ? sortDictionary([optimistic, ...without]) : without
}
return willBeVisible ? sortDictionary([optimistic, ...current]) : current
})
if (!original && willBeVisible) setTotal((value) => value + 1)
if (original && wasVisible && !willBeVisible) setTotal((value) => Math.max(0, value - 1))
if (original && !wasVisible && willBeVisible) setTotal((value) => value + 1)
setDialogOpen(false)
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
const saved = original
? await updateDictionaryEntry(client, user.id, original, normalized)
: await createDictionaryEntry(client, user.id, normalized)
setEntries((current) => {
const without = current.filter((entry) => entry.id !== optimisticId && entry.id !== saved.id)
return matchesView(saved, category, search) ? sortDictionary([saved, ...without]) : without
})
} catch (requestError) {
setEntries((current) => {
const withoutOptimistic = current.filter((entry) => entry.id !== optimisticId)
return original && wasVisible ? sortDictionary([original, ...withoutOptimistic]) : withoutOptimistic
})
if (!original && willBeVisible) setTotal((value) => Math.max(0, value - 1))
if (original && wasVisible && !willBeVisible) setTotal((value) => value + 1)
if (original && !wasVisible && willBeVisible) setTotal((value) => Math.max(0, value - 1))
setError(dictionaryMessage(requestError))
} finally {
setSaving(false)
}
}
const remove = async (entry: DictionaryEntry): Promise<void> => {
if (!user || mutatingIds.has(entry.id)) return
if (!window.confirm(`${entry.word}” 단어를 삭제할까요?`)) return
setMutatingIds((current) => new Set(current).add(entry.id))
setError(null)
const index = Math.max(0, entries.findIndex((candidate) => candidate.id === entry.id))
setEntries((current) => current.filter((candidate) => candidate.id !== entry.id))
setTotal((value) => Math.max(0, value - 1))
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
await deleteDictionaryEntry(client, user.id, entry)
} catch (requestError) {
setEntries((current) => {
if (current.some((candidate) => candidate.id === entry.id)) return current
const restored = [...current]
restored.splice(Math.min(index, restored.length), 0, entry)
return restored
})
setTotal((value) => value + 1)
setError(dictionaryMessage(requestError))
} finally {
setMutatingIds((current) => {
const next = new Set(current)
next.delete(entry.id)
return next
})
}
}
const emptyMessage = search || category !== 'all'
? '조건에 맞는 사전 단어가 없습니다.'
: '등록된 사전 단어가 없습니다.'
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 3 }}>
<Box>
<PhosphorText variant="title">CUSTOM DICTIONARY</PhosphorText>
<PhosphorText variant="meta" sx={{ display: 'block', mt: 0.75, color: d3roPalette.text.dimLabel }}>
{total} ENTRIES · ACCOUNT SYNC
</PhosphorText>
</Box>
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
</PhysicalButton>
</Box>
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, flexWrap: 'wrap' }}>
<TextField
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="단어 또는 발음 검색..."
size="small"
inputProps={{ 'aria-label': '커스텀 사전 검색', maxLength: 100 }}
InputProps={{
startAdornment: <InputAdornment position="start"><Search size={16} color="var(--d3-text-label)" /></InputAdornment>,
sx: { bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid var(--d3-border-default)', color: 'var(--d3-text-secondary)', '& fieldset': { border: 'none' } }
}}
sx={{ flex: 1, minWidth: 260 }}
/>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }} aria-label="사전 분류 필터">
{(['all', 'user', 'technical', 'auto'] as const).map((value) => (
<TactileBadge
key={value}
mono
tone={category === value ? 'accent' : 'default'}
onClick={() => setCategory(value)}
sx={{ cursor: 'pointer', height: 36, px: 1.5 }}
>
{value === 'all' ? '전체' : CATEGORY_LABELS[value]}
</TactileBadge>
))}
</Box>
</Box>
{error && (
<Alert
severity="error"
action={<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void loadFirstPage()}></Button>}
sx={{ mb: 3 }}
>
{error}
</Alert>
)}
{authLoading || loading ? (
<Box role="status" sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={30} aria-label="사전 불러오는 중" />
</Box>
) : entries.length === 0 ? (
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>{emptyMessage}</MetalCard>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
{entries.map((entry) => {
const busy = mutatingIds.has(entry.id)
return (
<MetalCard key={entry.id} sx={{ p: 2.5 }} data-testid={`dictionary-card-${entry.id}`}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: 18, fontWeight: 500, color: '#fff', overflowWrap: 'anywhere' }}>{entry.word}</Typography>
{entry.pronunciation && <TactileBadge mono tone="accent">[{entry.pronunciation}]</TactileBadge>}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TactileBadge mono tone="mono">{CATEGORY_LABELS[entry.category]}</TactileBadge>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: 'var(--d3-text-label)' }}>{entry.usage_count} </Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="편집"><span><IconButton aria-label={`${entry.word} 편집`} size="small" disabled={busy} onClick={() => openEdit(entry)} sx={{ color: 'var(--d3-text-label)' }}><Pencil size={15} /></IconButton></span></Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label={`${entry.word} 삭제`} size="small" disabled={busy} onClick={() => void remove(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
</Box>
</Box>
</MetalCard>
)
})}
</Box>
)}
{!loading && nextCursor && (
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button variant="outlined" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? <CircularProgress size={20} /> : '더 불러오기'}
</Button>
</Box>
)}
<Dialog
open={dialogOpen}
onClose={() => { if (!saving) setDialogOpen(false) }}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}
>
<DialogTitle sx={{ color: '#fff', fontWeight: 500 }}>{editing ? '단어 편집' : '새 단어 추가'}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label="단어"
value={draft.word}
onChange={(event) => setDraft((current) => ({ ...current, word: event.target.value }))}
inputProps={{ maxLength: 120 }}
fullWidth
autoFocus
size="small"
sx={{ mt: 1 }}
/>
<TextField
label="발음 (선택)"
value={draft.pronunciation ?? ''}
onChange={(event) => setDraft((current) => ({ ...current, pronunciation: event.target.value }))}
inputProps={{ maxLength: 200 }}
fullWidth
size="small"
helperText="예: 디쓰리오"
/>
<FormControl fullWidth size="small">
<InputLabel id="dictionary-category-label"></InputLabel>
<Select
labelId="dictionary-category-label"
label="분류"
value={draft.category}
onChange={(event) => setDraft((current) => ({ ...current, category: event.target.value as DictionaryCategory }))}
>
{(['user', 'technical', 'auto'] as const).map((value) => <MenuItem key={value} value={value}>{CATEGORY_LABELS[value]}</MenuItem>)}
</Select>
</FormControl>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<PhysicalButton tone="glass" disabled={saving} onClick={() => setDialogOpen(false)}></PhysicalButton>
<PhysicalButton tone="accent" disabled={saving || !draft.word.trim()} onClick={() => void save()}>
{saving ? '저장 중...' : '저장'}
</PhysicalButton>
</DialogActions>
</Dialog>
</Box>
)
}

View file

@ -0,0 +1,340 @@
'use client'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import React, { useCallback, useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
IconButton,
Stack,
TextField,
Tooltip,
Typography
} from '@mui/material'
import { ArrowLeft, Check, Copy, RefreshCw, Save, Star, Trash2 } from 'lucide-react'
import type { D3roSupabaseClient, HistoryEntry } from '@d3ro/api-client'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
deleteHistoryEntryRevisionSafe,
getHistoryEntry,
HistoryClientError,
updateHistoryEntryRevisionSafe
} from '@/lib/history-client'
function asApiClient(): D3roSupabaseClient {
return getSupabaseBrowserClient() as unknown as D3roSupabaseClient
}
async function requireCurrentUser(client: D3roSupabaseClient): Promise<string> {
const { data, error } = await client.auth.getUser()
if (error || !data.user) {
throw new HistoryClientError('auth', error?.message ?? 'Authenticated session is required')
}
return data.user.id
}
function historyMessage(error: unknown): string {
if (error instanceof HistoryClientError) {
if (error.code === 'auth') return '세션이 만료되었습니다. 다시 로그인해 주세요.'
if (error.code === 'not-found') return '이 전사 기록을 찾을 수 없습니다.'
if (error.code === 'conflict') return '다른 기기에서 변경되었습니다. 최신 기록을 다시 불러와 주세요.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
}
return '전사 기록을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
export default function HistoryDetailPage(): React.ReactElement {
const params = useParams<{ id: string }>()
const router = useRouter()
const entryId = params.id
const [entry, setEntry] = useState<HistoryEntry | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [editing, setEditing] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [originalText, setOriginalText] = useState('')
const [polishedText, setPolishedText] = useState('')
const applyEntry = (next: HistoryEntry): void => {
setEntry(next)
setTitle(next.title ?? '')
setOriginalText(next.original_text)
setPolishedText(next.polished_text ?? '')
}
const loadEntry = useCallback(async (): Promise<void> => {
setLoading(true)
setError(null)
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
applyEntry(await getHistoryEntry(client, userId, entryId))
} catch (requestError) {
setEntry(null)
setError(historyMessage(requestError))
} finally {
setLoading(false)
}
}, [entryId])
useEffect(() => {
void loadEntry()
}, [loadEntry])
const cancelEdit = (): void => {
if (!entry) return
setTitle(entry.title ?? '')
setOriginalText(entry.original_text)
setPolishedText(entry.polished_text ?? '')
setEditing(false)
setError(null)
}
const saveEntry = async (): Promise<void> => {
if (!entry || saving) return
if (originalText.trim().length === 0) {
setError('원본 전사는 비워 둘 수 없습니다.')
return
}
setSaving(true)
setError(null)
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
const updated = await updateHistoryEntryRevisionSafe(
client,
userId,
entry.id,
entry.revision,
{
title: title.trim() || null,
original_text: originalText.trim(),
polished_text: polishedText.trim() || null
}
)
applyEntry(updated)
setEditing(false)
} catch (requestError) {
setError(historyMessage(requestError))
} finally {
setSaving(false)
}
}
const toggleFavorite = async (): Promise<void> => {
if (!entry || saving) return
const previous = entry
setSaving(true)
setError(null)
setEntry({ ...previous, is_favorite: !previous.is_favorite, revision: previous.revision + 1 })
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
applyEntry(await updateHistoryEntryRevisionSafe(
client,
userId,
previous.id,
previous.revision,
{ is_favorite: !previous.is_favorite }
))
} catch (requestError) {
applyEntry(previous)
setError(historyMessage(requestError))
} finally {
setSaving(false)
}
}
const deleteEntry = async (): Promise<void> => {
if (!entry || deleting) return
if (!window.confirm('이 전사 기록을 영구 삭제할까요?')) return
setDeleting(true)
setError(null)
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
await deleteHistoryEntryRevisionSafe(client, userId, entry.id, entry.revision)
router.push('/history')
router.refresh()
} catch (requestError) {
setError(historyMessage(requestError))
setDeleting(false)
}
}
const copyTranscript = async (): Promise<void> => {
if (!entry) return
try {
await navigator.clipboard.writeText(entry.polished_text ?? entry.original_text)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
setError('클립보드에 복사하지 못했습니다. 브라우저 권한을 확인해 주세요.')
}
}
return (
<Box sx={{ maxWidth: 920, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<Button component={Link} href="/history" color="inherit" startIcon={<ArrowLeft size={16} />} sx={{ mb: 3 }}>
</Button>
{loading ? (
<Box role="status" sx={{ py: 10, display: 'flex', justifyContent: 'center' }}>
<CircularProgress aria-label="전사 기록 불러오는 중" />
</Box>
) : entry === null ? (
<MetalCard sx={{ p: 5, textAlign: 'center' }}>
<Alert severity="error" sx={{ mb: 3 }}>{error ?? '전사 기록을 불러오지 못했습니다.'}</Alert>
<Button variant="outlined" startIcon={<RefreshCw size={16} />} onClick={() => void loadEntry()}>
</Button>
</MetalCard>
) : (
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Box>
<PhosphorText variant="title">TRANSCRIPTION DETAIL</PhosphorText>
<Typography sx={{ mt: 1, color: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 12 }}>
{new Date(entry.created_at).toLocaleString('ko-KR')} · REV {entry.revision}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'}>
<span>
<IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} disabled={saving || editing} onClick={() => void toggleFavorite()} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}>
<Star size={19} fill={entry.is_favorite ? 'currentColor' : 'none'} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={copied ? '복사됨!' : '전사 복사'}>
<IconButton aria-label="전사 복사" onClick={() => void copyTranscript()} sx={{ color: copied ? 'var(--d3-tag-green)' : 'var(--d3-text-label)' }}>
{copied ? <Check size={19} /> : <Copy size={19} />}
</IconButton>
</Tooltip>
</Box>
</Box>
{error && (
<Alert
severity="error"
action={<Button color="inherit" size="small" onClick={() => void loadEntry()}> </Button>}
>
{error}
</Alert>
)}
<MetalCard sx={{ p: { xs: 2.5, md: 4 } }}>
<Stack spacing={3}>
<HistoryField label="제목">
{editing ? (
<TextField fullWidth value={title} onChange={(event) => setTitle(event.target.value)} inputProps={{ maxLength: 160 }} />
) : (
<Typography sx={{ color: d3roPalette.text.primary }}>{entry.title ?? '제목 없음'}</Typography>
)}
</HistoryField>
<HistoryField label="원본 전사">
{editing ? (
<TextField fullWidth multiline minRows={8} value={originalText} onChange={(event) => setOriginalText(event.target.value)} />
) : (
<Typography sx={{ color: d3roPalette.text.primary, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', lineHeight: 1.7 }}>{entry.original_text}</Typography>
)}
</HistoryField>
<HistoryField label="다듬은 전사">
{editing ? (
<TextField fullWidth multiline minRows={8} value={polishedText} onChange={(event) => setPolishedText(event.target.value)} />
) : (
<Typography sx={{ color: entry.polished_text ? d3roPalette.text.primary : d3roPalette.text.muted, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', lineHeight: 1.7 }}>
{entry.polished_text ?? '다듬은 전사가 없습니다.'}
</Typography>
)}
</HistoryField>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{editing ? (
<>
<Button color="inherit" disabled={saving} onClick={cancelEdit}></Button>
<Button variant="contained" disabled={saving} startIcon={saving ? <CircularProgress size={15} /> : <Save size={16} />} onClick={() => void saveEntry()}>
</Button>
</>
) : (
<Button variant="outlined" onClick={() => setEditing(true)}> </Button>
)}
</Box>
</Stack>
</MetalCard>
{entry.summary_text && (
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>SUMMARY</PhosphorText>
<Typography sx={{ color: d3roPalette.text.primary, whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>{entry.summary_text}</Typography>
</MetalCard>
)}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>METADATA</PhosphorText>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' }, gap: 1.5 }}>
<Metadata label="모드" value={entry.mode} />
<Metadata label="상태" value={entry.status} />
<Metadata label="언어" value={entry.detected_language ?? '—'} />
<Metadata label="길이" value={formatDuration(entry.duration)} />
<Metadata label="단어" value={`${entry.word_count}`} />
<Metadata label="STT 모델" value={entry.stt_model ?? '—'} />
<Metadata label="LLM 모델" value={entry.llm_model ?? '—'} />
<Metadata label="수정 시각" value={new Date(entry.updated_at).toLocaleString('ko-KR')} />
</Box>
</MetalCard>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button color="error" variant="outlined" disabled={deleting} startIcon={deleting ? <CircularProgress size={15} /> : <Trash2 size={16} />} onClick={() => void deleteEntry()}>
</Button>
</Box>
</Stack>
)}
</Box>
)
}
function HistoryField({ label, children }: { label: string; children: React.ReactNode }): React.ReactElement {
return (
<Box>
<Typography component="h2" sx={{ mb: 1, color: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 11, letterSpacing: 1.5 }}>
{label.toUpperCase()}
</Typography>
{children}
</Box>
)
}
function Metadata({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ p: 1.5, bgcolor: d3roPalette.bg.inset, borderRadius: 1 }}>
<Typography sx={{ color: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}>{label}</Typography>
<Typography sx={{ mt: 0.5, color: d3roPalette.text.primary, fontSize: 13, overflowWrap: 'anywhere' }}>{value}</Typography>
</Box>
)
}
function formatDuration(durationSeconds: number): string {
const totalSeconds = Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return `${minutes}:${String(seconds).padStart(2, '0')}`
}

View file

@ -0,0 +1,397 @@
'use client'
import Link from 'next/link'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
IconButton,
InputAdornment,
TextField,
Tooltip,
Typography
} from '@mui/material'
import {
Check,
ChevronRight,
Clock,
Copy,
Mic,
RefreshCw,
Search,
Star,
Trash2
} from 'lucide-react'
import type { D3roSupabaseClient, HistoryEntry } from '@d3ro/api-client'
import { MetalCard, PhosphorText, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
deleteHistoryEntryRevisionSafe,
HistoryClientError,
listHistoryPage,
updateHistoryEntryRevisionSafe,
type HistoryCursor,
type HistoryListFilter
} from '@/lib/history-client'
const SEARCH_DELAY_MS = 300
const PAGE_SIZE = 20
function asApiClient(): D3roSupabaseClient {
return getSupabaseBrowserClient() as unknown as D3roSupabaseClient
}
async function requireCurrentUser(client: D3roSupabaseClient): Promise<string> {
const { data, error } = await client.auth.getUser()
if (error || !data.user) {
throw new HistoryClientError('auth', error?.message ?? 'Authenticated session is required')
}
return data.user.id
}
function historyMessage(error: unknown): string {
if (error instanceof HistoryClientError) {
if (error.code === 'auth') return '세션이 만료되었습니다. 다시 로그인해 주세요.'
if (error.code === 'conflict') return '다른 기기에서 변경된 기록입니다. 최신 목록을 다시 불러와 주세요.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
}
return '히스토리를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function sortNewestFirst(entries: HistoryEntry[]): HistoryEntry[] {
return [...entries].sort((left, right) => {
const byDate = right.created_at.localeCompare(left.created_at)
return byDate !== 0 ? byDate : right.id.localeCompare(left.id)
})
}
export default function HistoryPage(): React.ReactElement {
const [items, setItems] = useState<HistoryEntry[]>([])
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<HistoryListFilter>('all')
const [nextCursor, setNextCursor] = useState<HistoryCursor | null>(null)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
const requestGeneration = useRef(0)
useEffect(() => {
const timer = window.setTimeout(() => setSearch(searchInput), SEARCH_DELAY_MS)
return () => window.clearTimeout(timer)
}, [searchInput])
const loadFirstPage = useCallback(async (): Promise<void> => {
const generation = ++requestGeneration.current
setLoading(true)
setError(null)
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
const result = await listHistoryPage(client, {
userId,
filter,
search,
pageSize: PAGE_SIZE,
cursor: null
})
if (generation !== requestGeneration.current) return
setItems(result.entries)
setNextCursor(result.nextCursor)
} catch (requestError) {
if (generation !== requestGeneration.current) return
setItems([])
setNextCursor(null)
setError(historyMessage(requestError))
} finally {
if (generation === requestGeneration.current) setLoading(false)
}
}, [filter, search])
useEffect(() => {
void loadFirstPage()
}, [loadFirstPage])
const loadMore = async (): Promise<void> => {
if (loading || loadingMore || nextCursor === null) return
const generation = requestGeneration.current
const cursor = nextCursor
setLoadingMore(true)
setError(null)
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
const result = await listHistoryPage(client, {
userId,
filter,
search,
pageSize: PAGE_SIZE,
cursor
})
if (generation !== requestGeneration.current) return
setItems((current) => {
const byId = new Map(current.map((entry) => [entry.id, entry]))
for (const entry of result.entries) byId.set(entry.id, entry)
return sortNewestFirst([...byId.values()])
})
setNextCursor(result.nextCursor)
} catch (requestError) {
if (generation === requestGeneration.current) setError(historyMessage(requestError))
} finally {
if (generation === requestGeneration.current) setLoadingMore(false)
}
}
const markMutating = (id: string, active: boolean): void => {
setMutatingIds((current) => {
const next = new Set(current)
if (active) next.add(id)
else next.delete(id)
return next
})
}
const toggleFavorite = async (entry: HistoryEntry): Promise<void> => {
if (mutatingIds.has(entry.id)) return
markMutating(entry.id, true)
setError(null)
const optimistic: HistoryEntry = {
...entry,
is_favorite: !entry.is_favorite,
revision: entry.revision + 1
}
setItems((current) => {
if (filter === 'favorites' && entry.is_favorite) {
return current.filter((item) => item.id !== entry.id)
}
return current.map((item) => item.id === entry.id ? optimistic : item)
})
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
const updated = await updateHistoryEntryRevisionSafe(
client,
userId,
entry.id,
entry.revision,
{ is_favorite: !entry.is_favorite }
)
setItems((current) => {
if (filter === 'favorites' && !updated.is_favorite) return current
return current.map((item) => item.id === updated.id ? updated : item)
})
} catch (requestError) {
setItems((current) => {
const restored = current.some((item) => item.id === entry.id)
? current.map((item) => item.id === entry.id ? entry : item)
: [...current, entry]
return sortNewestFirst(restored)
})
setError(historyMessage(requestError))
} finally {
markMutating(entry.id, false)
}
}
const deleteEntry = async (entry: HistoryEntry): Promise<void> => {
if (mutatingIds.has(entry.id)) return
if (!window.confirm('이 전사 기록을 영구 삭제할까요?')) return
markMutating(entry.id, true)
setError(null)
const originalIndex = Math.max(0, items.findIndex((item) => item.id === entry.id))
setItems((current) => current.filter((item) => item.id !== entry.id))
try {
const client = asApiClient()
const userId = await requireCurrentUser(client)
await deleteHistoryEntryRevisionSafe(client, userId, entry.id, entry.revision)
} catch (requestError) {
setItems((current) => {
if (current.some((item) => item.id === entry.id)) return current
const restored = [...current]
restored.splice(Math.min(originalIndex, restored.length), 0, entry)
return restored
})
setError(historyMessage(requestError))
} finally {
markMutating(entry.id, false)
}
}
const copyText = async (entry: HistoryEntry): Promise<void> => {
try {
await navigator.clipboard.writeText(entry.polished_text ?? entry.original_text)
setCopiedId(entry.id)
window.setTimeout(() => setCopiedId((current) => current === entry.id ? null : current), 2000)
} catch {
setError('클립보드에 복사하지 못했습니다. 브라우저 권한을 확인해 주세요.')
}
}
const emptyMessage = search.length > 0 || filter === 'favorites'
? '조건에 맞는 전사 기록이 없습니다.'
: '아직 전사 기록이 없습니다.'
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<PhosphorText variant="title">TRANSCRIPTION HISTORY</PhosphorText>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{items.length}{nextCursor ? '+' : ''} ENTRIES
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
<TextField
fullWidth
size="small"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="제목, 전사, 요약 검색..."
inputProps={{ 'aria-label': '전사 기록 검색', maxLength: 100 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search size={16} color="var(--d3-text-label)" />
</InputAdornment>
),
sx: {
bgcolor: 'var(--d3-bg-inset)',
borderRadius: '12px',
border: '1px solid var(--d3-border-default)',
color: 'var(--d3-text-secondary)',
'& fieldset': { border: 'none' }
}
}}
sx={{ flex: 1, minWidth: 260 }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }} aria-label="히스토리 필터">
{(['all', 'favorites'] as const).map((value) => (
<TactileBadge
key={value}
mono
tone={filter === value ? 'accent' : 'default'}
onClick={() => setFilter(value)}
sx={{ cursor: 'pointer', height: 36, px: 2 }}
>
{value === 'all' ? 'ALL' : 'FAVORITES'}
</TactileBadge>
))}
</Box>
</Box>
{error && (
<Alert
severity="error"
action={
<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void loadFirstPage()}>
</Button>
}
sx={{ mb: 3 }}
>
{error}
</Alert>
)}
{loading ? (
<Box role="status" sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={30} aria-label="히스토리 불러오는 중" />
</Box>
) : items.length === 0 ? (
<MetalCard sx={{ p: 6, textAlign: 'center', color: 'var(--d3-text-label)' }}>
{emptyMessage}
</MetalCard>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)' }, gap: 2.5 }}>
{items.map((entry) => {
const text = entry.polished_text?.trim() || entry.original_text
const busy = mutatingIds.has(entry.id)
return (
<MetalCard
key={entry.id}
data-testid={`history-card-${entry.id}`}
sx={{ p: 3, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', transition: 'border-color 0.2s ease', '&:hover': { borderColor: 'var(--d3-accent-main)' } }}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: entry.status === 'completed' ? 'var(--d3-tag-green)' : '#ef4444' }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 12, color: 'var(--d3-text-secondary)' }}>
{new Date(entry.created_at).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{entry.is_favorite && <Star size={13} fill="#ffb000" color="#ffb000" aria-label="즐겨찾기" />}
<Typography sx={{ px: 1, py: 0.25, borderRadius: '6px', bgcolor: 'rgba(59,130,246,0.1)', color: 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 11 }}>
{entry.word_count} W
</Typography>
</Box>
</Box>
<Link href={`/history/${entry.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{entry.title && (
<Typography component="h2" sx={{ color: '#f4f4f5', fontWeight: 500, fontSize: 15, mb: 1 }}>
{entry.title}
</Typography>
)}
<Typography sx={{ color: 'var(--d3-text-secondary)', fontSize: 14, lineHeight: 1.6, mb: 3, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{text}
</Typography>
</Link>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pt: 2, borderTop: '1px solid var(--d3-border-default)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, color: 'var(--d3-text-label)', fontSize: 11, fontFamily: d3roFontMono }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}><Clock size={13} /><span>{formatDuration(entry.duration)}</span></Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}><Mic size={13} /><span>{entry.stt_model ?? entry.mode}</span></Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'}>
<span><IconButton aria-label={entry.is_favorite ? '즐겨찾기 해제' : '즐겨찾기'} size="small" disabled={busy} onClick={() => void toggleFavorite(entry)} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}><Star size={15} fill={entry.is_favorite ? 'currentColor' : 'none'} /></IconButton></span>
</Tooltip>
<Tooltip title={copiedId === entry.id ? '복사됨!' : '클립보드 복사'}>
<IconButton aria-label="클립보드 복사" size="small" onClick={() => void copyText(entry)} sx={{ color: copiedId === entry.id ? 'var(--d3-tag-green)' : 'var(--d3-text-label)' }}>
{copiedId === entry.id ? <Check size={15} /> : <Copy size={15} />}
</IconButton>
</Tooltip>
<Tooltip title="삭제"><span><IconButton aria-label="전사 기록 삭제" size="small" disabled={busy} onClick={() => void deleteEntry(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}><Trash2 size={15} /></IconButton></span></Tooltip>
<Tooltip title="상세 보기"><IconButton aria-label="전사 기록 상세 보기" component={Link} href={`/history/${entry.id}`} size="small" sx={{ color: 'var(--d3-text-label)' }}><ChevronRight size={15} /></IconButton></Tooltip>
</Box>
</Box>
</MetalCard>
)
})}
</Box>
)}
{!loading && nextCursor && (
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
<Button variant="outlined" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? <CircularProgress size={20} /> : '더 불러오기'}
</Button>
</Box>
)}
</Box>
)
}
function formatDuration(durationSeconds: number): string {
const totalSeconds = Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return `${minutes}:${String(seconds).padStart(2, '0')}`
}

View file

@ -26,7 +26,7 @@ export default async function AppLayout({
}
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Box sx={{ display: 'flex', minHeight: '100dvh' }}>
<Sidebar />
<Box component="main" sx={{ flex: 1, overflow: 'auto' }}>
{children}

View file

@ -1,5 +1,5 @@
// apps/web/src/app/(app)/record/page.tsx
// 녹음 페이지 — auth/Sidebar는 (app)/layout.tsx가 제공
// apps/web/src/app/(app)/record/page.tsx
// D3RO-VOICE 실시간 음성 녹음 페이지
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
@ -7,11 +7,13 @@ import { MicRecorder } from '@/components/record/mic-recorder'
export default function RecordPage(): React.ReactElement {
return (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 4 }}>
RECORD
</PhosphorText>
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<Box sx={{ mb: 4 }}>
<PhosphorText variant="title">
REAL-TIME VOICE RECORDER
</PhosphorText>
</Box>
<MicRecorder />
</Box>
)
}
}

View file

@ -19,7 +19,7 @@ export default function AcceptInvitePage(): React.ReactElement {
fallback={
<Box
sx={{
minHeight: '100vh',
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -113,7 +113,7 @@ function AcceptInviteInner(): React.ReactElement {
return (
<Box
sx={{
minHeight: '100vh',
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',

View file

@ -3,7 +3,7 @@
'use client'
import React, { useState, useEffect } from 'react'
import React, { useState } from 'react'
import {
Box,
Typography,
@ -11,48 +11,21 @@ import {
Container,
Paper,
Chip,
IconButton,
Tooltip,
} from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'
import AppleIcon from '@mui/icons-material/Apple'
import WindowsIcon from '@mui/icons-material/Window'
import StorageIcon from '@mui/icons-material/Storage'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
const OFFICIAL_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2'
import { d3roPalette } from '@d3ro/ui/theme'
export default function DownloadPage(): React.ReactElement {
const [detectedOs, setDetectedOs] = useState<'windows' | 'mac' | 'linux'>('windows')
const [copied, setCopied] = useState(false)
const [verifyStatus, setVerifyStatus] = useState<'idle' | 'computing' | 'match' | 'custom'>('idle')
const [verifyStatus, setVerifyStatus] = useState<'idle' | 'computing' | 'done'>('idle')
const [computedHash, setComputedHash] = useState('')
const [fileName, setFileName] = useState('')
useEffect(() => {
if (typeof window !== 'undefined') {
const ua = window.navigator.userAgent.toLowerCase()
if (ua.includes('mac') || ua.includes('darwin')) {
setDetectedOs('mac')
} else if (ua.includes('linux')) {
setDetectedOs('linux')
} else {
setDetectedOs('windows')
}
}
}, [])
const copyHash = (hash: string): void => {
navigator.clipboard.writeText(hash).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const handleFileVerify = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = e.target.files
if (!files || files.length === 0) return
@ -66,19 +39,15 @@ export default function DownloadPage(): React.ReactElement {
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
setComputedHash(hashHex)
if (hashHex.toLowerCase() === OFFICIAL_HASH.toLowerCase()) {
setVerifyStatus('match')
} else {
setVerifyStatus('custom')
}
setVerifyStatus('done')
}
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: '#05070d',
color: '#f8fafc',
minHeight: '100dvh',
bgcolor: d3roPalette.bg.app,
color: d3roPalette.text.primary,
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.15), transparent 70%)',
py: { xs: 4, md: 8 },
px: 2,
@ -93,28 +62,28 @@ export default function DownloadPage(): React.ReactElement {
width: 36,
height: 36,
borderRadius: '10px',
bgcolor: '#0284c7',
bgcolor: d3roPalette.accent.dark,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 900,
fontWeight: 600,
color: '#fff',
boxShadow: '0 0 20px rgba(56, 189, 248, 0.4)',
}}
>
D3
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, letterSpacing: '-0.02em', color: '#fff' }}>
<Typography variant="h6" sx={{ fontWeight: 600, letterSpacing: '-0.02em', color: '#fff' }}>
D3RO VOICE
</Typography>
<Chip
label="v1.0.0 STABLE"
label="v1.1.0 RELEASE PREPARATION"
size="small"
sx={{
bgcolor: 'rgba(56, 189, 248, 0.15)',
color: '#38bdf8',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.3)',
fontWeight: 700,
fontWeight: 500,
fontSize: '10px',
}}
/>
@ -125,7 +94,7 @@ export default function DownloadPage(): React.ReactElement {
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: '#94a3b8', fontSize: '13px', textTransform: 'none', '&:hover': { color: '#fff' } }}
sx={{ color: d3roPalette.text.secondary, fontSize: '13px', textTransform: 'none', '&:hover': { color: '#fff' } }}
>
Forgejo Releases
</Button>
@ -133,11 +102,11 @@ export default function DownloadPage(): React.ReactElement {
href="/login"
variant="outlined"
sx={{
borderColor: 'rgba(255, 255, 255, 0.15)',
color: '#f8fafc',
borderColor: 'var(--d3-glass-hairlineStrong)',
color: d3roPalette.text.primary,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { borderColor: '#38bdf8', bgcolor: 'rgba(56, 189, 248, 0.08)' },
'&:hover': { borderColor: d3roPalette.accent.light, bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 8%, transparent)' },
}}
>
Web Console
@ -148,13 +117,13 @@ export default function DownloadPage(): React.ReactElement {
{/* Hero Section */}
<Box sx={{ textAlign: 'center', maxWidth: 700, mx: 'auto', mb: 8 }}>
<Chip
icon={<ShieldOutlinedIcon sx={{ fontSize: '14px !important', color: '#38bdf8 !important' }} />}
label="OFFICIAL PRODUCTION RELEASE • ZERO-LATENCY WHISPER"
icon={<ShieldOutlinedIcon sx={{ fontSize: '14px !important', color: 'var(--d3-accent-light) !important' }} />}
label="RELEASE CANDIDATE VERIFICATION IN PROGRESS"
sx={{
bgcolor: 'rgba(56, 189, 248, 0.1)',
color: '#38bdf8',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.25)',
fontWeight: 700,
fontWeight: 500,
fontSize: '11px',
mb: 3,
}}
@ -163,20 +132,20 @@ export default function DownloadPage(): React.ReactElement {
variant="h3"
component="h1"
sx={{
fontWeight: 900,
fontWeight: 600,
letterSpacing: '-0.03em',
mb: 2,
fontSize: { xs: '2rem', md: '3rem' },
}}
>
Download{' '}
<Box component="span" sx={{ color: '#38bdf8' }}>
Prepare{' '}
<Box component="span" sx={{ color: d3roPalette.accent.light }}>
D3RO Voice
</Box>{' '}
for Desktop
Desktop Release 1.1.0
</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '16px', lineHeight: 1.6 }}>
100% on-device Whisper transcription, multi-cloud LLM proxies, and rewarded ad tokens.
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '16px', lineHeight: 1.6 }}>
Installers, signatures, and update paths are under verification. No binary is offered until the evidence is complete.
</Typography>
</Box>
@ -188,7 +157,7 @@ export default function DownloadPage(): React.ReactElement {
mx: 'auto',
p: { xs: 3, sm: 4 },
borderRadius: '24px',
bgcolor: 'rgba(15, 23, 42, 0.75)',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 75%, transparent)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(56, 189, 248, 0.35)',
boxShadow: '0 24px 60px -15px rgba(0, 0, 0, 0.7), 0 0 40px -10px rgba(56, 189, 248, 0.2)',
@ -202,47 +171,40 @@ export default function DownloadPage(): React.ReactElement {
width: 48,
height: 48,
borderRadius: '14px',
bgcolor: 'rgba(56, 189, 248, 0.15)',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
border: '1px solid rgba(56, 189, 248, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#38bdf8',
color: d3roPalette.accent.light,
}}
>
{detectedOs === 'mac' ? <AppleIcon /> : <WindowsIcon />}
<ShieldOutlinedIcon />
</Box>
<Box>
<Typography sx={{ fontWeight: 800, fontSize: '18px', color: '#fff' }}>
{detectedOs === 'mac' ? 'macOS Apple Silicon' : 'Windows 64-bit Installer'}
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff' }}>
D3RO Voice Desktop 1.1.0
</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '12px', fontFamily: 'monospace' }}>
{detectedOs === 'mac'
? 'D3RO-Voice-1.0.0-arm64.dmg • 98 MB'
: 'D3RO-Voice-Setup-1.0.0-x64.exe • 102 MB'}
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '12px', fontFamily: 'monospace' }}>
Windows x64 and macOS Apple Silicon candidates under verification
</Typography>
</Box>
</Box>
<Chip
label="RECOMMENDED"
label="RELEASE PENDING"
size="small"
sx={{
bgcolor: 'rgba(34, 197, 94, 0.15)',
color: '#4ade80',
bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 15%, transparent)',
color: d3roPalette.tag.green,
border: '1px solid rgba(34, 197, 94, 0.3)',
fontWeight: 800,
fontWeight: 600,
fontSize: '10px',
}}
/>
</Box>
<Button
href={
detectedOs === 'mac'
? '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg'
: '/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe'
}
download
disabled
variant="contained"
fullWidth
size="large"
@ -250,16 +212,16 @@ export default function DownloadPage(): React.ReactElement {
sx={{
py: 2,
borderRadius: '14px',
bgcolor: '#38bdf8',
color: '#090d19',
fontWeight: 800,
bgcolor: d3roPalette.accent.light,
color: d3roPalette.bg.card,
fontWeight: 600,
fontSize: '15px',
textTransform: 'none',
boxShadow: '0 8px 25px rgba(56, 189, 248, 0.35)',
'&:hover': { bgcolor: '#7dd3fc' },
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
Download for {detectedOs === 'mac' ? 'macOS (v1.0.0)' : 'Windows (v1.0.0)'}
Installer available after verification
</Button>
<Box
@ -271,31 +233,26 @@ export default function DownloadPage(): React.ReactElement {
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '12px',
color: '#94a3b8',
color: d3roPalette.text.secondary,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, fontFamily: 'monospace' }}>
<span>SHA-256:</span>
<span style={{ color: '#cbd5e1' }}>b0ac051443151a2e...</span>
<Tooltip title={copied ? 'Copied!' : 'Copy full hash'}>
<IconButton size="small" onClick={() => copyHash(OFFICIAL_HASH)} sx={{ color: '#38bdf8', p: 0.5 }}>
{copied ? <CheckCircleOutlineIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>Release evidence:</span>
<span style={{ color: d3roPalette.text.primary }}>artifact not yet published</span>
</Box>
<Typography sx={{ color: '#4ade80', fontSize: '11px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon sx={{ fontSize: 14 }} /> Signed & Verified
<Typography sx={{ color: d3roPalette.tag.orange, fontSize: '11px', fontWeight: 600 }}>
Signing and update verification pending
</Typography>
</Box>
</Paper>
{/* All Platform Bento Grid */}
<Box sx={{ mb: 10 }}>
<Typography variant="h5" sx={{ fontWeight: 800, mb: 1, color: '#fff' }}>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1, color: '#fff' }}>
All Platform Packages
</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '14px', mb: 4 }}>
Direct downloadable binaries and self-hosted deployment files.
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '14px', mb: 4 }}>
Planned targets and their current verification status.
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
@ -305,41 +262,40 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'rgba(15, 23, 42, 0.6)',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'rgba(56, 189, 248, 0.4)' },
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-accent-light) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<WindowsIcon sx={{ color: '#38bdf8', fontSize: 28 }} />
<Chip label="x64 / ARM" size="small" sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', color: '#94a3b8' }} />
<WindowsIcon sx={{ color: d3roPalette.accent.light, fontSize: 28 }} />
<Chip label="x64 TARGET" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 800, fontSize: '18px', color: '#fff', mb: 1 }}>Windows</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '13px', mb: 3 }}>
NSIS installer with background delta updates and DirectML GPU acceleration.
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>Windows</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Candidate build undergoing installation, signing, and update-recovery verification.
</Typography>
</Box>
<Button
href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe"
download
disabled
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
sx={{
borderColor: 'rgba(56, 189, 248, 0.3)',
color: '#38bdf8',
borderColor: 'color-mix(in srgb, var(--d3-accent-light) 30%, transparent)',
color: d3roPalette.accent.light,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 700,
'&:hover': { bgcolor: 'rgba(56, 189, 248, 0.1)', borderColor: '#38bdf8' },
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)', borderColor: d3roPalette.accent.light },
}}
>
Installer (.exe) 102 MB
Verification pending
</Button>
</Paper>
@ -349,41 +305,40 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'rgba(15, 23, 42, 0.6)',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'rgba(168, 85, 247, 0.4)' },
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-tag-purple) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<AppleIcon sx={{ color: '#c084fc', fontSize: 28 }} />
<Chip label="M1 / M2 / M3 / M4" size="small" sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', color: '#94a3b8' }} />
<AppleIcon sx={{ color: d3roPalette.tag.purple, fontSize: 28 }} />
<Chip label="M1 / M2 / M3 / M4" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 800, fontSize: '18px', color: '#fff', mb: 1 }}>macOS</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '13px', mb: 3 }}>
Apple Silicon DMG with Metal framework hardware acceleration.
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>macOS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Apple Silicon candidate undergoing code-signing and installation verification.
</Typography>
</Box>
<Button
href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg"
download
disabled
variant="outlined"
fullWidth
startIcon={<DownloadIcon />}
sx={{
borderColor: 'rgba(168, 85, 247, 0.3)',
color: '#c084fc',
borderColor: 'color-mix(in srgb, var(--d3-tag-purple) 30%, transparent)',
color: d3roPalette.tag.purple,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 700,
'&:hover': { bgcolor: 'rgba(168, 85, 247, 0.1)', borderColor: '#c084fc' },
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-tag-purple) 10%, transparent)', borderColor: d3roPalette.tag.purple },
}}
>
DMG Package 98 MB
Verification pending
</Button>
</Paper>
@ -393,22 +348,22 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 3.5,
borderRadius: '20px',
bgcolor: 'rgba(15, 23, 42, 0.6)',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
border: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
transition: 'border-color 0.2s',
'&:hover': { borderColor: 'rgba(34, 197, 94, 0.4)' },
'&:hover': { borderColor: 'color-mix(in srgb, var(--d3-tag-green) 40%, transparent)' },
}}
>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<StorageIcon sx={{ color: '#4ade80', fontSize: 28 }} />
<Chip label="Container Manager" size="small" sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', color: '#94a3b8' }} />
<StorageIcon sx={{ color: d3roPalette.tag.green, fontSize: 28 }} />
<Chip label="Container Manager" size="small" sx={{ bgcolor: 'var(--d3-border-subtle)', color: d3roPalette.text.secondary }} />
</Box>
<Typography sx={{ fontWeight: 800, fontSize: '18px', color: '#fff', mb: 1 }}>Synology NAS</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '13px', mb: 3 }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', mb: 1 }}>Synology NAS</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px', mb: 3 }}>
Self-hosted private deployment package for Synology Container Manager & CRM.
</Typography>
</Box>
@ -419,12 +374,12 @@ export default function DownloadPage(): React.ReactElement {
fullWidth
startIcon={<OpenInNewIcon />}
sx={{
borderColor: 'rgba(34, 197, 94, 0.3)',
color: '#4ade80',
borderColor: 'color-mix(in srgb, var(--d3-tag-green) 30%, transparent)',
color: d3roPalette.tag.green,
textTransform: 'none',
borderRadius: '10px',
fontWeight: 700,
'&:hover': { bgcolor: 'rgba(34, 197, 94, 0.1)', borderColor: '#4ade80' },
fontWeight: 500,
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 10%, transparent)', borderColor: d3roPalette.tag.green },
}}
>
docker-compose.nas.yml
@ -439,21 +394,21 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: { xs: 3, md: 5 },
borderRadius: '24px',
bgcolor: 'rgba(15, 23, 42, 0.65)',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
border: '1px solid rgba(56, 189, 248, 0.2)',
mb: 10,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 3 }}>
<Box>
<Typography sx={{ color: '#38bdf8', fontSize: '11px', fontFamily: 'monospace', fontWeight: 700, mb: 0.5 }}>
CLIENT-SIDE SECURITY
<Typography sx={{ color: d3roPalette.accent.light, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500, mb: 0.5 }}>
LOCAL FILE UTILITY
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#fff' }}>
SHA-256 Binary Integrity Verifier
<Typography variant="h6" sx={{ fontWeight: 600, color: '#fff' }}>
SHA-256 File Calculator
</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: '13px' }}>
Select your downloaded file to compute SHA-256 hash locally in your browser (no server upload).
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: '13px' }}>
Compute a selected file hash locally. This does not certify an official D3RO Voice release.
</Typography>
</Box>
@ -462,8 +417,8 @@ export default function DownloadPage(): React.ReactElement {
variant="outlined"
startIcon={<CloudUploadIcon />}
sx={{
borderColor: 'rgba(56, 189, 248, 0.3)',
color: '#38bdf8',
borderColor: 'color-mix(in srgb, var(--d3-accent-light) 30%, transparent)',
color: d3roPalette.accent.light,
borderRadius: '12px',
textTransform: 'none',
fontWeight: 600,
@ -486,17 +441,12 @@ export default function DownloadPage(): React.ReactElement {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<span style={{ color: '#cbd5e1', fontWeight: 'bold' }}>{fileName}</span>
{verifyStatus === 'computing' && <Chip label="Computing..." size="small" sx={{ bgcolor: '#f59e0b', color: '#000' }} />}
{verifyStatus === 'match' && <Chip label="✓ OFFICIAL MATCH (GENUINE)" size="small" sx={{ bgcolor: '#22c55e', color: '#000' }} />}
{verifyStatus === 'custom' && <Chip label="✓ LOCAL HASH GENERATED" size="small" sx={{ bgcolor: '#38bdf8', color: '#000' }} />}
<span style={{ color: d3roPalette.text.primary, fontWeight: 'bold' }}>{fileName}</span>
{verifyStatus === 'computing' && <Chip label="Computing..." size="small" sx={{ bgcolor: d3roPalette.tag.orange, color: '#000' }} />}
{verifyStatus === 'done' && <Chip label="LOCAL HASH GENERATED" size="small" sx={{ bgcolor: d3roPalette.accent.light, color: '#000' }} />}
</Box>
<Box sx={{ color: '#94a3b8' }}>
Calculated: <span style={{ color: '#38bdf8' }}>{computedHash || 'Hashing...'}</span>
</Box>
<Box sx={{ color: '#94a3b8' }}>
Official:{' '}
<span style={{ color: '#4ade80' }}>{OFFICIAL_HASH}</span>
<Box sx={{ color: d3roPalette.text.secondary }}>
Calculated: <span style={{ color: d3roPalette.accent.light }}>{computedHash || 'Hashing...'}</span>
</Box>
</Box>
)}
@ -505,14 +455,14 @@ export default function DownloadPage(): React.ReactElement {
{/* Release Changelog Timeline */}
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#fff' }}>
<Typography variant="h5" sx={{ fontWeight: 600, color: '#fff' }}>
Release Changelog & History
</Typography>
<Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
target="_blank"
endIcon={<OpenInNewIcon sx={{ fontSize: '14px !important' }} />}
sx={{ color: '#38bdf8', fontSize: '13px', textTransform: 'none' }}
sx={{ color: d3roPalette.accent.light, fontSize: '13px', textTransform: 'none' }}
>
Forgejo Releases
</Button>
@ -524,8 +474,8 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 3.5,
borderRadius: '16px',
bgcolor: 'rgba(15, 23, 42, 0.65)',
borderLeft: '4px solid #38bdf8',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
borderLeft: '4px solid var(--d3-accent-light)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderLeftWidth: '4px',
mb: 3,
@ -533,33 +483,32 @@ export default function DownloadPage(): React.ReactElement {
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 800, fontSize: '18px', color: '#fff', fontFamily: 'monospace' }}>
<Typography sx={{ fontWeight: 600, fontSize: '18px', color: '#fff', fontFamily: 'monospace' }}>
v1.0.0
</Typography>
<Chip
label="LATEST STABLE"
label="ARCHIVED · DOWNLOAD UNAVAILABLE"
size="small"
sx={{
bgcolor: 'rgba(56, 189, 248, 0.15)',
color: '#38bdf8',
bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)',
color: d3roPalette.accent.light,
border: '1px solid rgba(56, 189, 248, 0.3)',
fontWeight: 700,
fontWeight: 500,
}}
/>
<Typography sx={{ color: '#64748b', fontSize: '12px', fontFamily: 'monospace' }}>2026-08-20</Typography>
<Typography sx={{ color: d3roPalette.text.label, fontSize: '12px', fontFamily: 'monospace' }}>2026-08-20</Typography>
</Box>
<Button
href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe"
download
disabled
size="small"
startIcon={<DownloadIcon />}
sx={{ color: '#38bdf8', textTransform: 'none', fontWeight: 700 }}
sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontWeight: 500 }}
>
Installer (.exe)
Historical binary unavailable
</Button>
</Box>
<Typography sx={{ color: '#cbd5e1', fontSize: '13px', lineHeight: 1.6, mb: 2 }}>
<Typography sx={{ color: d3roPalette.text.primary, fontSize: '13px', lineHeight: 1.6, mb: 2 }}>
<strong>10+ Ad Mediation Engine</strong>: Real-time header bidding auction (EthicalAds, Carbon, GAM, Playwire, AppLovin, Unity).<br />
<strong>Rewarded Video Token Refills</strong>: Watch 15s sponsored video to gain +50 Cloud AI tokens.<br />
<strong>Forgejo CI/CD & Synology NAS Packaging</strong>: Multi-platform automated packaging and Docker CRM.<br />
@ -573,13 +522,12 @@ export default function DownloadPage(): React.ReactElement {
bgcolor: 'rgba(0, 0, 0, 0.3)',
fontFamily: 'monospace',
fontSize: '11px',
color: '#94a3b8',
color: d3roPalette.text.secondary,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>SHA-256: b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span>
<span style={{ color: '#38bdf8' }}>102 MB</span>
<span>This entry is retained only as history. No installer, fixed hash, or size is offered as a current release.</span>
</Box>
</Paper>
</Box>

View file

@ -21,6 +21,15 @@ export default function RootLayout({
}): React.ReactElement {
return (
<html lang="ko" suppressHydrationWarning>
<head>
{/* Pretendard Variable .
OS ( ) . */}
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossOrigin="" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
</head>
<body suppressHydrationWarning>
<ThemeProvider>
<I18nProvider>

View file

@ -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>
)
}