Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템

Phase 10 킬러 피처:
- MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB)
- VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종
- ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트
- ChainService: LLM 명령어 순차 실행 파이프라인
- CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백

VoiceModeService 파이프라인 통합:
- 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입

시스템 오디오 캡처:
- setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지)
- electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현

Phase 11 수익화:
- LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API
- Feature Gate: requireFeature/checkFeature/consumeFeature
- 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage)
- LicenseModal, ProBadge, UpgradePromptModal UI

디자인 보강:
- d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템
- ScreenPanel, ButtonGroup DS 컴포넌트 신규
- PhosphorText 4→13종 변형, MetalDial conic-gradient 광택
- 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard

기타:
- 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings)
- StatusBar 자막 LED + 효과음, 자막 로딩 UI
- LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged)
- 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View file

@ -1,103 +1,30 @@
// src/renderer/pages/DashboardPage.tsx
// 기능 중심 대시보드: 통계 카드 + 서비스 상태 + 최근 히스토리 (정밀기기 비주얼)
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Box, Typography, IconButton, Tooltip } from '@mui/material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme'
import type { StatsSummary, HistoryEntry, HistoryPage as HistoryPageData, HotkeyBinding } from '@shared/types'
// ── 유틸 ──────────────────────────────────────────────
function formatRecordingTime(ms: number): string {
const totalMin = Math.round(ms / 60000)
if (totalMin >= 60) {
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
return `${h}:${m.toString().padStart(2, '0')}`
}
return `${totalMin}`
}
function formatRecordingTimeUnit(ms: number): string {
const totalMin = Math.round(ms / 60000)
return totalMin >= 60 ? '시간' : '분'
}
function formatNumber(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
return `${n}`
}
function formatDuration(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.round(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
}
function getDateLabel(ts: number): string {
const d = new Date(ts)
d.setHours(0, 0, 0, 0)
const today = new Date()
today.setHours(0, 0, 0, 0)
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (d.getTime() === today.getTime()) return 'TODAY'
if (d.getTime() === yesterday.getTime()) return 'YESTERDAY'
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase()
}
function getDateKey(ts: number): string {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// ── 통계 카드 ─────────────────────────────────────────
function StatCard({
label,
value,
unit,
}: {
label: string
value: string
unit?: string
}): React.ReactElement {
return (
<MetalCard>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
<PhosphorText variant="hero" sx={{ fontSize: '32px' }}>
{value}
</PhosphorText>
{unit && (
<PhosphorText variant="dim" sx={{ fontSize: '12px' }}>
{unit}
</PhosphorText>
)}
</Box>
</Box>
</MetalCard>
)
}
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 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) => {
@ -112,14 +39,36 @@ export function DashboardPage(): React.ReactElement {
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() })
return () => { clearInterval(interval); unsub() }
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])
// 날짜별 그룹핑
@ -128,141 +77,269 @@ export function DashboardPage(): React.ReactElement {
for (const entry of history) {
const key = getDateKey(entry.createdAt)
if (!groups[key]) {
groups[key] = { label: getDateLabel(entry.createdAt), entries: [] }
groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] }
}
groups[key].entries.push(entry)
}
return Object.values(groups)
}, [history])
}, [history, formatRelativeDate])
// 서비스 상태 목록
const services = [
{ name: 'STT ENGINE', status: 'READY', ok: true },
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' : 'OFFLINE', ok: ollamaConnected },
{ name: 'HOTKEY HOOK', status: 'ACTIVE', ok: true },
{ name: 'AUDIO INPUT', status: 'STANDBY', ok: true },
]
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. HERO 영역 ──────────────────────────── */}
<Box sx={{ position: 'relative', zIndex: 1 }}>
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
<InstrumentPanel
engravingLeft="D3RO-VOICE"
engravingRight="v1.0.0"
engravingBottom="LOCAL AI VOICE ASSISTANT"
>
<Box sx={{ py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse size={8} />
<PhosphorText variant="value" sx={{ fontSize: '16px' }}>
, D3RO-VOICE만으로
<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>
</Box>
{/* 핫키 표시 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{dictationBinding ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.5,
borderRadius: '8px',
border: `1px solid ${d3roPalette.border.default}`,
bgcolor: d3roPalette.bg.inset,
}}
>
{dictationBinding.displayLabel.split(' + ').map((key) => (
<Typography
key={key}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
color: d3roPalette.text.primary,
px: 0.75,
py: 0.25,
borderRadius: '4px',
bgcolor: d3roPalette.bg.chassis,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
{key}
</Typography>
))}
{/* 중앙: 큰 수치 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>
) : (
<PhosphorText variant="dim"> </PhosphorText>
)}
<PhosphorText variant="dim">
. Agent .
</PhosphorText>
</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>
</Box>
{/* ── 2. 통계 카드 ──────────────────────────── */}
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
<Box
sx={{
position: 'relative',
zIndex: 2,
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
mt: 3,
}}
>
<StatCard
label="RECORDING"
value={formatRecordingTime(stats?.totalRecordingTimeMs ?? 0)}
unit={formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0)}
/>
<StatCard
label="WORDS"
value={formatNumber(stats?.totalWordCount ?? 0)}
unit="단어"
/>
<StatCard
label="TODAY"
value={`${stats?.todaySessionCount ?? 0}`}
unit="세션"
/>
<StatCard
label="STREAK"
value={`${stats?.streakDays ?? 0}`}
unit="일"
/>
{[
{ 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>
{/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */}
<Box sx={{ mt: 3, position: 'relative', zIndex: 3 }}>
<CrtDisplay amplitude={0.05} frequency={6} height={120}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 1,
px: 1,
}}
>
{/* ── 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="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
<PhosphorText
variant="value"
sx={{
fontSize: '11px',
color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red,
}}
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
>
{svc.status}
</PhosphorText>
@ -273,19 +350,13 @@ export function DashboardPage(): React.ReactElement {
</Box>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4, position: 'relative', zIndex: 4 }}>
<Box sx={{ mt: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
RECENT TRANSCRIPTIONS
{t('dashboard.recentTranscriptions').toUpperCase()}
</PhosphorText>
{history.length === 0 ? (
<MetalCard>
<Box sx={{ py: 4, textAlign: 'center' }}>
<PhosphorText variant="dim">
</PhosphorText>
</Box>
</MetalCard>
<EmptyStateCard message={t('dashboard.noHistory')} />
) : (
groupedHistory.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
@ -295,60 +366,19 @@ export function DashboardPage(): React.ReactElement {
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
<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) => (
<MetalCard key={entry.id}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
fontSize: '13px',
color: d3roPalette.text.primary,
lineHeight: 1.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{entry.polishedText || entry.originalText}
</Box>
<Box
sx={{
display: 'flex',
gap: 2,
mt: 1,
fontFamily: d3roFontMono,
fontSize: '10px',
color: d3roPalette.text.dimLabel,
letterSpacing: '0.5px',
}}
>
<span>{formatTime(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
</Box>
</Box>
<Tooltip title="복사" arrow>
<IconButton
size="small"
onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<ContentCopyIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</MetalCard>
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={(text) => navigator.clipboard.writeText(text)}
/>
))}
</Box>
</Box>