feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
496
apps/web/src/app/(app)/commands/page.tsx
Normal file
496
apps/web/src/app/(app)/commands/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
419
apps/web/src/app/(app)/dictionary/page.tsx
Normal file
419
apps/web/src/app/(app)/dictionary/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
340
apps/web/src/app/(app)/history/[id]/page.tsx
Normal file
340
apps/web/src/app/(app)/history/[id]/page.tsx
Normal 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')}`
|
||||
}
|
||||
397
apps/web/src/app/(app)/history/page.tsx
Normal file
397
apps/web/src/app/(app)/history/page.tsx
Normal 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')}`
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue