// src/renderer/pages/DashboardPage.tsx // 레퍼런스(Meteorological Instrument) 스타일 대시보드: // ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리 import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { Box } from '@mui/material' import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '../components/ds' import { EmptyStateCard, HistoryEntryCard } from '../components/shared' import { d3roPalette, d3roTypo } from '../theme' import { useI18n } from '../i18n' import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters' import { FileDropZone } from '../components/FileDropZone' import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@shared/types' // ── 메인 컴포넌트 ───────────────────────────────────── export function DashboardPage(): React.ReactElement { const { t, formatTime, formatRelativeDate } = useI18n() const [stats, setStats] = useState(null) const [history, setHistory] = useState([]) const [ollamaConnected, setOllamaConnected] = useState(false) const [dictationBinding, setDictationBinding] = useState(null) const [activeView, setActiveView] = useState<'stats' | 'status'>('stats') const [captionState, setCaptionState] = useState('inactive') const [audioLevel, setAudioLevel] = useState(0) const audioDecayRef = useRef | null>(null) const [licenseTier, setLicenseTier] = useState('free') const [usageQuotas, setUsageQuotas] = useState([]) const loadData = useCallback(() => { window.electronAPI.stats.getSummary().then((r) => { if (r.success) setStats(r.data) }) window.electronAPI.llm.getStatus().then((r) => { if (r.success) setOllamaConnected(r.data.connectionState === 'connected') }) window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => { if (r.success) setHistory(r.data.entries) }) window.electronAPI.hotkey.getDictationShortcut().then((r) => { if (r.success && r.data) setDictationBinding(r.data) }) window.electronAPI.caption.getState().then((r) => { if (r.success) setCaptionState(r.data) }) window.electronAPI.license.getInfo().then((r) => { if (r.success) setLicenseTier(r.data.tier) }) window.electronAPI.license.getAllUsage().then((r) => { if (r.success) setUsageQuotas(r.data) }) }, []) useEffect(() => { loadData() const interval = setInterval(loadData, 30000) const unsub = window.electronAPI.app.onDataChanged(() => { loadData() }) const unsubCaption = window.electronAPI.caption.onStateChanged((state) => { setCaptionState(state) }) const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => { setAudioLevel(e.level) }) // 오디오 이벤트가 없을 때 서서히 감쇠 audioDecayRef.current = setInterval(() => { setAudioLevel(prev => prev > 0.01 ? prev * 0.85 : 0) }, 100) return () => { clearInterval(interval) if (audioDecayRef.current) clearInterval(audioDecayRef.current) unsub(); unsubCaption(); unsubAudio() } }, [loadData]) // 날짜별 그룹핑 const groupedHistory = useMemo(() => { const groups: Record = {} for (const entry of history) { const key = getDateKey(entry.createdAt) if (!groups[key]) { groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] } } groups[key].entries.push(entry) } return Object.values(groups) }, [history, formatRelativeDate]) const services = useMemo(() => [ { name: t('service.sttEngine'), status: t('service.ready'), ok: true }, { name: t('service.ollamaLlm'), status: ollamaConnected ? t('service.connected') : t('service.offline'), ok: ollamaConnected }, { name: t('service.hotkeyHook'), status: t('service.active'), ok: true }, { name: t('service.audioInput'), status: t('service.standby'), ok: true }, ], [t, ollamaConnected]) return ( {/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */} {/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */} {/* 상단 라벨 */} {activeView === 'stats' ? t('dashboard.sessionOverview').toUpperCase() : t('dashboard.systemStatus').toUpperCase()} {new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()} {/* 중앙: 큰 수치 or 서비스 상태 */} {activeView === 'stats' ? ( <> {stats?.todaySessionCount ?? 0} {t('dashboard.sessionsToday').toUpperCase()} {dictationBinding ? t('dashboard.pressToRecord', { key: dictationBinding.displayLabel.toUpperCase() }).toUpperCase() : t('dashboard.hotkeyNotSet').toUpperCase()} ) : ( {services.map((svc) => ( {svc.name} {svc.status} ))} )} {/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */} {activeView === 'stats' && ( {t('dashboard.words').toUpperCase()} {formatNumber(stats?.totalWordCount ?? 0)} {t('dashboard.total').toUpperCase()} {t('dashboard.streak').toUpperCase()} {stats?.streakDays ?? 0} {t('dashboard.days').toUpperCase()} )} {/* 우측: 컨트롤 패널 */} {/* LED 상태 클러스터 */} {/* 버튼 그룹 */} setActiveView('stats')} sx={{ minWidth: 0 }} > {t('dashboard.stat').toUpperCase()} setActiveView('status')} sx={{ minWidth: 0 }} > {t('dashboard.sys').toUpperCase()} {/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */} {[ { label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) }, { label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() }, { label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() }, { label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() }, ].map((card) => ( {card.label} {card.value} {card.unit} ))} {/* ── 2.4. 사용량 바 (Free 티어) ─────────────── */} {licenseTier === 'free' && usageQuotas.length > 0 && ( {t('license.usageToday').toUpperCase()} {usageQuotas.map((q) => ( {t(`license.feature.${q.feature}`)} {q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`} {q.limit > 0 && ( = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber, borderRadius: '2px', transition: 'width 0.3s ease', }} /> )} ))} )} {/* ── 2.5. 실시간 자막 토글 ────────────────── */} {t('dashboard.caption').toUpperCase()} {captionState === 'active' && ( {t('dashboard.captionActive').toUpperCase()} )} { if (captionState === 'active') { await window.electronAPI.caption.stop() } else if (captionState === 'inactive') { await window.electronAPI.caption.start() } }} sx={{ minWidth: 100 }} > {captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()} {/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */} {services.map((svc) => ( {svc.name} {svc.status} ))} {/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */} {t('fileTranscription.title').toUpperCase()} {/* ── 4. 최근 히스토리 ──────────────────────── */} {t('dashboard.recentTranscriptions').toUpperCase()} {history.length === 0 ? ( ) : ( groupedHistory.map((group) => ( {/* 날짜 구분자 */} {group.label} {t('dashboard.entries', { count: group.entries.length })} {/* 히스토리 항목 */} {group.entries.map((entry) => ( navigator.clipboard.writeText(text)} /> ))} )) )} ) }