- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
399 lines
18 KiB
TypeScript
399 lines
18 KiB
TypeScript
// 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<StatsSummary | null>(null)
|
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
|
const [ollamaConnected, setOllamaConnected] = useState(false)
|
|
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
|
const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
|
|
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
|
|
const [audioLevel, setAudioLevel] = useState(0)
|
|
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const [licenseTier, setLicenseTier] = useState<LicenseTier>('free')
|
|
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
|
|
|
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<string, { label: string; entries: HistoryEntry[] }> = {}
|
|
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 (
|
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
|
|
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
|
|
<InstrumentPanel
|
|
engravingLeft="D3RO-VOICE"
|
|
engravingRight="v1.0.0"
|
|
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 180px',
|
|
gap: 2.5,
|
|
alignItems: 'stretch',
|
|
}}
|
|
>
|
|
{/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
|
|
<ScreenPanel height={200}>
|
|
{/* 상단 라벨 */}
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<PhosphorText variant="label">
|
|
{activeView === 'stats'
|
|
? t('dashboard.sessionOverview').toUpperCase()
|
|
: t('dashboard.systemStatus').toUpperCase()}
|
|
</PhosphorText>
|
|
<PhosphorText variant="label">
|
|
{new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
|
|
</PhosphorText>
|
|
</Box>
|
|
|
|
{/* 중앙: 큰 수치 or 서비스 상태 */}
|
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
|
{activeView === 'stats' ? (
|
|
<>
|
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
|
<PhosphorText variant="hero">
|
|
{stats?.todaySessionCount ?? 0}
|
|
</PhosphorText>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
{t('dashboard.sessionsToday').toUpperCase()}
|
|
</PhosphorText>
|
|
</Box>
|
|
<PhosphorText variant="label" sx={{ mt: 0.5, color: d3roPalette.text.dimLabel }}>
|
|
{dictationBinding
|
|
? t('dashboard.pressToRecord', { key: dictationBinding.displayLabel.toUpperCase() }).toUpperCase()
|
|
: t('dashboard.hotkeyNotSet').toUpperCase()}
|
|
</PhosphorText>
|
|
</>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{services.map((svc) => (
|
|
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
|
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
|
<PhosphorText
|
|
variant="value"
|
|
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
|
>
|
|
{svc.status}
|
|
</PhosphorText>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
|
|
{activeView === 'stats' && (
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<Box>
|
|
<PhosphorText variant="label">{t('dashboard.words').toUpperCase()}</PhosphorText>
|
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
|
<PhosphorText variant="value">
|
|
{formatNumber(stats?.totalWordCount ?? 0)}
|
|
</PhosphorText>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
{t('dashboard.total').toUpperCase()}
|
|
</PhosphorText>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ textAlign: 'right' }}>
|
|
<PhosphorText variant="label">{t('dashboard.streak').toUpperCase()}</PhosphorText>
|
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, justifyContent: 'flex-end' }}>
|
|
<PhosphorText variant="value">
|
|
{stats?.streakDays ?? 0}
|
|
</PhosphorText>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
{t('dashboard.days').toUpperCase()}
|
|
</PhosphorText>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</ScreenPanel>
|
|
|
|
{/* 우측: 컨트롤 패널 */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
{/* LED 상태 클러스터 */}
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
|
|
<Led color={ollamaConnected ? 'green' : 'red'} size={8} />
|
|
<Led color="amber" pulse size={8} />
|
|
</Box>
|
|
|
|
{/* 버튼 그룹 */}
|
|
<ButtonGroup>
|
|
<PhysicalButton
|
|
selected={activeView === 'stats'}
|
|
onClick={() => setActiveView('stats')}
|
|
sx={{ minWidth: 0 }}
|
|
>
|
|
{t('dashboard.stat').toUpperCase()}
|
|
</PhysicalButton>
|
|
<PhysicalButton
|
|
selected={activeView === 'status'}
|
|
onClick={() => setActiveView('status')}
|
|
sx={{ minWidth: 0 }}
|
|
>
|
|
{t('dashboard.sys').toUpperCase()}
|
|
</PhysicalButton>
|
|
</ButtonGroup>
|
|
</Box>
|
|
</Box>
|
|
</InstrumentPanel>
|
|
|
|
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
gap: 2,
|
|
mt: 3,
|
|
}}
|
|
>
|
|
{[
|
|
{ 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) => (
|
|
<MetalCard key={card.label}>
|
|
<Box sx={{ textAlign: 'center', py: 1 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
|
{card.label}
|
|
</PhosphorText>
|
|
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
|
|
<PhosphorText variant="value" sx={{ fontSize: d3roTypo.title.size, fontWeight: 300 }}>
|
|
{card.value}
|
|
</PhosphorText>
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
|
{card.unit}
|
|
</PhosphorText>
|
|
</Box>
|
|
</Box>
|
|
</MetalCard>
|
|
))}
|
|
</Box>
|
|
|
|
{/* ── 2.4. 사용량 바 (Free 티어) ─────────────── */}
|
|
{licenseTier === 'free' && usageQuotas.length > 0 && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<MetalCard>
|
|
<Box sx={{ px: 1, py: 0.5 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
|
{t('license.usageToday').toUpperCase()}
|
|
</PhosphorText>
|
|
{usageQuotas.map((q) => (
|
|
<Box key={q.feature} sx={{ mb: 1 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
|
<PhosphorText variant="small">
|
|
{t(`license.feature.${q.feature}`)}
|
|
</PhosphorText>
|
|
<PhosphorText variant="small" sx={{ color: q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
|
|
{q.limit === -1
|
|
? t('license.unlimited')
|
|
: `${q.used}/${q.limit}`}
|
|
</PhosphorText>
|
|
</Box>
|
|
{q.limit > 0 && (
|
|
<Box sx={{
|
|
height: 3,
|
|
borderRadius: '2px',
|
|
bgcolor: d3roPalette.bg.inset,
|
|
overflow: 'hidden',
|
|
}}>
|
|
<Box sx={{
|
|
height: '100%',
|
|
width: `${Math.min(100, (q.used / q.limit) * 100)}%`,
|
|
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
|
borderRadius: '2px',
|
|
transition: 'width 0.3s ease',
|
|
}} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</MetalCard>
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── 2.5. 실시간 자막 토글 ────────────────── */}
|
|
<Box sx={{ mt: 3 }}>
|
|
<MetalCard>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Led
|
|
color={captionState === 'active' ? 'green' : captionState === 'starting' || captionState === 'stopping' ? 'amber' : 'off'}
|
|
size={8}
|
|
pulse={captionState === 'active'}
|
|
/>
|
|
<Box>
|
|
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight }}>
|
|
{t('dashboard.caption').toUpperCase()}
|
|
</PhosphorText>
|
|
{captionState === 'active' && (
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size, color: d3roPalette.tag.green }}>
|
|
{t('dashboard.captionActive').toUpperCase()}
|
|
</PhosphorText>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<PhysicalButton
|
|
selected={captionState === 'active'}
|
|
onClick={async () => {
|
|
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()}
|
|
</PhysicalButton>
|
|
</Box>
|
|
</MetalCard>
|
|
</Box>
|
|
|
|
{/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
|
|
<Box sx={{ mt: 3 }}>
|
|
<CrtDisplay amplitude={0.05} frequency={6} height={100} audioLevel={audioLevel}>
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, px: 1 }}>
|
|
{services.map((svc) => (
|
|
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
|
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
|
<PhosphorText
|
|
variant="value"
|
|
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
|
>
|
|
{svc.status}
|
|
</PhosphorText>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</CrtDisplay>
|
|
</Box>
|
|
|
|
{/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
|
|
<Box sx={{ mt: 3 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
|
|
{t('fileTranscription.title').toUpperCase()}
|
|
</PhosphorText>
|
|
<FileDropZone />
|
|
</Box>
|
|
|
|
{/* ── 4. 최근 히스토리 ──────────────────────── */}
|
|
<Box sx={{ mt: 4 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
|
{t('dashboard.recentTranscriptions').toUpperCase()}
|
|
</PhosphorText>
|
|
|
|
{history.length === 0 ? (
|
|
<EmptyStateCard message={t('dashboard.noHistory')} />
|
|
) : (
|
|
groupedHistory.map((group) => (
|
|
<Box key={group.label} sx={{ mb: 3 }}>
|
|
{/* 날짜 구분자 */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
|
{group.label}
|
|
</PhosphorText>
|
|
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
|
|
{t('dashboard.entries', { count: group.entries.length })}
|
|
</PhosphorText>
|
|
</Box>
|
|
|
|
{/* 히스토리 항목 */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{group.entries.map((entry) => (
|
|
<HistoryEntryCard
|
|
key={entry.id}
|
|
entry={entry}
|
|
onCopy={(text) => navigator.clipboard.writeText(text)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|