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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
83
apps/web/src/components/dashboard/meetings-trend-chart.tsx
Normal file
83
apps/web/src/components/dashboard/meetings-trend-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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,6 +127,20 @@ export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement
|
|||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<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
|
||||
|
|
@ -137,6 +157,22 @@ export function DocumentEditor({ doc }: DocumentEditorProps): React.ReactElement
|
|||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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}
|
||||
|
|
|
|||
171
apps/web/src/components/meetings/markdown-preview.tsx
Normal file
171
apps/web/src/components/meetings/markdown-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
apps/web/src/components/meetings/meeting-audio-player.tsx
Normal file
103
apps/web/src/components/meetings/meeting-audio-player.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
apps/web/src/components/providers/theme-mode-context.tsx
Normal file
101
apps/web/src/components/providers/theme-mode-context.tsx
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<AppRouterCacheProvider options={{ key: 'mui', enableCssLayer: true }}>
|
||||
<ThemeModeProvider>
|
||||
<ThemeProviderInner>{children}</ThemeProviderInner>
|
||||
</ThemeModeProvider>
|
||||
</AppRouterCacheProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,16 @@ V2 5차 고도화 (2026-04-10):
|
|||
- [U] ✅ Desktop CloudSyncService Realtime 구독 + web record 오디오 Storage 업로드 + 회의 메모 작성 UI (MemoForm)
|
||||
- [V] ✅ 11개 locale에 nav.chat/knowledge/actions 키 추가, ko.json 정리, Sidebar Actions 메뉴
|
||||
|
||||
**다음 사이클**: 사용자 환경 실제 연결, 회의 상세 편집 고도화(Rich Markdown preview, mermaid 렌더), RAG에서 검색 결과 → /chat으로 연동, VoiceAction 카탈로그 확대, E2E 테스트 실제 실행 (Playwright install)
|
||||
V2 6차 고도화 (2026-04-11):
|
||||
- [W] 🐛 fix: AppRouterCacheProvider Emotion key `'d3ro-mui'` → `'mui'` (`d3ro` 안에 숫자 `3` → Emotion 검증 실패로 dev SSR 500)
|
||||
- [A] ✅ Dark/Light/Theme 토글 — `theme-mode-context.tsx` (localStorage + prefers-color-scheme), Sidebar에 6종 + auto MUI Select, 12개 locale `theme.*` 키 추가
|
||||
- [B] ✅ DocumentEditor Mermaid + Markdown 렌더 — `markdown-preview.tsx` (react-markdown + remark-gfm + lazy mermaid), Edit/Preview Tabs
|
||||
- [C] ✅ 회의 오디오 재생 — `meeting-audio-player.tsx` (Storage signed URL 1h TTL → `<audio controls>`), `/meetings/[id]` AUDIO 카드
|
||||
- [D] ✅ Dashboard 14일 추이 차트 — recharts LineChart (회의/문서 수 일별 집계), 이번 주 stat 자동 계산
|
||||
|
||||
**6차 검증**: web typecheck/dev 200, production build 15 라우트 (`dashboard` 1.6kB→111kB, `meetings/[id]` 4.8kB→55kB by chart/mermaid)
|
||||
|
||||
**다음 사이클 후보**: VoiceAction 카탈로그 확대, RAG → /chat 연동, Knowledge 파일 업로드(PDF/docx), Voice Input UI(/actions 마이크), 알림 센터, 팀 대시보드, recharts dynamic import로 dashboard split, Playwright browsers install + E2E 실 실행
|
||||
|
||||
## V1 완료 페이즈
|
||||
|
||||
|
|
|
|||
1433
package-lock.json
generated
1433
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "HuggingFace Token",
|
||||
"settings.hfTokenHint": "Ein HuggingFace Token wird für die Sprechertrennung benötigt",
|
||||
"settings.diarization": "Sprechertrennung",
|
||||
"settings.diarizationHint": "Sprecher werden nach der Aufnahme automatisch identifiziert"
|
||||
"settings.diarizationHint": "Sprecher werden nach der Aufnahme automatisch identifiziert",
|
||||
"theme.label": "Design",
|
||||
"theme.dark": "Dunkel",
|
||||
"theme.light": "Hell",
|
||||
"theme.auto": "Auto (System)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -491,5 +491,13 @@
|
|||
"settings.hfToken": "HuggingFace Token",
|
||||
"settings.hfTokenHint": "A HuggingFace token is required for speaker diarization",
|
||||
"settings.diarization": "Speaker Diarization",
|
||||
"settings.diarizationHint": "Automatically identify speakers after recording ends"
|
||||
"settings.diarizationHint": "Automatically identify speakers after recording ends",
|
||||
"theme.label": "Theme",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"theme.auto": "Auto (system)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "Token de HuggingFace",
|
||||
"settings.hfTokenHint": "Se necesita un token de HuggingFace para identificar hablantes",
|
||||
"settings.diarization": "Identificación de hablantes",
|
||||
"settings.diarizationHint": "Identifica automáticamente los hablantes al finalizar la grabación"
|
||||
"settings.diarizationHint": "Identifica automáticamente los hablantes al finalizar la grabación",
|
||||
"theme.label": "Tema",
|
||||
"theme.dark": "Oscuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.auto": "Auto (sistema)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "Token HuggingFace",
|
||||
"settings.hfTokenHint": "Un token HuggingFace est requis pour l'identification des locuteurs",
|
||||
"settings.diarization": "Identification des locuteurs",
|
||||
"settings.diarizationHint": "Identifier automatiquement les locuteurs après l'enregistrement"
|
||||
"settings.diarizationHint": "Identifier automatiquement les locuteurs après l'enregistrement",
|
||||
"theme.label": "Thème",
|
||||
"theme.dark": "Sombre",
|
||||
"theme.light": "Clair",
|
||||
"theme.auto": "Auto (système)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "HuggingFaceトークン",
|
||||
"settings.hfTokenHint": "話者分離にはHuggingFaceトークンが必要です",
|
||||
"settings.diarization": "話者分離",
|
||||
"settings.diarizationHint": "録音終了後に自動的に話者を識別します"
|
||||
"settings.diarizationHint": "録音終了後に自動的に話者を識別します",
|
||||
"theme.label": "テーマ",
|
||||
"theme.dark": "ダーク",
|
||||
"theme.light": "ライト",
|
||||
"theme.auto": "自動 (システム)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -491,5 +491,13 @@
|
|||
"settings.hfToken": "HuggingFace 토큰",
|
||||
"settings.hfTokenHint": "화자 구분을 위해 HuggingFace 토큰이 필요합니다",
|
||||
"settings.diarization": "화자 구분",
|
||||
"settings.diarizationHint": "녹음 종료 후 화자를 자동으로 구분합니다"
|
||||
"settings.diarizationHint": "녹음 종료 후 화자를 자동으로 구분합니다",
|
||||
"theme.label": "테마",
|
||||
"theme.dark": "다크",
|
||||
"theme.light": "라이트",
|
||||
"theme.auto": "자동 (시스템)",
|
||||
"theme.nord": "노드",
|
||||
"theme.solarized": "솔라라이즈드",
|
||||
"theme.catppuccin": "카푸치노",
|
||||
"theme.dracula": "드라큘라"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "Token do HuggingFace",
|
||||
"settings.hfTokenHint": "Um token do HuggingFace é necessário para identificar oradores",
|
||||
"settings.diarization": "Identificação de oradores",
|
||||
"settings.diarizationHint": "Identifica automaticamente os oradores após o término da gravação"
|
||||
"settings.diarizationHint": "Identifica automaticamente os oradores após o término da gravação",
|
||||
"theme.label": "Tema",
|
||||
"theme.dark": "Escuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.auto": "Auto (sistema)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "Токен HuggingFace",
|
||||
"settings.hfTokenHint": "Токен HuggingFace необходим для определения говорящих",
|
||||
"settings.diarization": "Диаризация",
|
||||
"settings.diarizationHint": "Автоматически определять говорящих после завершения записи"
|
||||
"settings.diarizationHint": "Автоматически определять говорящих после завершения записи",
|
||||
"theme.label": "Тема",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.light": "Светлая",
|
||||
"theme.auto": "Авто (система)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "โทเค็น HuggingFace",
|
||||
"settings.hfTokenHint": "ต้องใช้โทเค็น HuggingFace สำหรับการแยกผู้พูด",
|
||||
"settings.diarization": "การแยกผู้พูด",
|
||||
"settings.diarizationHint": "ระบุผู้พูดโดยอัตโนมัติหลังจากการบันทึกสิ้นสุด"
|
||||
"settings.diarizationHint": "ระบุผู้พูดโดยอัตโนมัติหลังจากการบันทึกสิ้นสุด",
|
||||
"theme.label": "ธีม",
|
||||
"theme.dark": "มืด",
|
||||
"theme.light": "สว่าง",
|
||||
"theme.auto": "อัตโนมัติ (ระบบ)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "Token HuggingFace",
|
||||
"settings.hfTokenHint": "Cần token HuggingFace để phân tách người nói",
|
||||
"settings.diarization": "Phân tách người nói",
|
||||
"settings.diarizationHint": "Tự động nhận dạng người nói sau khi ghi âm kết thúc"
|
||||
"settings.diarizationHint": "Tự động nhận dạng người nói sau khi ghi âm kết thúc",
|
||||
"theme.label": "Chủ đề",
|
||||
"theme.dark": "Tối",
|
||||
"theme.light": "Sáng",
|
||||
"theme.auto": "Tự động (hệ thống)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "HuggingFace 令牌",
|
||||
"settings.hfTokenHint": "說話人分離需要HuggingFace令牌",
|
||||
"settings.diarization": "說話人分離",
|
||||
"settings.diarizationHint": "錄音結束後自動識別說話人"
|
||||
"settings.diarizationHint": "錄音結束後自動識別說話人",
|
||||
"theme.label": "主題",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "淺色",
|
||||
"theme.auto": "自動 (系統)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
|
|
@ -291,5 +291,13 @@
|
|||
"settings.hfToken": "HuggingFace令牌",
|
||||
"settings.hfTokenHint": "说话人分离需要HuggingFace令牌",
|
||||
"settings.diarization": "说话人分离",
|
||||
"settings.diarizationHint": "录音结束后自动识别说话人"
|
||||
"settings.diarizationHint": "录音结束后自动识别说话人",
|
||||
"theme.label": "主题",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.auto": "自动 (系统)",
|
||||
"theme.nord": "Nord",
|
||||
"theme.solarized": "Solarized",
|
||||
"theme.catppuccin": "Catppuccin",
|
||||
"theme.dracula": "Dracula"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue