feat(ui): Midnight Glass v2 전면 리디자인 — 기반+셸+대시보드

- theme.ts 재작성: glass/gradient/glow 토큰군, 기본 dark=Midnight(블루),
  6종 테마 변형 전부 파생 토큰 유지 (테마 시스템 보존)
- DS 리스킨(API 호환): 글래스 카드/버튼, Pretendard 타이포, 소프트 LED
- 신규: GradientWave(canvas 스펙트럼 음파), StatRing(컬러 링 타일)
- 보더리스: frame:false + 커스텀 TitleBar, 네이티브 스크롤바 제거(폭 불변)
  + OverlayScrollbars 오버레이 스크롤
- AppLayout 와이드 사이드바(lucide, 그라디언트 액티브 필), 대시보드 재구축,
  StatusBar(브랜드/로컬시간), Pretendard 번들, i18n +20키(ko/en)
This commit is contained in:
Yun Chan 2026-07-21 20:14:15 +09:00
parent f3ca0eafd4
commit a8a1d6e546
25 changed files with 1638 additions and 960 deletions

View file

@ -1,34 +1,131 @@
// src/renderer/pages/DashboardPage.tsx
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
// v2 "Midnight Glass" 대시보드 — 레퍼런스 시안 재현:
// 히어로(웨이브+CTA) / 컬러 링 스탯 4타일 / 백엔드·요금제 / 사용량·실시간 자막 /
// 시스템 상태(웨이브+레벨미터) / 파일 전사 / 최근 히스토리
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box } from '@mui/material'
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '@d3ro/ui/components/ds'
import { Box, Button, Typography } from '@mui/material'
import {
Mic,
BookOpen,
CalendarDays,
Flame,
Settings2,
KeyRound,
BarChart3,
Star,
Check,
Cpu,
} from 'lucide-react'
import { GradientWave, Led, MetalCard, PhosphorText, PhysicalButton, StatRing } from '@d3ro/ui/components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
import { FileDropZone } from '../components/FileDropZone'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
// ── 로컬 프리미티브 ───────────────────────────────────
/** 옅은 헤어라인 필 (레퍼런스의 "TUE" / "로컬 연결" 칩) */
function Pill({
children,
tone = 'neutral',
}: {
children: React.ReactNode
tone?: 'neutral' | 'accent' | 'green'
}): React.ReactElement {
const color =
tone === 'accent' ? d3roPalette.accent.light : tone === 'green' ? d3roPalette.tag.green : d3roPalette.text.secondary
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: d3roRadius.pill,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
bgcolor: d3roPalette.glass.raised,
color,
...typoSx('meta'),
fontFamily: d3roFontSans,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{children}
</Box>
)
}
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
function CardHeader({
title,
right,
}: {
title: string
right?: React.ReactNode
}): React.ReactElement {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1.5,
mb: 2,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<PhosphorText variant="heading">{title}</PhosphorText>
{right}
</Box>
)
}
/** 그라디언트 프로그레스 바 */
function GradientBar({
ratio,
alt = false,
}: {
/** 0~1 (무제한이면 1) */
ratio: number
alt?: boolean
}): React.ReactElement {
return (
<Box sx={{ height: 5, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
<Box
sx={{
height: '100%',
width: `${Math.min(100, Math.max(4, ratio * 100))}%`,
backgroundImage: alt ? d3roPalette.gradient.barAlt : d3roPalette.gradient.bar,
borderRadius: d3roRadius.pill,
transition: 'width 0.3s ease',
}}
/>
</Box>
)
}
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
const { t, formatTime, formatRelativeDate } = useI18n()
const { t, formatRelativeDate } = useI18n()
const [stats, setStats] = useState<StatsSummary | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([])
const [ollamaConnected, setOllamaConnected] = useState(false)
const [llmModel, setLlmModel] = useState<string | null>(null)
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 [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null)
const statsRef = useRef<HTMLDivElement>(null)
const loadData = useCallback(() => {
window.electronAPI.stats.getSummary().then((r) => {
@ -37,6 +134,9 @@ export function DashboardPage(): React.ReactElement {
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
})
window.electronAPI.llm.getActiveModel().then((r) => {
if (r.success) setLlmModel(r.data)
})
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
if (r.success) setHistory(r.data.entries)
})
@ -103,311 +203,256 @@ export function DashboardPage(): React.ReactElement {
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
], [t, ollamaConnected, premiumStatus])
const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm'))
const now = new Date()
const weekday = now.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()
const openSettings = useCallback(() => {
window.dispatchEvent(new Event('d3ro:open-settings'))
}, [])
const statTiles = [
{ ring: 'blue' as const, icon: <Mic size={20} />, label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
{ ring: 'purple' as const, icon: <BookOpen size={20} />, label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
{ ring: 'green' as const, icon: <CalendarDays size={20} />, label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
{ ring: 'orange' as const, icon: <Flame size={20} />, label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
]
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: formatHotkeyLabel(dictationBinding).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 sx={{ maxWidth: 980, mx: 'auto', p: 4, pt: 5, pb: 8 }}>
{/* ── 1. 히어로 카드 ─────────────────────────── */}
<MetalCard sx={{ p: 3.5 }}>
{/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse size={7} />
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontFamily: d3roFontSans }}>
{t('dashboard.sessionsToday')}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{now.toLocaleDateString()}
</PhosphorText>
<Pill>{weekday}</Pill>
<Pill tone={allOk ? 'green' : 'neutral'}>
<Led color={allOk ? 'green' : 'orange'} size={6} />
{allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
</Pill>
</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}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 3, alignItems: 'start' }}>
{/* 좌: 헤드라인 + 힌트 + 키 설정 */}
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="hero" sx={{ mb: 1 }}>
{t('dashboard.heroIdle')}
</PhosphorText>
<PhosphorText variant="body" sx={{ mb: 2 }}>
{dictationBinding
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
: t('dashboard.hotkeyNotSet')}
</PhosphorText>
<PhysicalButton onClick={openSettings} sx={{ height: 34, px: 1.5, gap: 0.75 }}>
<KeyRound size={13} />
{t('dashboard.keySetup')}
</PhysicalButton>
</Box>
{/* 중: 수치 2종 */}
<Box sx={{ display: 'flex', gap: 4, pt: 0.5 }}>
{[
{ label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
{ label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
].map((item) => (
<Box key={item.label} sx={{ textAlign: 'left' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.5, display: 'block' }}>
{item.label}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{card.unit}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '34px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
{item.value}
</Typography>
{item.unit && (
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
{item.unit}
</PhosphorText>
)}
</Box>
</Box>
))}
</Box>
{/* 우: CTA 버튼 스택 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 150 }}>
<Button
variant="contained"
color="primary"
startIcon={<BarChart3 size={15} />}
onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
sx={{ py: 1.25 }}
>
{t('dashboard.viewStats')}
</Button>
<PhysicalButton onClick={openSettings} sx={{ gap: 0.75 }}>
<Settings2 size={14} />
{t('dashboard.systemSettings')}
</PhysicalButton>
</Box>
</Box>
{/* 하단: 그라디언트 웨이브 */}
<Box sx={{ mt: 2.5, height: 56 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.3} barWidth={3} barGap={3} />
</Box>
</MetalCard>
{/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
<Box ref={statsRef} sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, mt: 2.5 }}>
{statTiles.map((tile) => (
<MetalCard key={tile.label} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color={tile.ring} size={52}>
{tile.icon}
</StatRing>
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}>
{tile.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '26px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
{tile.value}
</Typography>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
{tile.unit}
</PhosphorText>
</Box>
</Box>
</Box>
</MetalCard>
))}
</Box>
{/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2 }}>
{/* 현재 백엔드 카드 */}
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('dashboard.currentBackend').toUpperCase()}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led
color={premiumStatus?.backend === 'premium' ? 'green' : 'amber'}
size={8}
pulse={premiumStatus?.backend === 'premium'}
/>
<PhosphorText variant="value">
{premiumStatus?.backend === 'premium'
? t('dashboard.backendPremium').toUpperCase()
: t('dashboard.backendLocal').toUpperCase()}
{/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
{/* 백엔드 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader
title={t('dashboard.currentBackend')}
right={
<Pill tone="accent">
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
</Pill>
}
/>
<MetalCard inset sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<StatRing color="accent" size={46} thickness={2}>
<Cpu size={18} />
</StatRing>
<Box sx={{ minWidth: 0, flex: 1 }}>
<PhosphorText variant="heading" sx={{ mb: 0.5 }}>
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}: {llmModel ?? '—'}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
</PhosphorText>
<Led color={ollamaConnected ? 'green' : 'red'} size={7} />
</Box>
</Box>
</Box>
</Box>
</MetalCard>
</MetalCard>
{/* 티어 카드 */}
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('license.currentTier').toUpperCase()}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led
color={licenseTier === 'free' ? 'amber' : 'green'}
size={8}
pulse={licenseTier !== 'free'}
/>
<PhosphorText variant="value">
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
</PhosphorText>
{/* 요금제 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.currentTier')} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<StatRing color="blue" size={40} thickness={2}>
<Star size={15} />
</StatRing>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '30px', fontWeight: 800, color: d3roPalette.text.primary, lineHeight: 1 }}>
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
</Typography>
</Box>
{licenseTier === 'free' && <Pill>{t('dashboard.planFree')}</Pill>}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, pl: 2, borderLeft: `1px solid ${d3roPalette.glass.hairline}` }}>
{[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}><Check size={14} /></Box>
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.secondary }}>
{feat}
</PhosphorText>
</Box>
))}
</Box>
</Box>
</MetalCard>
</Box>
{/* 사용량 바 — 모든 티어에서 표시 */}
{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>
)}
{/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
{/* 사용량 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.usageToday')} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{usageQuotas.length === 0 && (
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>—</PhosphorText>
)}
{usageQuotas.map((q, idx) => (
<MetalCard inset key={q.feature} sx={{ p: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<StatRing color={idx % 2 === 0 ? 'blue' : 'purple'} size={32} thickness={2}>
{idx % 2 === 0 ? <Mic size={13} /> : <Cpu size={13} />}
</StatRing>
<PhosphorText variant="compact" sx={{ flex: 1 }}>
{t(`license.feature.${q.feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light }}>
{q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
</PhosphorText>
</Box>
))}
<GradientBar
ratio={q.limit === -1 ? 1 : Math.min(1, q.used / Math.max(1, q.limit))}
alt={idx % 2 === 1}
/>
</MetalCard>
))}
</Box>
</MetalCard>
{/* Premium 모델별 쿼터 표시 */}
{premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
<>
<PhosphorText variant="label" sx={{ mt: 1.5, mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
{t('license.premiumQuota').toUpperCase()}
</PhosphorText>
{PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
<Box key={m.model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">
{t(m.i18nKey)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
{m.limit === -1
? t('license.unlimited')
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
</PhosphorText>
</Box>
{m.limit > 0 && (
<Box sx={{
height: 3,
borderRadius: '2px',
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: '0%',
bgcolor: d3roPalette.tag.green,
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()}
{/* 실시간 자막 카드 */}
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column' }}>
<CardHeader
title={t('dashboard.caption')}
right={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led
color={captionState === 'active' ? 'red' : 'off'}
size={7}
pulse={captionState === 'active'}
/>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
</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'}
}
/>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2 }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.captionPlaceholder')}
</PhosphorText>
<Button
variant="contained"
color="primary"
startIcon={<Mic size={15} />}
onClick={async () => {
if (captionState === 'active') {
await window.electronAPI.caption.stop()
@ -415,46 +460,105 @@ export function DashboardPage(): React.ReactElement {
await window.electronAPI.caption.start()
}
}}
sx={{ minWidth: 100 }}
sx={{ alignSelf: 'center', px: 4 }}
>
{captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()}
</PhysicalButton>
{captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
</Button>
</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 }}>
{/* ── 5. 시스템 상태 ──────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('dashboard.systemStatus')} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 3, alignItems: 'center' }}>
{/* 좌: 서비스 상태 리스트 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 220 }}>
{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>
<Led color={svc.ok ? 'green' : 'red'} size={7} />
<PhosphorText variant="small" sx={{ flex: 1, color: d3roPalette.text.secondary }}>
{svc.name}
</PhosphorText>
<PhosphorText
variant="value"
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
variant="meta"
sx={{ color: svc.ok ? d3roPalette.tag.green : 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>
{/* 중: 웨이브 + 레벨 미터 */}
<Box sx={{ minWidth: 0 }}>
<Box sx={{ height: 64, mb: 1.25 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.35} barWidth={2.5} barGap={2.5} glow={6} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="meta" sx={{ whiteSpace: 'nowrap' }}>
{t('dashboard.inputLevel')}
</PhosphorText>
<Box sx={{ display: 'flex', gap: '3px', flex: 1 }}>
{Array.from({ length: 24 }, (_, i) => (
<Box
key={i}
sx={{
flex: 1,
height: 8,
borderRadius: '2px',
bgcolor: i < Math.round(audioLevel * 24) ? d3roPalette.tag.green : d3roPalette.bg.inset,
transition: 'background-color 0.1s linear',
}}
/>
))}
</Box>
<PhosphorText variant="meta" sx={{ color: d3roPalette.tag.green, minWidth: 34, textAlign: 'right' }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
</Box>
</Box>
{/* 우: 백엔드 요약 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 180 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('service.ollamaLlm')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaConnected ? t('service.ready') : t('service.offline')}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, fontFamily: d3roFontMono }}>
{llmModel ?? '—'}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.statusLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: allOk ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{allOk ? 'CONNECTED' : 'CHECK'}
</PhosphorText>
</Box>
</Box>
</Box>
</MetalCard>
{/* ── 6. 파일 전사 ────────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('fileTranscription.title')} />
<FileDropZone />
</Box>
</MetalCard>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
{/* ── 7. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
{t('dashboard.recentTranscriptions').toUpperCase()}
<PhosphorText variant="heading" sx={{ mb: 2, display: 'block' }}>
{t('dashboard.recentTranscriptions')}
</PhosphorText>
{history.length === 0 ? (
@ -464,10 +568,10 @@ export function DashboardPage(): React.ReactElement {
<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' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('dashboard.entries', { count: group.entries.length })}
</PhosphorText>