diff --git a/apps/web/package.json b/apps/web/package.json index f06bfcc..09441d4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,9 +25,13 @@ "@mui/material-nextjs": "^7.0.0", "@supabase/ssr": "^0.10.0", "@supabase/supabase-js": "^2.103.0", + "mermaid": "^11.14.0", "next": "^15.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "recharts": "^3.8.1", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/node": "^22.13.0", diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 54510f3..318dacd 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -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 { - const { stats, recentMeetings } = await loadDashboardData() + const { stats, recentMeetings, trend } = await loadDashboardData() return ( @@ -80,6 +137,13 @@ export default async function DashboardPage(): Promise { ))} + + + ACTIVITY TREND ({TREND_DAYS}D) + + + + RECENT MEETINGS diff --git a/apps/web/src/app/(app)/meetings/[id]/page.tsx b/apps/web/src/app/(app)/meetings/[id]/page.tsx index 7430ac3..639c7df 100644 --- a/apps/web/src/app/(app)/meetings/[id]/page.tsx +++ b/apps/web/src/app/(app)/meetings/[id]/page.tsx @@ -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< + {/* Audio — Supabase Storage signed URL */} + + + AUDIO + + + + {/* Transcript — Realtime 구독 */} diff --git a/apps/web/src/components/dashboard/meetings-trend-chart.tsx b/apps/web/src/components/dashboard/meetings-trend-chart.tsx new file mode 100644 index 0000000..e3bb255 --- /dev/null +++ b/apps/web/src/components/dashboard/meetings-trend-chart.tsx @@ -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 ( + + + + + + + + + + + + + + ) +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 7a27590..dd67355 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -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): void => { + setMode(event.target.value as ThemeMode) + } const items: NavItem[] = [ { @@ -135,6 +155,31 @@ export function Sidebar(): React.ReactElement { })} + + + + + {t('theme.label') ?? 'Theme'} + + + labelId="theme-mode-label" + id="theme-mode-select" + value={mode} + onChange={handleThemeChange} + label={t('theme.label') ?? 'Theme'} + > + {AVAILABLE_MODES.map((m) => ( + + {t(`theme.${m}`) ?? m} + + ))} + + + + void handleLogout()}> diff --git a/apps/web/src/components/meetings/document-editor.tsx b/apps/web/src/components/meetings/document-editor.tsx index 337a068..0c0fa18 100644 --- a/apps/web/src/components/meetings/document-editor.tsx +++ b/apps/web/src/components/meetings/document-editor.tsx @@ -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(null) + const [tab, setTab] = useState<'edit' | 'preview'>('edit') async function handleSave(): Promise { setError(null) @@ -121,22 +127,52 @@ export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement - setContent(e.target.value)} - disabled={busy} - placeholder="마크다운으로 편집..." - variant="outlined" - slotProps={{ - input: { - style: { fontFamily: 'monospace', fontSize: 13 } - } - }} - /> + setTab(v)} + sx={{ mb: 2 }} + > + } iconPosition="start" label="Edit" /> + } + iconPosition="start" + label="Preview" + /> + + {tab === 'edit' ? ( + setContent(e.target.value)} + disabled={busy} + placeholder="마크다운으로 편집..." + variant="outlined" + slotProps={{ + input: { + style: { fontFamily: 'monospace', fontSize: 13 } + } + }} + /> + ) : ( + + + + )} {error && ( {error} diff --git a/apps/web/src/components/meetings/markdown-preview.tsx b/apps/web/src/components/meetings/markdown-preview.tsx new file mode 100644 index 0000000..82cbf97 --- /dev/null +++ b/apps/web/src/components/meetings/markdown-preview.tsx @@ -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 | null = null + +function loadMermaid(): Promise { + 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(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('.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 = `
mermaid error: ${msg}
` + }) + }) + }) + return () => { + cancelled = true + } + }, [content, runId]) + + return ( + + div 대신 div 하나로 렌더하면 react-markdown이 span으로 감쌀 수 있음. + // 여기서는 div.d3ro-mermaid-source를 반환하고 useEffect에서 replace. + return
{source}
+ } + return ( + + {children} + + ) + } + }} + > + {content} +
+
+ ) +} diff --git a/apps/web/src/components/meetings/meeting-audio-player.tsx b/apps/web/src/components/meetings/meeting-audio-player.tsx new file mode 100644 index 0000000..edc8b76 --- /dev/null +++ b/apps/web/src/components/meetings/meeting-audio-player.tsx @@ -0,0 +1,103 @@ +'use client' + +// apps/web/src/components/meetings/meeting-audio-player.tsx +// 회의 오디오 재생 — storage key → signed URL →