feat(V2-6차): 테마 토글 + Mermaid + 오디오 재생 + Dashboard 차트

[A] Dark/Light/6종 + Auto 테마 토글
- theme-mode-context.tsx: localStorage 영속 + prefers-color-scheme
- theme-provider.tsx: 컨텍스트 기반 동적 getTheme
- sidebar.tsx: MUI Select 토글 (PaletteIcon)
- 12개 locale theme.* 키 추가 (label/dark/light/auto/nord/solarized/catppuccin/dracula)

[B] DocumentEditor Mermaid + Markdown 렌더
- markdown-preview.tsx: react-markdown + remark-gfm + lazy mermaid
  - mermaid 코드 블록 자동 SVG 렌더
  - dark theme + d3ro 폰트
- document-editor.tsx: Edit/Preview Tabs

[C] 회의 오디오 재생
- meeting-audio-player.tsx: Supabase Storage signed URL (1h) → <audio controls>
- meetings/[id] AUDIO 카드 추가

[D] Dashboard 14일 추이 차트
- meetings-trend-chart.tsx: recharts LineChart (회의/문서)
- dashboard/page.tsx: 14일 데이터 집계 + 이번 주 stat 자동 계산

[W] fix: Emotion key 'd3ro-mui' → 'mui'
- 'd3ro-mui'에 숫자 3이 포함돼 Emotion 키 검증 실패
- dev SSR /login 500 에러 차단했음

deps:
- react-markdown@10, remark-gfm@4, mermaid@11, recharts@3
This commit is contained in:
윤찬 2026-04-11 08:57:27 +09:00
parent 162a59dc47
commit 07c6d13c99
24 changed files with 2220 additions and 63 deletions

View file

@ -5,6 +5,10 @@ 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 {
MeetingsTrendChart,
type TrendPoint
} from '@/components/dashboard/meetings-trend-chart'
interface Stat {
label: string
@ -12,22 +16,73 @@ interface Stat {
hint?: string
}
async function loadDashboardData(): Promise<{ stats: Stat[]; recentMeetings: Array<{ id: string; title: string; started_at: 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 })
}
return out
}
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
}
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 [{ count: meetingCount }, { data: recent }] = await Promise.all([
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)
.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: '—', hint: '7일간' },
{ label: '이번 주', value: String(thisWeek), hint: '최근 7일' },
{ label: '구독 티어', value: 'Free', hint: '업그레이드 가능' },
{ label: '쿼터 사용', value: '0 / 50', hint: '오늘' }
]
@ -38,7 +93,8 @@ async function loadDashboardData(): Promise<{ stats: Stat[]; recentMeetings: Arr
id: m.id,
title: m.title ?? '(제목 없음)',
started_at: m.started_at
}))
})),
trend
}
} catch {
return {
@ -48,13 +104,14 @@ async function loadDashboardData(): Promise<{ stats: Stat[]; recentMeetings: Arr
{ label: '구독 티어', value: '—' },
{ label: '쿼터 사용', value: '—' }
],
recentMeetings: []
recentMeetings: [],
trend
}
}
}
export default async function DashboardPage(): Promise<React.ReactElement> {
const { stats, recentMeetings } = await loadDashboardData()
const { stats, recentMeetings, trend } = await loadDashboardData()
return (
<Box sx={{ p: 4 }}>
@ -80,6 +137,13 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
))}
</Grid>
<MetalCard sx={{ p: 3, mb: 4 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
ACTIVITY TREND ({TREND_DAYS}D)
</PhosphorText>
<MeetingsTrendChart points={trend} />
</MetalCard>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
RECENT MEETINGS
</PhosphorText>

View file

@ -13,6 +13,7 @@ import {
import { GenerateDocumentButton } from '@/components/meetings/generate-document-button'
import { DocumentEditor } from '@/components/meetings/document-editor'
import { MemoForm } from '@/components/meetings/memo-form'
import { MeetingAudioPlayer } from '@/components/meetings/meeting-audio-player'
interface PageProps {
params: Promise<{ id: string }>
@ -59,6 +60,14 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
</Box>
<Stack spacing={3}>
{/* Audio — Supabase Storage signed URL */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
AUDIO
</PhosphorText>
<MeetingAudioPlayer storageKey={meeting.audio_storage_key ?? null} />
</MetalCard>
{/* Transcript — Realtime 구독 */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>

View file

@ -0,0 +1,83 @@
'use client'
// apps/web/src/components/dashboard/meetings-trend-chart.tsx
// recharts 기반 회의/문서 추이 차트 (최근 14일).
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend
} from 'recharts'
import { Box } from '@mui/material'
import { d3roPalette } from '@d3ro/ui/theme'
export interface TrendPoint {
date: string
meetings: number
documents: number
}
interface MeetingsTrendChartProps {
points: TrendPoint[]
}
export function MeetingsTrendChart({ points }: MeetingsTrendChartProps): React.ReactElement {
return (
<Box sx={{ width: '100%', height: 260 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points} margin={{ top: 5, right: 12, left: -12, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
<XAxis
dataKey="date"
stroke={d3roPalette.text.muted}
fontSize={11}
tickLine={false}
/>
<YAxis
stroke={d3roPalette.text.muted}
fontSize={11}
tickLine={false}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: 8,
color: d3roPalette.text.primary,
fontSize: 12
}}
labelStyle={{ color: d3roPalette.text.label }}
/>
<Legend
wrapperStyle={{ fontSize: 11, color: d3roPalette.text.secondary }}
iconType="circle"
/>
<Line
type="monotone"
dataKey="meetings"
name="Meetings"
stroke={d3roPalette.accent.amber}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="documents"
name="Documents"
stroke={d3roPalette.tag.purple}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</Box>
)
}

View file

@ -4,7 +4,19 @@
// 대시보드 좌측 사이드바
import { usePathname, useRouter } from 'next/navigation'
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import {
Box,
FormControl,
InputLabel,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
Select,
type SelectChangeEvent
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
import MicIcon from '@mui/icons-material/Mic'
@ -14,10 +26,13 @@ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'
import GroupsIcon from '@mui/icons-material/Groups'
import PaymentIcon from '@mui/icons-material/Payment'
import LogoutIcon from '@mui/icons-material/Logout'
import PaletteIcon from '@mui/icons-material/Palette'
import type { ThemeMode } from '@d3ro/core/types'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import { AVAILABLE_MODES, useThemeMode } from '@/components/providers/theme-mode-context'
interface NavItem {
key: string
@ -30,6 +45,11 @@ export function Sidebar(): React.ReactElement {
const router = useRouter()
const pathname = usePathname()
const { t } = useI18n()
const { mode, setMode } = useThemeMode()
const handleThemeChange = (event: SelectChangeEvent<ThemeMode>): void => {
setMode(event.target.value as ThemeMode)
}
const items: NavItem[] = [
{
@ -135,6 +155,31 @@ export function Sidebar(): React.ReactElement {
})}
</List>
<Box sx={{ px: 2, py: 1.5, borderTop: `1px solid ${d3roPalette.border.default}` }}>
<FormControl fullWidth size="small">
<InputLabel
id="theme-mode-label"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
<PaletteIcon fontSize="inherit" />
{t('theme.label') ?? 'Theme'}
</InputLabel>
<Select<ThemeMode>
labelId="theme-mode-label"
id="theme-mode-select"
value={mode}
onChange={handleThemeChange}
label={t('theme.label') ?? 'Theme'}
>
{AVAILABLE_MODES.map((m) => (
<MenuItem key={m} value={m}>
{t(`theme.${m}`) ?? m}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<List sx={{ borderTop: `1px solid ${d3roPalette.border.default}` }}>
<ListItem disablePadding>
<ListItemButton onClick={() => void handleLogout()}>

View file

@ -16,13 +16,18 @@ import {
Box,
Alert,
IconButton,
Stack
Stack,
Tab,
Tabs
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import SaveIcon from '@mui/icons-material/Save'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import VisibilityIcon from '@mui/icons-material/Visibility'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import { MarkdownPreview } from './markdown-preview'
interface DocumentEditorProps {
doc: {
@ -41,6 +46,7 @@ export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement
const [content, setContent] = useState(doc.content)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<'edit' | 'preview'>('edit')
async function handleSave(): Promise<void> {
setError(null)
@ -121,22 +127,52 @@ export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement
</Stack>
</DialogTitle>
<DialogContent dividers>
<TextField
fullWidth
multiline
minRows={20}
maxRows={40}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
placeholder="마크다운으로 편집..."
variant="outlined"
slotProps={{
input: {
style: { fontFamily: 'monospace', fontSize: 13 }
}
}}
/>
<Tabs
value={tab}
onChange={(_e, v: 'edit' | 'preview') => setTab(v)}
sx={{ mb: 2 }}
>
<Tab value="edit" icon={<EditIcon fontSize="small" />} iconPosition="start" label="Edit" />
<Tab
value="preview"
icon={<VisibilityIcon fontSize="small" />}
iconPosition="start"
label="Preview"
/>
</Tabs>
{tab === 'edit' ? (
<TextField
fullWidth
multiline
minRows={20}
maxRows={40}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
placeholder="마크다운으로 편집..."
variant="outlined"
slotProps={{
input: {
style: { fontFamily: 'monospace', fontSize: 13 }
}
}}
/>
) : (
<Box
sx={{
minHeight: 400,
maxHeight: '60vh',
overflowY: 'auto',
px: 2,
py: 1.5,
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: 1
}}
>
<MarkdownPreview content={content} />
</Box>
)}
{error && (
<Alert severity="error" variant="outlined" sx={{ mt: 2 }}>
{error}

View file

@ -0,0 +1,171 @@
'use client'
// apps/web/src/components/meetings/markdown-preview.tsx
// react-markdown + remark-gfm 렌더 + mermaid code block → 다이어그램 자동 변환.
// DocumentEditor 미리보기 탭에서 사용.
import { useEffect, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Box } from '@mui/material'
import { d3roPalette } from '@d3ro/ui/theme'
interface MarkdownPreviewProps {
content: string
}
// mermaid는 번들 크기가 크고 brower-only라서 lazy import.
let mermaidReady: Promise<typeof import('mermaid').default> | null = null
function loadMermaid(): Promise<typeof import('mermaid').default> {
if (!mermaidReady) {
mermaidReady = import('mermaid').then((mod) => {
const m = mod.default
m.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'strict',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
})
return m
})
}
return mermaidReady
}
export function MarkdownPreview({ content }: MarkdownPreviewProps): React.ReactElement {
const rootRef = useRef<HTMLDivElement | null>(null)
// Unique run id for this component instance to avoid id collisions.
const runId = useMemo(() => `mermaid-${Math.random().toString(36).slice(2, 9)}`, [])
// content 바뀔 때마다 mermaid 블록 재렌더.
useEffect(() => {
let cancelled = false
void loadMermaid().then((mermaid) => {
if (cancelled || !rootRef.current) return
const nodes = rootRef.current.querySelectorAll<HTMLElement>('.d3ro-mermaid-source')
nodes.forEach((node, idx) => {
const source = node.textContent ?? ''
const id = `${runId}-${idx}`
mermaid
.render(id, source)
.then(({ svg }) => {
if (cancelled) return
node.innerHTML = svg
node.classList.remove('d3ro-mermaid-source')
node.classList.add('d3ro-mermaid-rendered')
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
node.innerHTML = `<pre style="color:${d3roPalette.tag.red};white-space:pre-wrap;">mermaid error: ${msg}</pre>`
})
})
})
return () => {
cancelled = true
}
}, [content, runId])
return (
<Box
ref={rootRef}
sx={{
color: d3roPalette.text.primary,
fontSize: 14,
lineHeight: 1.6,
'& h1, & h2, & h3, & h4': {
color: d3roPalette.text.primary,
fontWeight: 700,
marginTop: '1.2em',
marginBottom: '0.4em'
},
'& h1': { fontSize: '1.6em' },
'& h2': { fontSize: '1.35em' },
'& h3': { fontSize: '1.15em' },
'& p': { margin: '0.6em 0', color: d3roPalette.text.primary },
'& ul, & ol': { paddingLeft: '1.4em', margin: '0.6em 0' },
'& li': { margin: '0.25em 0' },
'& a': { color: d3roPalette.accent.amber, textDecoration: 'underline' },
'& code': {
bgcolor: d3roPalette.bg.inset,
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '0.9em'
},
'& pre': {
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: 1,
padding: 1.5,
overflowX: 'auto',
fontSize: '0.9em'
},
'& pre code': { bgcolor: 'transparent', padding: 0 },
'& blockquote': {
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
margin: '0.8em 0',
paddingLeft: '1em',
color: d3roPalette.text.secondary
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
margin: '0.8em 0'
},
'& th, & td': {
border: `1px solid ${d3roPalette.border.default}`,
padding: '6px 10px',
textAlign: 'left'
},
'& th': { bgcolor: d3roPalette.bg.inset, fontWeight: 700 },
'& hr': { border: 'none', borderTop: `1px solid ${d3roPalette.border.default}`, margin: '1.2em 0' },
'& .d3ro-mermaid-source': {
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
fontSize: '0.85em',
whiteSpace: 'pre',
color: d3roPalette.text.secondary,
bgcolor: d3roPalette.bg.inset,
border: `1px dashed ${d3roPalette.border.default}`,
borderRadius: 1,
padding: 1,
overflowX: 'auto'
},
'& .d3ro-mermaid-rendered': {
display: 'flex',
justifyContent: 'center',
padding: 1,
bgcolor: d3roPalette.bg.inset,
borderRadius: 1,
border: `1px solid ${d3roPalette.border.subtle}`,
'& svg': { maxWidth: '100%', height: 'auto' }
}
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code(props) {
const { className, children, ...rest } = props
const lang = /language-(\w+)/.exec(className ?? '')?.[1]
if (lang === 'mermaid') {
const source = String(children).replace(/\n$/, '')
// pre > div 대신 div 하나로 렌더하면 react-markdown이 span으로 감쌀 수 있음.
// 여기서는 div.d3ro-mermaid-source를 반환하고 useEffect에서 replace.
return <div className="d3ro-mermaid-source">{source}</div>
}
return (
<code className={className} {...rest}>
{children}
</code>
)
}
}}
>
{content}
</ReactMarkdown>
</Box>
)
}

View file

@ -0,0 +1,103 @@
'use client'
// apps/web/src/components/meetings/meeting-audio-player.tsx
// 회의 오디오 재생 — storage key → signed URL → <audio> 엘리먼트
import { useEffect, useState } from 'react'
import { Alert, Box, CircularProgress } from '@mui/material'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
interface MeetingAudioPlayerProps {
storageKey: string | null
}
const SIGNED_URL_TTL_SECONDS = 3600
export function MeetingAudioPlayer({ storageKey }: MeetingAudioPlayerProps): React.ReactElement {
const [signedUrl, setSignedUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)
useEffect(() => {
if (!storageKey) {
setSignedUrl(null)
setError(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
const supabase = getSupabaseBrowserClient()
supabase.storage
.from('audio')
.createSignedUrl(storageKey, SIGNED_URL_TTL_SECONDS)
.then(({ data, error: signErr }) => {
if (cancelled) return
if (signErr || !data?.signedUrl) {
setError(signErr?.message ?? 'Failed to create signed URL')
setSignedUrl(null)
return
}
setSignedUrl(data.signedUrl)
})
.catch((err: unknown) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [storageKey])
if (!storageKey) {
return (
<Box sx={{ ...typoSx('body'), color: d3roPalette.text.muted, fontSize: 13 }}>
.
</Box>
)
}
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}> URL ...</Box>
</Box>
)
}
if (error) {
return (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)
}
if (!signedUrl) return <></>
return (
<Box sx={{ width: '100%' }}>
<Box
component="audio"
controls
preload="metadata"
sx={{
width: '100%',
'&::-webkit-media-controls-panel': {
backgroundColor: d3roPalette.bg.inset
}
}}
src={signedUrl}
/>
</Box>
)
}

View file

@ -0,0 +1,101 @@
'use client'
// apps/web/src/components/providers/theme-mode-context.tsx
// 웹 테마 모드(dark/light/nord/...) 전역 상태.
// localStorage 영속 + 시스템 prefers-color-scheme 기본값.
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { ThemeMode } from '@d3ro/core/types'
const STORAGE_KEY = 'd3ro.voice.theme-mode'
const DEFAULT_MODE: ThemeMode = 'dark'
export const AVAILABLE_MODES: readonly ThemeMode[] = [
'dark',
'light',
'nord',
'solarized',
'catppuccin',
'dracula',
'auto'
] as const
interface ThemeModeContextValue {
mode: ThemeMode
effectiveMode: Exclude<ThemeMode, 'auto'>
setMode: (next: ThemeMode) => void
prefersDark: boolean
}
const ThemeModeContext = createContext<ThemeModeContextValue>({
mode: DEFAULT_MODE,
effectiveMode: 'dark',
setMode: () => undefined,
prefersDark: true
})
function loadStoredMode(): ThemeMode {
if (typeof window === 'undefined') return DEFAULT_MODE
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (raw && (AVAILABLE_MODES as readonly string[]).includes(raw)) {
return raw as ThemeMode
}
} catch {
// storage blocked
}
return DEFAULT_MODE
}
export function ThemeModeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [mode, setModeState] = useState<ThemeMode>(DEFAULT_MODE)
const [prefersDark, setPrefersDark] = useState<boolean>(true)
const [hydrated, setHydrated] = useState<boolean>(false)
// 초기 로드 (CSR only — SSR은 dark 기본)
useEffect(() => {
setModeState(loadStoredMode())
if (typeof window !== 'undefined' && 'matchMedia' in window) {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
setPrefersDark(mq.matches)
const listener = (e: MediaQueryListEvent): void => {
setPrefersDark(e.matches)
}
mq.addEventListener('change', listener)
setHydrated(true)
return () => {
mq.removeEventListener('change', listener)
}
}
setHydrated(true)
return undefined
}, [])
const setMode = useCallback((next: ThemeMode): void => {
setModeState(next)
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
// storage blocked
}
}
}, [])
const effectiveMode: Exclude<ThemeMode, 'auto'> =
mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
// hydration 전에는 SSR과 동일한 dark 기본값 유지 (mismatch 방지)
const value: ThemeModeContextValue = {
mode: hydrated ? mode : DEFAULT_MODE,
effectiveMode: hydrated ? effectiveMode : 'dark',
setMode,
prefersDark
}
return <ThemeModeContext.Provider value={value}>{children}</ThemeModeContext.Provider>
}
export function useThemeMode(): ThemeModeContextValue {
return useContext(ThemeModeContext)
}

View file

@ -2,23 +2,32 @@
// apps/web/src/components/providers/theme-provider.tsx
// MUI ThemeProvider + Emotion cache (Next.js App Router용)
// ThemeModeProvider로 감싸서 런타임 테마 모드 전환 지원.
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'
import { ThemeModeProvider, useThemeMode } from './theme-mode-context'
export function ThemeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
// 웹은 시스템 prefers-color-scheme 대신 사용자 설정 저장소를 나중에 추가.
// V2-3 MVP에서는 dark 고정.
const theme = useMemo(() => getTheme('dark', true), [])
function ThemeProviderInner({ children }: { children: React.ReactNode }): React.ReactElement {
const { effectiveMode } = useThemeMode()
const theme = useMemo(() => getTheme(effectiveMode, true), [effectiveMode])
return (
<AppRouterCacheProvider options={{ key: 'd3ro-mui', enableCssLayer: true }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
)
}
export function ThemeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<AppRouterCacheProvider options={{ key: 'mui', enableCssLayer: true }}>
<ThemeModeProvider>
<ThemeProviderInner>{children}</ThemeProviderInner>
</ThemeModeProvider>
</AppRouterCacheProvider>
)
}