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:
parent
162a59dc47
commit
07c6d13c99
24 changed files with 2220 additions and 63 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue