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