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:
parent
9742b2109a
commit
d0c33ca259
190 changed files with 6167 additions and 18 deletions
28
apps/web/src/app/auth/callback/route.ts
Normal file
28
apps/web/src/app/auth/callback/route.ts
Normal 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}`)
|
||||
}
|
||||
35
apps/web/src/app/dashboard/layout.tsx
Normal file
35
apps/web/src/app/dashboard/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
apps/web/src/app/dashboard/page.tsx
Normal file
104
apps/web/src/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/web/src/app/layout.tsx
Normal file
33
apps/web/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
apps/web/src/app/login/page.tsx
Normal file
122
apps/web/src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
apps/web/src/app/meetings/[id]/page.tsx
Normal file
153
apps/web/src/app/meetings/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
apps/web/src/app/meetings/page.tsx
Normal file
89
apps/web/src/app/meetings/page.tsx
Normal 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
22
apps/web/src/app/page.tsx
Normal 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')
|
||||
}
|
||||
}
|
||||
32
apps/web/src/app/record/page.tsx
Normal file
32
apps/web/src/app/record/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
apps/web/src/components/layout/sidebar.tsx
Normal file
118
apps/web/src/components/layout/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/web/src/components/providers/auth-provider.tsx
Normal file
58
apps/web/src/components/providers/auth-provider.tsx
Normal 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)
|
||||
}
|
||||
31
apps/web/src/components/providers/i18n-provider.tsx
Normal file
31
apps/web/src/components/providers/i18n-provider.tsx
Normal 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>
|
||||
}
|
||||
24
apps/web/src/components/providers/theme-provider.tsx
Normal file
24
apps/web/src/components/providers/theme-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
303
apps/web/src/components/record/mic-recorder.tsx
Normal file
303
apps/web/src/components/record/mic-recorder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/web/src/lib/supabase-browser.ts
Normal file
25
apps/web/src/lib/supabase-browser.ts
Normal 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)
|
||||
}
|
||||
34
apps/web/src/lib/supabase-server.ts
Normal file
34
apps/web/src/lib/supabase-server.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue