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