feat(V2-3): Web App MVP — Next.js 15 + @d3ro/api-client

packages/api-client (@d3ro/api-client) 신규:
- types.ts: 12개 테이블 Row/Insert/Update 타입 + Database 형식
- client.ts: createD3roSupabaseClient 팩토리 (placeholder fallback)
- auth.ts: signInWithOAuth/signOut/getSession/onAuthStateChange
- meetings.ts: 회의 + 메모 + 문서 + 전사 CRUD
- history.ts: 음성 입력 이력 CRUD
- usage.ts: 일일 쿼터 + 구독 조회
- 루트 barrel은 types만 re-export, 함수는 subpath import 전용

apps/web (@d3ro/web) 신규 — Next.js 15 App Router:
- transpilePackages: @d3ro/core, ui, i18n, api-client
- ThemeProvider (MUI + AppRouterCacheProvider)
- I18nProvider (localStorage 어댑터)
- AuthProvider (Supabase session Context)
- 라우트 9개:
  - / (auth 상태 기반 리다이렉트)
  - /login (Google/GitHub OAuth, 미설정 경고)
  - /auth/callback (code -> session 교환)
  - /dashboard (요약 카드 + 최근 회의)
  - /meetings (카드 그리드 리스트)
  - /meetings/[id] (transcripts/memos/documents)
  - /record (getUserMedia + MediaRecorder + stt-proxy)
- Sidebar, 인증 가드, 9바 웨이브폼, 레벨 미터

packages/ui 확장:
- MetalCard가 BoxProps 상속 (sx/onClick 등 전달)
- theme.ts에 typoSx(key) 헬퍼 추가 (d3roTypo -> MUI sx 변환)
- d3roPalette.tag.blue 추가 (M5 정리 포함)
- DS 컴포넌트 9개에 'use client' directive
- MetalDial 미사용 import 제거

packages/i18n 확장:
- ko.json에 7개 새 키 (nav.meetings/record/logout, login.*)

설계 결정:
- Database 제네릭 현재는 기본 타입 (V2-4에서 supabase gen types로 자동화)
- api-client barrel은 타입만 노출해서 컴파일 전파 차단
- DS 컴포넌트 client 경계 명시
- env 없어도 Next.js 빌드 성공 (placeholder URL/key)

검증:
- web typecheck OK
- desktop typecheck OK (회귀 없음)
- web next build OK (9 라우트 정적/동적 생성)
- desktop build OK (회귀 없음)
This commit is contained in:
yunchan8804 2026-04-09 02:52:31 +09:00
parent 9742b2109a
commit d0c33ca259
190 changed files with 6167 additions and 18 deletions

View file

@ -0,0 +1,28 @@
// apps/web/src/app/auth/callback/route.ts
// OAuth 콜백 — provider에서 리다이렉트된 code를 session으로 교환.
import { NextResponse } from 'next/server'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
export async function GET(request: Request): Promise<NextResponse> {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (!isSupabaseConfiguredServer()) {
return NextResponse.redirect(`${origin}/login?error=supabase_not_configured`)
}
if (!code) {
return NextResponse.redirect(`${origin}/login?error=no_code`)
}
const supabase = await getSupabaseServerClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`)
}
return NextResponse.redirect(`${origin}${next}`)
}

View file

@ -0,0 +1,35 @@
// apps/web/src/app/dashboard/layout.tsx
// 대시보드 섹션 레이아웃 — 인증 가드 + 사이드바
import { redirect } from 'next/navigation'
import { Box } from '@mui/material'
import { Sidebar } from '@/components/layout/sidebar'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
export default async function DashboardLayout({
children
}: {
children: React.ReactNode
}): Promise<React.ReactElement> {
if (!isSupabaseConfiguredServer()) {
redirect('/login')
}
const supabase = await getSupabaseServerClient()
const {
data: { user }
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar />
<Box component="main" sx={{ flex: 1, p: 4, overflow: 'auto' }}>
{children}
</Box>
</Box>
)
}

View file

@ -0,0 +1,104 @@
// apps/web/src/app/dashboard/page.tsx
// 대시보드 — 요약 카드 4개 + 최근 회의
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'
interface Stat {
label: string
value: string
hint?: string
}
async function loadDashboardData(): Promise<{ stats: Stat[]; recentMeetings: Array<{ id: string; title: string; started_at: string }> }> {
try {
const supabase = await getSupabaseServerClient()
const [{ count: meetingCount }, { data: recent }] = 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)
])
const stats: Stat[] = [
{ label: '총 회의', value: String(meetingCount ?? 0), hint: '전체 기간' },
{ label: '이번 주', value: '—', 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
}))
}
} catch {
return {
stats: [
{ label: '총 회의', value: '—' },
{ label: '이번 주', value: '—' },
{ label: '구독 티어', value: '—' },
{ label: '쿼터 사용', value: '—' }
],
recentMeetings: []
}
}
}
export default async function DashboardPage(): Promise<React.ReactElement> {
const { stats, recentMeetings } = await loadDashboardData()
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 4 }}>
DASHBOARD
</PhosphorText>
<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>
<PhosphorText variant="title">
{stat.value}
</PhosphorText>
{stat.hint && (
<Box sx={{ mt: 1, color: d3roPalette.text.muted, fontSize: 11 }}>{stat.hint}</Box>
)}
</MetalCard>
</Grid>
))}
</Grid>
<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>
) : (
recentMeetings.map((meeting) => (
<MetalCard key={meeting.id} sx={{ p: 3 }}>
<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>
))
)}
</Stack>
</Box>
)
}

View file

@ -0,0 +1,33 @@
// apps/web/src/app/layout.tsx
// 루트 레이아웃 — Theme/I18n/Auth Provider 스택
import type { Metadata } from 'next'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { I18nProvider } from '@/components/providers/i18n-provider'
import { AuthProvider } from '@/components/providers/auth-provider'
export const metadata: Metadata = {
title: 'D3RO Voice',
description: '로컬+클라우드 하이브리드 AI 음성 어시스턴트',
icons: {
icon: '/favicon.ico'
}
}
export default function RootLayout({
children
}: {
children: React.ReactNode
}): React.ReactElement {
return (
<html lang="ko">
<body>
<ThemeProvider>
<I18nProvider>
<AuthProvider>{children}</AuthProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
)
}

View file

@ -0,0 +1,122 @@
'use client'
// apps/web/src/app/login/page.tsx
// OAuth 로그인 페이지
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Box, Button, Stack, Alert } from '@mui/material'
import GoogleIcon from '@mui/icons-material/Google'
import GitHubIcon from '@mui/icons-material/GitHub'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { useI18n } from '@d3ro/i18n'
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
import { useAuth } from '@/components/providers/auth-provider'
export default function LoginPage(): React.ReactElement {
const router = useRouter()
const { user, loading } = useAuth()
const { t } = useI18n()
const [error, setError] = useState<string | null>(null)
const [signingIn, setSigningIn] = useState(false)
const configured = isSupabaseConfigured()
// 이미 로그인이면 대시보드로
useEffect(() => {
if (!loading && user) {
router.replace('/dashboard')
}
}, [loading, user, router])
async function handleOAuth(provider: 'google' | 'github'): Promise<void> {
if (!configured) {
setError('Supabase 환경변수가 설정되지 않았습니다. apps/web/env.example.txt를 참고하세요.')
return
}
setError(null)
setSigningIn(true)
try {
const supabase = getSupabaseBrowserClient()
const redirectTo = `${window.location.origin}/auth/callback`
const { error: err } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo }
})
if (err) {
setError(err.message)
setSigningIn(false)
}
// 성공 시 브라우저가 provider로 리다이렉트되므로 여기서 끝
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')
setSigningIn(false)
}
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.app,
p: 4
}}
>
<MetalCard sx={{ maxWidth: 420, width: '100%', p: 4 }}>
<Stack spacing={3}>
<Box sx={{ textAlign: 'center' }}>
<PhosphorText variant="title">
D3RO VOICE
</PhosphorText>
<Box sx={{ mt: 1, ...typoSx("label"), color: d3roPalette.text.secondary }}>
{t('login.subtitle') ?? 'AI 음성 어시스턴트'}
</Box>
</Box>
{!configured && (
<Alert severity="warning" variant="outlined">
Supabase . env .
</Alert>
)}
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
<Stack spacing={2}>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<GoogleIcon />}
disabled={signingIn || !configured}
onClick={() => void handleOAuth('google')}
>
{t('login.google') ?? 'Google로 계속하기'}
</Button>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<GitHubIcon />}
disabled={signingIn || !configured}
onClick={() => void handleOAuth('github')}
>
{t('login.github') ?? 'GitHub로 계속하기'}
</Button>
</Stack>
<Box sx={{ textAlign: 'center', color: d3roPalette.text.label, fontSize: 12 }}>
{t('login.terms') ?? '계속 진행하면 이용약관 및 개인정보 처리방침에 동의합니다.'}
</Box>
</Stack>
</MetalCard>
</Box>
)
}

View file

@ -0,0 +1,153 @@
// apps/web/src/app/meetings/[id]/page.tsx
// 회의록 상세 — transcripts + memos + documents
import { notFound, redirect } from 'next/navigation'
import { Box, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
import { Sidebar } from '@/components/layout/sidebar'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function MeetingDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id } = await params
if (!isSupabaseConfiguredServer()) {
redirect('/login')
}
const supabase = await getSupabaseServerClient()
const {
data: { user }
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const [{ data: meeting }, { data: transcripts }, { data: memos }, { data: documents }] =
await Promise.all([
supabase.from('meetings').select('*').eq('id', id).maybeSingle(),
supabase
.from('transcripts')
.select('*')
.eq('meeting_id', id)
.order('segment_index', { ascending: true }),
supabase
.from('meeting_memos')
.select('*')
.eq('meeting_id', id)
.order('timestamp_ms', { ascending: true }),
supabase
.from('meeting_documents')
.select('*')
.eq('meeting_id', id)
.order('created_at', { ascending: true })
])
if (!meeting) {
notFound()
}
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar />
<Box component="main" sx={{ flex: 1, p: 4, overflow: 'auto' }}>
<Box sx={{ mb: 4 }}>
<PhosphorText variant="title">
{meeting.title ?? '(제목 없음)'}
</PhosphorText>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 1 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')} · {meeting.status}
</Box>
</Box>
<Stack spacing={3}>
{/* Transcript */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
TRANSCRIPT
</PhosphorText>
{(transcripts ?? []).length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
.
</Box>
) : (
<Stack spacing={1.5}>
{(transcripts ?? []).map((seg) => (
<Box key={seg.id}>
<Box sx={{ color: d3roPalette.text.label, fontSize: 11, mb: 0.5 }}>
{Math.floor(seg.timestamp_ms / 60000)}:
{String(Math.floor((seg.timestamp_ms / 1000) % 60)).padStart(2, '0')}
{seg.speaker && ` · ${seg.speaker}`}
</Box>
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>
{seg.text}
</Box>
</Box>
))}
</Stack>
)}
</MetalCard>
{/* Memos */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
MEMOS
</PhosphorText>
{(memos ?? []).length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}> .</Box>
) : (
<Stack spacing={1}>
{(memos ?? []).map((memo) => (
<Box
key={memo.id}
sx={{
p: 1.5,
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
bgcolor: d3roPalette.bg.inset
}}
>
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13 }}>{memo.content}</Box>
</Box>
))}
</Stack>
)}
</MetalCard>
{/* Documents */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
DOCUMENTS
</PhosphorText>
{(documents ?? []).length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
.
</Box>
) : (
<Stack spacing={1}>
{(documents ?? []).map((doc) => (
<Box
key={doc.id}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1
}}
>
<Box sx={{ ...typoSx("body"), color: d3roPalette.text.primary, mb: 0.5 }}>
{doc.title}
</Box>
<Box sx={{ color: d3roPalette.text.label, fontSize: 11 }}>
{doc.template_type} · {new Date(doc.created_at).toLocaleDateString('ko-KR')}
</Box>
</Box>
))}
</Stack>
)}
</MetalCard>
</Stack>
</Box>
</Box>
)
}

View file

@ -0,0 +1,89 @@
// apps/web/src/app/meetings/page.tsx
// 회의록 리스트
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
import { Sidebar } from '@/components/layout/sidebar'
async function loadMeetings(): Promise<Array<{ id: string; title: string; started_at: string; status: string }>> {
try {
const supabase = await getSupabaseServerClient()
const { data } = await supabase
.from('meetings')
.select('id, title, started_at, status')
.order('started_at', { ascending: false })
.limit(100)
return (data ?? []).map((m) => ({
id: m.id,
title: m.title ?? '(제목 없음)',
started_at: m.started_at,
status: m.status
}))
} catch {
return []
}
}
export default async function MeetingsPage(): Promise<React.ReactElement> {
if (!isSupabaseConfiguredServer()) {
redirect('/login')
}
const supabase = await getSupabaseServerClient()
const {
data: { user }
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const meetings = await loadMeetings()
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar />
<Box component="main" sx={{ flex: 1, p: 4, overflow: 'auto' }}>
<PhosphorText variant="title" sx={{ mb: 4 }}>
MEETINGS
</PhosphorText>
{meetings.length === 0 ? (
<MetalCard sx={{ p: 6, textAlign: 'center', color: d3roPalette.text.muted }}>
. .
</MetalCard>
) : (
<Grid container spacing={3}>
{meetings.map((meeting) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={meeting.id}>
<Link href={`/meetings/${meeting.id}`} style={{ textDecoration: 'none' }}>
<MetalCard sx={{ p: 3, cursor: 'pointer', minHeight: 140 }}>
<Box sx={{ ...typoSx("body"), color: d3roPalette.text.primary, mb: 1 }}>
{meeting.title}
</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mb: 2 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')}
</Box>
<Box
sx={{
display: 'inline-block',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: 11,
bgcolor: d3roPalette.bg.inset,
color: d3roPalette.text.label
}}
>
{meeting.status}
</Box>
</MetalCard>
</Link>
</Grid>
))}
</Grid>
)}
</Box>
</Box>
)
}

22
apps/web/src/app/page.tsx Normal file
View file

@ -0,0 +1,22 @@
// apps/web/src/app/page.tsx
// 루트 페이지 — 로그인 상태면 dashboard로, 아니면 login으로 리다이렉트
import { redirect } from 'next/navigation'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
export default async function HomePage(): Promise<React.ReactElement> {
if (!isSupabaseConfiguredServer()) {
redirect('/login')
}
const supabase = await getSupabaseServerClient()
const {
data: { user }
} = await supabase.auth.getUser()
if (user) {
redirect('/dashboard')
} else {
redirect('/login')
}
}

View file

@ -0,0 +1,32 @@
// apps/web/src/app/record/page.tsx
// 녹음 페이지
import { redirect } from 'next/navigation'
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { Sidebar } from '@/components/layout/sidebar'
import { MicRecorder } from '@/components/record/mic-recorder'
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
export default async function RecordPage(): Promise<React.ReactElement> {
if (!isSupabaseConfiguredServer()) {
redirect('/login')
}
const supabase = await getSupabaseServerClient()
const {
data: { user }
} = await supabase.auth.getUser()
if (!user) redirect('/login')
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Sidebar />
<Box component="main" sx={{ flex: 1, p: 4, overflow: 'auto' }}>
<PhosphorText variant="title" sx={{ mb: 4 }}>
RECORD
</PhosphorText>
<MicRecorder />
</Box>
</Box>
)
}

View file

@ -0,0 +1,118 @@
'use client'
// apps/web/src/components/layout/sidebar.tsx
// 대시보드 좌측 사이드바
import { usePathname, useRouter } from 'next/navigation'
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
import MicIcon from '@mui/icons-material/Mic'
import LogoutIcon from '@mui/icons-material/Logout'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
interface NavItem {
key: string
path: string
label: string
icon: React.ReactElement
}
export function Sidebar(): React.ReactElement {
const router = useRouter()
const pathname = usePathname()
const { t } = useI18n()
const items: NavItem[] = [
{
key: 'dashboard',
path: '/dashboard',
label: t('nav.dashboard') ?? 'Dashboard',
icon: <DashboardIcon />
},
{
key: 'meetings',
path: '/meetings',
label: t('nav.meetings') ?? 'Meetings',
icon: <MeetingRoomIcon />
},
{
key: 'record',
path: '/record',
label: t('nav.record') ?? 'Record',
icon: <MicIcon />
}
]
async function handleLogout(): Promise<void> {
const supabase = getSupabaseBrowserClient()
await supabase.auth.signOut()
router.replace('/login')
}
return (
<Box
sx={{
width: 240,
minHeight: '100vh',
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.default}`,
display: 'flex',
flexDirection: 'column'
}}
>
<Box sx={{ p: 3, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
<PhosphorText variant="heading">
D3RO VOICE
</PhosphorText>
</Box>
<List sx={{ flex: 1, py: 2 }}>
{items.map((item) => {
const active = pathname === item.path || pathname.startsWith(`${item.path}/`)
return (
<ListItem key={item.key} disablePadding>
<ListItemButton
selected={active}
onClick={() => router.push(item.path)}
sx={{
'&.Mui-selected': {
bgcolor: d3roPalette.bg.inset,
borderLeft: `3px solid ${d3roPalette.accent.amber}`
}
}}
>
<ListItemIcon sx={{ color: active ? d3roPalette.accent.amber : d3roPalette.text.label }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary
}}
/>
</ListItemButton>
</ListItem>
)
})}
</List>
<List sx={{ borderTop: `1px solid ${d3roPalette.border.default}` }}>
<ListItem disablePadding>
<ListItemButton onClick={() => void handleLogout()}>
<ListItemIcon sx={{ color: d3roPalette.text.label }}>
<LogoutIcon />
</ListItemIcon>
<ListItemText
primary={t('nav.logout') ?? 'Logout'}
primaryTypographyProps={{ color: d3roPalette.text.secondary }}
/>
</ListItemButton>
</ListItem>
</List>
</Box>
)
}

View file

@ -0,0 +1,58 @@
'use client'
// apps/web/src/components/providers/auth-provider.tsx
// Supabase 세션을 Context로 공유 + 구독으로 실시간 갱신.
import { createContext, useContext, useEffect, useState } from 'react'
import type { Session, User } from '@supabase/supabase-js'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
interface AuthContextValue {
session: Session | null
user: User | null
loading: boolean
}
const AuthContext = createContext<AuthContextValue>({
session: null,
user: null,
loading: true
})
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const supabase = getSupabaseBrowserClient()
supabase.auth
.getSession()
.then(({ data }) => {
setSession(data.session)
})
.finally(() => {
setLoading(false)
})
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((_event, newSession) => {
setSession(newSession)
})
return () => {
subscription.unsubscribe()
}
}, [])
return (
<AuthContext.Provider value={{ session, user: session?.user ?? null, loading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
return useContext(AuthContext)
}

View file

@ -0,0 +1,31 @@
'use client'
// apps/web/src/components/providers/i18n-provider.tsx
// @d3ro/i18n을 웹 환경에 바인딩 (localStorage 어댑터)
import { I18nProvider as BaseI18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
const LOCALE_KEY = 'd3ro.voice.locale'
const localStorageI18nStorage: I18nStorage = {
load: () => {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(LOCALE_KEY)
} catch {
return null
}
},
save: (locale: Locale) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(LOCALE_KEY, locale)
} catch {
// storage 차단 시 무시
}
}
}
export function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement {
return <BaseI18nProvider storage={localStorageI18nStorage}>{children}</BaseI18nProvider>
}

View file

@ -0,0 +1,24 @@
'use client'
// apps/web/src/components/providers/theme-provider.tsx
// MUI ThemeProvider + Emotion cache (Next.js App Router용)
import { useMemo } from 'react'
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material'
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'
import { getTheme } from '@d3ro/ui/theme'
export function ThemeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
// 웹은 시스템 prefers-color-scheme 대신 사용자 설정 저장소를 나중에 추가.
// V2-3 MVP에서는 dark 고정.
const theme = useMemo(() => getTheme('dark', true), [])
return (
<AppRouterCacheProvider options={{ key: 'd3ro-mui', enableCssLayer: true }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</AppRouterCacheProvider>
)
}

View file

@ -0,0 +1,303 @@
'use client'
// apps/web/src/components/record/mic-recorder.tsx
// getUserMedia + MediaRecorder로 오디오 캡처 → Edge Function(stt-proxy)로 전송
import { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import StopIcon from '@mui/icons-material/Stop'
import CloseIcon from '@mui/icons-material/Close'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
type RecorderState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
interface SttResponse {
transcript: string
confidence: number
language_code: string
duration_seconds: number
}
export function MicRecorder(): React.ReactElement {
const [state, setState] = useState<RecorderState>('idle')
const [error, setError] = useState<string | null>(null)
const [transcript, setTranscript] = useState<string>('')
const [level, setLevel] = useState<number>(0)
const [elapsed, setElapsed] = useState<number>(0)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const rafRef = useRef<number | null>(null)
const startAtRef = useRef<number>(0)
const elapsedTimerRef = useRef<number | null>(null)
const chunksRef = useRef<Blob[]>([])
const configured = isSupabaseConfigured()
// cleanup
useEffect(() => {
return () => {
stopStream()
if (rafRef.current) cancelAnimationFrame(rafRef.current)
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
}
}, [])
const stopStream = useCallback((): void => {
streamRef.current?.getTracks().forEach((track) => track.stop())
streamRef.current = null
audioContextRef.current?.close().catch(() => {})
audioContextRef.current = null
analyserRef.current = null
}, [])
const updateLevel = useCallback((): void => {
const analyser = analyserRef.current
if (!analyser) return
const buf = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteTimeDomainData(buf)
let sum = 0
for (const v of buf) {
const n = (v - 128) / 128
sum += n * n
}
const rms = Math.sqrt(sum / buf.length)
setLevel(Math.min(1, rms * 2.5))
rafRef.current = requestAnimationFrame(updateLevel)
}, [])
const startRecording = useCallback(async (): Promise<void> => {
setError(null)
setTranscript('')
setElapsed(0)
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
})
streamRef.current = stream
// 오디오 레벨 미터
const AudioCtx = window.AudioContext
const audioContext = new AudioCtx()
audioContextRef.current = audioContext
const source = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 512
source.connect(analyser)
analyserRef.current = analyser
rafRef.current = requestAnimationFrame(updateLevel)
// MediaRecorder
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm'
const mediaRecorder = new MediaRecorder(stream, { mimeType })
mediaRecorderRef.current = mediaRecorder
chunksRef.current = []
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data)
}
mediaRecorder.onstop = () => {
void handleRecordingComplete()
}
mediaRecorder.start(250)
startAtRef.current = Date.now()
elapsedTimerRef.current = window.setInterval(() => {
setElapsed(Date.now() - startAtRef.current)
}, 100)
setState('recording')
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start recording')
setState('error')
stopStream()
}
}, [stopStream, updateLevel])
const handleRecordingComplete = useCallback(async (): Promise<void> => {
setState('processing')
if (rafRef.current) cancelAnimationFrame(rafRef.current)
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
const blob = new Blob(chunksRef.current, { type: 'audio/webm' })
stopStream()
if (!configured) {
setError('Supabase가 설정되지 않아 전사할 수 없습니다.')
setState('error')
return
}
try {
const supabase = getSupabaseBrowserClient()
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
setError('로그인이 필요합니다.')
setState('error')
return
}
const formData = new FormData()
formData.append('audio', blob, 'recording.webm')
formData.append('sample_rate', '16000')
formData.append('language_code', 'ko-KR')
const response = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stt-proxy`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${session.access_token}`
},
body: formData
}
)
if (!response.ok) {
const errText = await response.text()
throw new Error(`STT failed: ${response.status} ${errText}`)
}
const data = (await response.json()) as SttResponse
setTranscript(data.transcript)
setState('done')
} catch (e) {
setError(e instanceof Error ? e.message : 'STT 처리 실패')
setState('error')
}
}, [configured, stopStream])
const stopRecording = useCallback((): void => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
}, [])
const cancelRecording = useCallback((): void => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.onstop = null
mediaRecorderRef.current.stop()
}
if (rafRef.current) cancelAnimationFrame(rafRef.current)
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
stopStream()
setState('idle')
setTranscript('')
setElapsed(0)
setError(null)
}, [stopStream])
const elapsedText = `${String(Math.floor(elapsed / 60000)).padStart(2, '0')}:${String(
Math.floor((elapsed / 1000) % 60)
).padStart(2, '0')}`
// 9개 웨이브 바 (Speakly 스타일)
const barHeights = Array.from({ length: 9 }, (_, i) => {
const dist = Math.abs(i - 4) / 4
const base = 1 - dist * 0.5
return state === 'recording' ? Math.max(0.15, level * base) : 0.15
})
return (
<MetalCard sx={{ p: 4, maxWidth: 600, mx: 'auto' }}>
<Stack spacing={3} alignItems="center">
<PhosphorText variant="heading">
{state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'}
</PhosphorText>
{/* 웨이브 바 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
height: 60,
width: '100%'
}}
>
{barHeights.map((h, i) => (
<Box
key={i}
sx={{
width: 8,
height: `${h * 100}%`,
bgcolor: d3roPalette.accent.amber,
borderRadius: 1,
transition: state === 'recording' ? 'height 50ms linear' : 'height 200ms'
}}
/>
))}
</Box>
{state === 'recording' && (
<Box sx={{ ...typoSx("title"), color: d3roPalette.text.primary, fontFamily: 'monospace' }}>
{elapsedText}
</Box>
)}
{state === 'processing' && <CircularProgress size={32} />}
{state === 'done' && transcript && (
<Box
sx={{
p: 2,
width: '100%',
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
color: d3roPalette.text.primary,
whiteSpace: 'pre-wrap'
}}
>
{transcript}
</Box>
)}
{error && (
<Alert severity="error" sx={{ width: '100%' }} variant="outlined">
{error}
</Alert>
)}
<Stack direction="row" spacing={2}>
{state === 'idle' || state === 'done' || state === 'error' ? (
<Button
variant="contained"
size="large"
startIcon={<MicIcon />}
onClick={() => void startRecording()}
disabled={!configured}
>
{state === 'done' ? '새 녹음' : '녹음 시작'}
</Button>
) : state === 'recording' ? (
<>
<Button variant="contained" color="error" startIcon={<StopIcon />} onClick={stopRecording}>
</Button>
<Button variant="outlined" startIcon={<CloseIcon />} onClick={cancelRecording}>
</Button>
</>
) : null}
</Stack>
</Stack>
</MetalCard>
)
}

View file

@ -0,0 +1,25 @@
// apps/web/src/lib/supabase-browser.ts
// 브라우저 컴포넌트용 Supabase 클라이언트.
'use client'
import { createBrowserClient } from '@supabase/ssr'
// V2-3 MVP: Database 제네릭 없이 동작. V2-4에서 `supabase gen types typescript`로
// 자동 생성된 Database 타입을 주입하여 select/insert에 타입 안전성 추가 예정.
let cachedClient: ReturnType<typeof createBrowserClient> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
cachedClient = createBrowserClient(url, key)
return cachedClient
}
export function isSupabaseConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
}

View file

@ -0,0 +1,34 @@
// apps/web/src/lib/supabase-server.ts
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션).
// V2-3 MVP: Database 제네릭 없이 동작. V2-4에서 자동 생성 Database 타입 주입 예정.
import { cookies } from 'next/headers'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient>> {
const cookieStore = await cookies()
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
return createServerClient(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// RSC에서 set은 실패하지만 route handler에서는 성공
}
}
}
})
}
export function isSupabaseConfiguredServer(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
}