Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성
- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등) - DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여) - HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달 - SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경 - DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑 - recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버) - 설계 문서: phase-8.md, speakly-settings-ui.md
This commit is contained in:
parent
f41fc277e3
commit
28371a9d1a
19 changed files with 1879 additions and 403 deletions
|
|
@ -7,6 +7,7 @@ import AddIcon from '@mui/icons-material/Add'
|
|||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { IPCResult } from '@shared/errors'
|
||||
|
||||
interface CustomInstruction {
|
||||
|
|
@ -42,7 +43,7 @@ export function CommandsPage(): React.ReactElement {
|
|||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
||||
</PhosphorText>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button>
|
||||
|
|
@ -64,16 +65,16 @@ export function CommandsPage(): React.ReactElement {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||
<Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{inst.name}</Box>
|
||||
<Box sx={{ fontSize: '11px', color: '#77797c', mt: 0.25 }}>{inst.description}</Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{inst.name}</Box>
|
||||
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>{inst.description}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
|
||||
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
{!inst.isBuiltin && (
|
||||
<IconButton size="small" sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
||||
<IconButton size="small" sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,182 +1,354 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
// 시안 A 인스트루먼트 패널: CRT 디스플레이 + LED 클러스터 + 물리 버튼 + 통계
|
||||
// 기능 중심 대시보드: 통계 카드 + 서비스 상태 + 최근 히스토리 (정밀기기 비주얼)
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText } from '../components/ds'
|
||||
import type { StatsSummary } from '@shared/types'
|
||||
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 formatTime(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000)
|
||||
const h = Math.floor(totalSec / 3600)
|
||||
const m = Math.floor((totalSec % 3600) / 60)
|
||||
const s = totalSec % 60
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
// ── 유틸 ──────────────────────────────────────────────
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
type DisplayMode = 'stats' | 'voice' | 'sys'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('stats')
|
||||
const [glitchTrigger, setGlitchTrigger] = useState(0)
|
||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||
|
||||
const loadStats = useCallback(() => {
|
||||
window.electronAPI.stats.getSummary().then((result) => {
|
||||
if (result.success) setStats(result.data)
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.stats.getSummary().then((r) => {
|
||||
if (r.success) setStats(r.data)
|
||||
})
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setOllamaConnected(result.data.connectionState === 'connected')
|
||||
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)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
const interval = setInterval(loadStats, 30000)
|
||||
loadData()
|
||||
const interval = setInterval(loadData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadStats])
|
||||
}, [loadData])
|
||||
|
||||
const switchMode = (mode: DisplayMode) => {
|
||||
setDisplayMode(mode)
|
||||
setGlitchTrigger((prev) => prev + 1)
|
||||
}
|
||||
// 날짜별 그룹핑
|
||||
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: getDateLabel(entry.createdAt), entries: [] }
|
||||
}
|
||||
groups[key].entries.push(entry)
|
||||
}
|
||||
return Object.values(groups)
|
||||
}, [history])
|
||||
|
||||
// 서비스 상태 목록
|
||||
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 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100%', p: 3 }}>
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
|
||||
{/* ── 1. HERO 영역 ──────────────────────────── */}
|
||||
<InstrumentPanel
|
||||
engravingLeft="D3RO-VOICE SYS."
|
||||
engravingRight="MOD-01 / TERMINAL"
|
||||
engravingLeft="D3RO-VOICE"
|
||||
engravingRight="v1.0.0"
|
||||
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
||||
>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: 3, minHeight: 320 }}>
|
||||
{/* ── 좌측: CRT 디스플레이 ─────────────────── */}
|
||||
<CrtDisplay
|
||||
amplitude={displayMode === 'voice' ? 0.4 : 0.1}
|
||||
frequency={displayMode === 'voice' ? 15 : 8}
|
||||
glitchTrigger={glitchTrigger}
|
||||
height={320}
|
||||
>
|
||||
{displayMode === 'stats' && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<PhosphorText variant="label">TOTAL SESSIONS</PhosphorText>
|
||||
<PhosphorText variant="label">D3RO</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ mt: 'auto', mb: 3 }}>
|
||||
<PhosphorText variant="hero">
|
||||
{stats?.totalSessionCount ?? 0}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ mt: 1 }}>
|
||||
{formatTime(stats?.totalRecordingTimeMs ?? 0)} RECORDED
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">WORDS</PhosphorText>
|
||||
<PhosphorText variant="value">{stats?.totalWordCount ?? 0}</PhosphorText>
|
||||
</Box>
|
||||
<Box>
|
||||
<PhosphorText variant="label">TODAY</PhosphorText>
|
||||
<PhosphorText variant="value">{stats?.todaySessionCount ?? 0}</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<PhosphorText variant="label">STREAK</PhosphorText>
|
||||
<PhosphorText variant="value">
|
||||
{stats?.streakDays ?? 0}<PhosphorText variant="dim" component="span" sx={{ ml: 0.5 }}>D</PhosphorText>
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMode === 'voice' && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<PhosphorText variant="label">VOICE MODE</PhosphorText>
|
||||
<PhosphorText variant="label">STANDBY</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ mt: 'auto', mb: 3, textAlign: 'center' }}>
|
||||
<PhosphorText variant="hero">IDLE</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ mt: 1 }}>
|
||||
PRESS RIGHT ALT TO DICTATE
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">MODE</PhosphorText>
|
||||
<PhosphorText variant="value">DICT</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<PhosphorText variant="label">HOTKEY</PhosphorText>
|
||||
<PhosphorText variant="value">R.ALT</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMode === 'sys' && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<PhosphorText variant="label">SYSTEM STATUS</PhosphorText>
|
||||
<PhosphorText variant="label">DIAG</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{[
|
||||
{ name: 'STT ENGINE', status: 'READY' as const },
|
||||
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' as const : 'OFFLINE' as const },
|
||||
{ name: 'HOTKEY HOOK', status: 'ACTIVE' as const },
|
||||
{ name: 'AUDIO INPUT', status: 'STANDBY' as const },
|
||||
].map((item) => (
|
||||
<Box key={item.name} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<PhosphorText variant="label">{item.name}</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
color: item.status === 'OFFLINE' ? '#ef4444' : '#f25b29',
|
||||
}}
|
||||
>
|
||||
{item.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<PhosphorText variant="label">VERSION</PhosphorText>
|
||||
<PhosphorText variant="value" sx={{ fontSize: '12px' }}>v1.0.0</PhosphorText>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CrtDisplay>
|
||||
|
||||
{/* ── 우측: 컨트롤 패널 ────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
{/* LED 상태 클러스터 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
|
||||
<Led color="green" pulse />
|
||||
<Led color={ollamaConnected ? 'green' : 'red'} />
|
||||
<Led color="amber" pulse />
|
||||
<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만으로
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 모드 버튼 그룹 */}
|
||||
<MetalCard inset>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<PhysicalButton selected={displayMode === 'stats'} onClick={() => switchMode('stats')} fullWidth>
|
||||
STAT
|
||||
</PhysicalButton>
|
||||
<PhysicalButton selected={displayMode === 'voice'} onClick={() => switchMode('voice')} fullWidth>
|
||||
VOICE
|
||||
</PhysicalButton>
|
||||
<PhysicalButton selected={displayMode === 'sys'} onClick={() => switchMode('sys')} fullWidth>
|
||||
SYS
|
||||
</PhysicalButton>
|
||||
{/* 핫키 표시 */}
|
||||
<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>
|
||||
))}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
) : (
|
||||
<PhosphorText variant="dim">핫키 미설정</PhosphorText>
|
||||
)}
|
||||
<PhosphorText variant="dim">
|
||||
키를 누른 상태에서 받아쓰기. 더블클릭하면 Agent 모드.
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</InstrumentPanel>
|
||||
|
||||
{/* ── 2. 통계 카드 ──────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
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="일"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<CrtDisplay amplitude={0.05} frequency={6} height={120}>
|
||||
<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: '11px',
|
||||
color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red,
|
||||
}}
|
||||
>
|
||||
{svc.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CrtDisplay>
|
||||
</Box>
|
||||
|
||||
{/* ── 4. 최근 히스토리 ──────────────────────── */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
RECENT TRANSCRIPTIONS
|
||||
</PhosphorText>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">
|
||||
히스토리 없음 — 핫키를 눌러 녹음을 시작하세요
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
) : (
|
||||
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: '10px' }}>
|
||||
더 보기
|
||||
</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>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import AddIcon from '@mui/icons-material/Add'
|
|||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||
|
||||
export function DictionaryPage(): React.ReactElement {
|
||||
const [data, setData] = useState<DictPageData | null>(null)
|
||||
|
|
@ -40,7 +40,7 @@ export function DictionaryPage(): React.ReactElement {
|
|||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
||||
</PhosphorText>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small">
|
||||
|
|
@ -53,8 +53,8 @@ export function DictionaryPage(): React.ReactElement {
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' } }}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment> } }}
|
||||
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' } }}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment> } }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -72,17 +72,17 @@ export function DictionaryPage(): React.ReactElement {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{entry.word}</Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{entry.word}</Box>
|
||||
{entry.pronunciation && (
|
||||
<Box sx={{ fontFamily: MONO, fontSize: '11px', color: '#5c2615' }}>[{entry.pronunciation}]</Box>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ fontFamily: MONO, fontSize: '10px', color: '#77797c', mt: 0.5, letterSpacing: '0.5px' }}>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5, letterSpacing: '0.5px' }}>
|
||||
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
|
||||
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import SearchIcon from '@mui/icons-material/Search'
|
|||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
|
@ -40,7 +40,7 @@ export function HistoryPage(): React.ReactElement {
|
|||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: '#77797c' }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
||||
</PhosphorText>
|
||||
|
||||
|
|
@ -51,11 +51,11 @@ export function HistoryPage(): React.ReactElement {
|
|||
fullWidth
|
||||
sx={{
|
||||
mb: 3,
|
||||
'& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' },
|
||||
'& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' },
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment>,
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment>,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
@ -76,13 +76,13 @@ export function HistoryPage(): React.ReactElement {
|
|||
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{
|
||||
fontSize: '13px', color: '#fff', lineHeight: 1.5,
|
||||
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: MONO, fontSize: '10px', color: '#5c2615', letterSpacing: '0.5px' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
|
||||
<span>{formatDate(entry.createdAt)}</span>
|
||||
<span>{formatDuration(entry.duration)}</span>
|
||||
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
||||
|
|
@ -91,11 +91,11 @@ export function HistoryPage(): React.ReactElement {
|
|||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<IconButton size="small" onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
|
||||
sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
||||
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue