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:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
|
|
@ -1,14 +1,20 @@
|
|||
// src/renderer/pages/CommandsPage.tsx
|
||||
// 커스텀 LLM 명령어 관리: 클릭하면 활성 명령어로 설정 → 다음 녹음 시 적용
|
||||
// 커스텀 LLM 명령어 관리 + Phase 10: 음성 키워드 + LLM 체인
|
||||
// 3 섹션: 명령어 목록 / 음성 키워드 / LLM 체인
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Chip, Select, MenuItem, FormControl, InputLabel } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
||||
import LinkIcon from '@mui/icons-material/Link'
|
||||
import { MetalCard, PhosphorText, Led, ScreenPanel } from '../components/ds'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { VoiceCommandRule, VoiceCommandKeyword, KeywordMatchMode, LLMChain, ChainStep } from '@shared/types'
|
||||
|
||||
interface CustomInstruction {
|
||||
id: string
|
||||
|
|
@ -21,6 +27,7 @@ interface CustomInstruction {
|
|||
}
|
||||
|
||||
export function CommandsPage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [instructions, setInstructions] = useState<CustomInstruction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
|
@ -58,9 +65,8 @@ export function CommandsPage(): React.ReactElement {
|
|||
}, [loadData])
|
||||
|
||||
const handleActivate = (id: string) => {
|
||||
const newId = activeId === id ? null : id // 토글: 같은 거 누르면 해제
|
||||
const newId = activeId === id ? null : id
|
||||
setActiveId(newId)
|
||||
// ConfigService에 저장 + defaultLLMAction을 'custom'으로 변경
|
||||
if (newId) {
|
||||
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: newId as never })
|
||||
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' })
|
||||
|
|
@ -74,7 +80,7 @@ export function CommandsPage(): React.ReactElement {
|
|||
setEditId(null)
|
||||
setFormName('')
|
||||
setFormDesc('')
|
||||
setFormPrompt('{{text}}를 다듬어주세요.')
|
||||
setFormPrompt(t('commands.defaultPrompt'))
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -119,103 +125,453 @@ export function CommandsPage(): React.ReactElement {
|
|||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<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>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title={t('commands.title').toUpperCase()}
|
||||
count={t('commands.count', { count: instructions.length }).toUpperCase()}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
{t('commands.add').toUpperCase()}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 활성 명령어 표시 */}
|
||||
<Box sx={{ mb: 3, p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '1px', mb: 0.5 }}>
|
||||
ACTIVE COMMAND
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '13px', color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled }}>
|
||||
{activeInstruction ? `● ${activeInstruction.name}` : '없음 — 명령어를 클릭하여 활성화'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||
) : instructions.length === 0 ? (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">NO COMMANDS — CLICK ADD TO CREATE</PhosphorText>
|
||||
{/* 활성 명령어 — ScreenPanel로 극적 표시 */}
|
||||
<ScreenPanel height={72}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%' }}>
|
||||
<Led color={activeInstruction ? 'amber' : 'off'} size={8} pulse={!!activeInstruction} />
|
||||
<Box>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('commands.activeCommand').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{
|
||||
fontSize: d3roTypo.heading.size,
|
||||
color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled,
|
||||
}}
|
||||
>
|
||||
{activeInstruction ? activeInstruction.name : t('commands.none')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{instructions.map((inst) => {
|
||||
const isActive = inst.id === activeId
|
||||
return (
|
||||
<Box
|
||||
key={inst.id}
|
||||
onClick={() => handleActivate(inst.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '22px',
|
||||
border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<MetalCard>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
{isActive ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
|
||||
) : (
|
||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||
)}
|
||||
<Box>
|
||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary }}>
|
||||
{inst.name}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
|
||||
{inst.description}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 명령어 목록 */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{loading ? (
|
||||
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
|
||||
) : instructions.length === 0 ? (
|
||||
<EmptyStateCard message={t('commands.noCommands').toUpperCase()} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{instructions.map((inst) => {
|
||||
const isActive = inst.id === activeId
|
||||
return (
|
||||
<Box
|
||||
key={inst.id}
|
||||
onClick={() => handleActivate(inst.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '22px',
|
||||
border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<MetalCard>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
{isActive ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
|
||||
) : (
|
||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||
)}
|
||||
<Box>
|
||||
<Box sx={{
|
||||
fontSize: d3roTypo.body.size,
|
||||
fontWeight: d3roTypo.heading.weight,
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary,
|
||||
}}>
|
||||
{inst.name}
|
||||
</Box>
|
||||
<Box sx={{
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.text.inactive,
|
||||
mt: 0.25,
|
||||
}}>
|
||||
{inst.description}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEdit(inst)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
{!inst.isBuiltin && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(inst.id)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
onClick={() => openEdit(inst)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
{!inst.isBuiltin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(inst.id)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── 음성 키워드 섹션 ── */}
|
||||
<VoiceKeywordsSection instructions={instructions} />
|
||||
|
||||
{/* ── LLM 체인 섹션 ── */}
|
||||
<ChainSection instructions={instructions} />
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>
|
||||
{editId ? '명령어 편집' : '명령어 추가'}
|
||||
{editId ? t('commands.editTitle') : t('commands.addTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="이름" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label="설명" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
<TextField label="프롬프트 템플릿" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="{{text}}는 전사된 텍스트로 치환됩니다" />
|
||||
<TextField label={t('commands.name')} value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label={t('commands.description')} value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
<TextField label={t('commands.promptTemplate')} value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText={t('commands.promptHelp')} />
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>취소</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>저장</Button>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
음성 키워드 섹션: 명령어별 키워드 편집
|
||||
──────────────────────────────────────────────────────────── */
|
||||
|
||||
function VoiceKeywordsSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [rules, setRules] = useState<VoiceCommandRule[]>([])
|
||||
const [editingRule, setEditingRule] = useState<string | null>(null)
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
const result = await window.electronAPI.voiceCommand.getAll()
|
||||
if (result.success) setRules(result.data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadRules() }, [loadRules])
|
||||
|
||||
const handleAddKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[]) => {
|
||||
const trimmed = keywordInput.trim()
|
||||
if (!trimmed) return
|
||||
const updated: VoiceCommandKeyword[] = [...existing, { keyword: trimmed, matchMode: 'prefix' as KeywordMatchMode }]
|
||||
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
|
||||
setKeywordInput('')
|
||||
loadRules()
|
||||
}, [keywordInput, loadRules])
|
||||
|
||||
const handleRemoveKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[], idx: number) => {
|
||||
const updated = existing.filter((_, i) => i !== idx)
|
||||
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
|
||||
loadRules()
|
||||
}, [loadRules])
|
||||
|
||||
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('voiceCommand.title').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
</Box>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size }}>
|
||||
{t('voiceCommand.noKeywords').toUpperCase()}
|
||||
</PhosphorText>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{rules.map(rule => (
|
||||
<MetalCard key={rule.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Led color={rule.enabled ? 'green' : 'off'} size={6} />
|
||||
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight, minWidth: 100 }}>
|
||||
{getInstructionName(rule.instructionId)}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, flex: 1 }}>
|
||||
{rule.keywords.map((kw, idx) => (
|
||||
<Chip
|
||||
key={`${kw.keyword}-${idx}`}
|
||||
label={kw.keyword}
|
||||
size="small"
|
||||
onDelete={() => handleRemoveKeyword(rule.instructionId, rule.keywords, idx)}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
bgcolor: d3roPalette.tag.orangeBg,
|
||||
color: d3roPalette.tag.orange,
|
||||
borderRadius: d3roRadius.small,
|
||||
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.orange, fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{editingRule === rule.instructionId ? (
|
||||
<Box
|
||||
component="input"
|
||||
value={keywordInput}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) }
|
||||
if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') }
|
||||
}}
|
||||
onBlur={() => { if (!keywordInput.trim()) setEditingRule(null) }}
|
||||
autoFocus
|
||||
placeholder={t('voiceCommand.keywordPlaceholder')}
|
||||
sx={{
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.input,
|
||||
color: d3roPalette.text.primary,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
px: 1, py: 0.25,
|
||||
borderRadius: d3roRadius.xs,
|
||||
outline: 'none', width: 120,
|
||||
'&:focus': { borderColor: d3roPalette.accent.amber },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setEditingRule(rule.instructionId); setKeywordInput('') }}
|
||||
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
LLM 체인 섹션: 멀티 명령어 파이프라인 관리
|
||||
──────────────────────────────────────────────────────────── */
|
||||
|
||||
function ChainSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [chains, setChains] = useState<LLMChain[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editChain, setEditChain] = useState<LLMChain | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formSteps, setFormSteps] = useState<ChainStep[]>([])
|
||||
|
||||
const loadChains = useCallback(async () => {
|
||||
const result = await window.electronAPI.chain.getAll()
|
||||
if (result.success) setChains(result.data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadChains() }, [loadChains])
|
||||
|
||||
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
|
||||
|
||||
const openAdd = () => {
|
||||
setEditChain(null)
|
||||
setFormName('')
|
||||
setFormSteps([{ instructionId: instructions[0]?.id ?? '', inputSource: 'original' }])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (chain: LLMChain) => {
|
||||
setEditChain(chain)
|
||||
setFormName(chain.name)
|
||||
setFormSteps([...chain.steps])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim() || formSteps.length === 0) return
|
||||
const validSteps = formSteps.filter(s => s.instructionId)
|
||||
if (validSteps.length === 0) return
|
||||
|
||||
if (editChain) {
|
||||
await window.electronAPI.chain.update({ id: editChain.id, name: formName.trim(), steps: validSteps })
|
||||
} else {
|
||||
await window.electronAPI.chain.create({ name: formName.trim(), steps: validSteps })
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadChains()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await window.electronAPI.chain.delete({ id })
|
||||
loadChains()
|
||||
}
|
||||
|
||||
const handleExecute = async (chainId: string) => {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
await window.electronAPI.chain.execute({ chainId, inputText: text })
|
||||
}
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
setFormSteps(prev => [...prev, {
|
||||
instructionId: instructions[0]?.id ?? '',
|
||||
inputSource: prev.length > 0 ? 'previous' : 'original',
|
||||
}])
|
||||
}
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
setFormSteps(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const updateStep = (idx: number, field: keyof ChainStep, value: string) => {
|
||||
setFormSteps(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('chain.title').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
<Button variant="outlined" startIcon={<LinkIcon />} onClick={openAdd} size="small" sx={{ fontSize: d3roTypo.micro.size }}>
|
||||
{t('chain.add').toUpperCase()}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{chains.length === 0 ? (
|
||||
<EmptyStateCard message={t('chain.noChains').toUpperCase()} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{chains.map(chain => (
|
||||
<MetalCard key={chain.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>
|
||||
{chain.name}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||
{chain.steps.map((step, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{idx > 0 && (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size }}>→</PhosphorText>
|
||||
)}
|
||||
<Chip
|
||||
label={getInstructionName(step.instructionId)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
bgcolor: d3roPalette.tag.greenBg,
|
||||
color: d3roPalette.tag.green,
|
||||
borderRadius: d3roRadius.xs,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => handleExecute(chain.id)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.green } }}>
|
||||
<PlayArrowIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => openEdit(chain)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => handleDelete(chain.id)}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 체인 편집 다이얼로그 */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>
|
||||
{editChain ? t('chain.editTitle') : t('chain.addTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label={t('chain.name')}
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
fullWidth autoFocus sx={{ mt: 1 }}
|
||||
/>
|
||||
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mt: 3, mb: 1, display: 'block' }}>
|
||||
{t('chain.steps').toUpperCase()}
|
||||
</PhosphorText>
|
||||
|
||||
{formSteps.map((step, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, minWidth: 24 }}>
|
||||
{idx + 1}.
|
||||
</PhosphorText>
|
||||
<FormControl size="small" sx={{ flex: 1 }}>
|
||||
<InputLabel>{t('chain.selectInstruction')}</InputLabel>
|
||||
<Select
|
||||
value={step.instructionId}
|
||||
label={t('chain.selectInstruction')}
|
||||
onChange={e => updateStep(idx, 'instructionId', e.target.value)}
|
||||
>
|
||||
{instructions.map(inst => (
|
||||
<MenuItem key={inst.id} value={inst.id}>{inst.name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{idx > 0 && (
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>{t('chain.inputSource')}</InputLabel>
|
||||
<Select
|
||||
value={step.inputSource}
|
||||
label={t('chain.inputSource')}
|
||||
onChange={e => updateStep(idx, 'inputSource', e.target.value)}
|
||||
>
|
||||
<MenuItem value="original">{t('chain.inputSource.original')}</MenuItem>
|
||||
<MenuItem value="previous">{t('chain.inputSource.previous')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => removeStep(idx)} disabled={formSteps.length <= 1}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button onClick={addStep} startIcon={<AddIcon />} size="small" sx={{ mt: 1 }}>
|
||||
{t('chain.addStep')}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formSteps.length === 0}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
// src/renderer/pages/DictionaryPage.tsx
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText + 타이포 토큰
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material'
|
||||
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import SearchIcon from '@mui/icons-material/Search'
|
||||
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import { MetalCard, PhosphorText } from '../components/ds'
|
||||
import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function DictionaryPage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [data, setData] = useState<DictPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -67,32 +69,26 @@ 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: d3roPalette.text.inactive }}>
|
||||
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
||||
</PhosphorText>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
ADD
|
||||
</Button>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title={t('dictionary.title').toUpperCase()}
|
||||
count={t('dictionary.words', { count: data?.total ?? 0 }).toUpperCase()}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
|
||||
{t('dictionary.add').toUpperCase()}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
placeholder="SEARCH..."
|
||||
<SearchInput
|
||||
placeholder={t('dictionary.search').toUpperCase()}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
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> } }}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{search ? 'NO RESULTS' : 'NO WORDS — ADD CUSTOM WORDS FOR BETTER STT'}</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<EmptyStateCard message={search ? t('dictionary.noResults').toUpperCase() : t('dictionary.noWords').toUpperCase()} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{data.entries.map((entry: DictionaryEntry) => (
|
||||
|
|
@ -100,13 +96,19 @@ 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: d3roPalette.text.primary }}>{entry.word}</Box>
|
||||
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>{entry.word}</Box>
|
||||
{entry.pronunciation && (
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5, letterSpacing: '0.5px' }}>
|
||||
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
||||
<Box sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.label.size,
|
||||
color: d3roPalette.text.inactive,
|
||||
mt: 0.5,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
}}>
|
||||
{entry.category.toUpperCase()} · {t('dictionary.used', { count: entry.usageCount })}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
|
|
@ -126,14 +128,14 @@ export function DictionaryPage(): React.ReactElement {
|
|||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? '단어 편집' : '단어 추가'}</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? t('dictionary.editTitle') : t('dictionary.addTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="단어" value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label="발음 (선택)" value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
<TextField label={t('dictionary.word')} value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
|
||||
<TextField label={t('dictionary.pronunciation')} value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>취소</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>저장</Button>
|
||||
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>{t('common.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,109 +1,167 @@
|
|||
// src/renderer/pages/HistoryPage.tsx
|
||||
// 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑
|
||||
// Phase 10: 태그 필터링 + 태그 관리 통합
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Box, TextField, IconButton, InputAdornment } from '@mui/material'
|
||||
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'
|
||||
import { Box, Chip, IconButton, Tooltip } from '@mui/material'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import { PhosphorText } from '../components/ds'
|
||||
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import { getDateKey } from '../utils/formatters'
|
||||
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
|
||||
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@shared/types'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
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 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 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()
|
||||
}
|
||||
|
||||
export function HistoryPage(): React.ReactElement {
|
||||
const { t, formatRelativeDate } = useI18n()
|
||||
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [allTags, setAllTags] = useState<TagCount[]>([])
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
const result = await window.electronAPI.memo.getAllTags()
|
||||
if (result.success) setAllTags(result.data)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = search.trim()
|
||||
? await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
: await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
|
||||
let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown }
|
||||
|
||||
if (activeTag) {
|
||||
result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE })
|
||||
} else if (search.trim()) {
|
||||
result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||
} else {
|
||||
result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
|
||||
}
|
||||
if (result.success) setData(result.data)
|
||||
setLoading(false)
|
||||
}, [search])
|
||||
}, [search, activeTag])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const unsub = window.electronAPI.app.onDataChanged(() => { loadData() })
|
||||
loadTags()
|
||||
const unsub = window.electronAPI.app.onDataChanged(() => { loadData(); loadTags() })
|
||||
return unsub
|
||||
}, [loadData])
|
||||
}, [loadData, loadTags])
|
||||
|
||||
// 날짜별 그룹핑
|
||||
const groupedEntries = useMemo(() => {
|
||||
if (!data) return []
|
||||
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
|
||||
for (const entry of data.entries) {
|
||||
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)
|
||||
}, [data])
|
||||
}, [data, formatRelativeDate])
|
||||
|
||||
const handleCopy = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
window.electronAPI.history.delete({ id })
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleTagClick = useCallback((tag: string) => {
|
||||
setActiveTag(prev => prev === tag ? null : tag)
|
||||
setSearch('')
|
||||
}, [])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
await window.electronAPI.memo.export({
|
||||
format: 'markdown' as const,
|
||||
tag: activeTag ?? undefined,
|
||||
})
|
||||
}, [activeTag])
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
||||
</PhosphorText>
|
||||
|
||||
<TextField
|
||||
placeholder="SEARCH..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
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>,
|
||||
},
|
||||
}}
|
||||
{/* 페이지 헤더 -- 각인 스타일 */}
|
||||
<PageHeader
|
||||
title={t('history.title').toUpperCase()}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={t('memo.export')} arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleExport}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('history.entries', { count: data?.total ?? 0 }).toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 태그 필터 바 */}
|
||||
{allTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||
{allTags.map(tc => (
|
||||
<Chip
|
||||
key={tc.tag}
|
||||
label={`#${tc.tag} (${tc.count})`}
|
||||
size="small"
|
||||
variant={activeTag === tc.tag ? 'filled' : 'outlined'}
|
||||
onClick={() => handleTagClick(tc.tag)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
borderRadius: d3roRadius.small,
|
||||
borderColor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.border.subtle,
|
||||
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : 'transparent',
|
||||
color: activeTag === tc.tag ? d3roPalette.bg.card : d3roPalette.text.secondary,
|
||||
'&:hover': {
|
||||
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.bg.cardHover,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{activeTag && (
|
||||
<Chip
|
||||
label={t('memo.clearFilter')}
|
||||
size="small"
|
||||
onClick={() => setActiveTag(null)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.micro.size,
|
||||
borderRadius: d3roRadius.small,
|
||||
color: d3roPalette.text.muted,
|
||||
'&:hover': { color: d3roPalette.tag.red },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!activeTag && (
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('history.search').toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<PhosphorText variant="dim">LOADING...</PhosphorText>
|
||||
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
|
||||
) : !data || data.entries.length === 0 ? (
|
||||
<MetalCard>
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{search ? 'NO RESULTS' : 'NO HISTORY — START RECORDING'}</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<EmptyStateCard
|
||||
message={search ? t('history.noResults').toUpperCase() : t('history.noHistory').toUpperCase()}
|
||||
/>
|
||||
) : (
|
||||
groupedEntries.map((group) => (
|
||||
<Box key={group.label} sx={{ mb: 3 }}>
|
||||
|
|
@ -113,44 +171,22 @@ export function HistoryPage(): React.ReactElement {
|
|||
{group.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
|
||||
{group.entries.length}건
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
|
||||
{t('history.count', { count: group.entries.length })}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{/* 항목 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{group.entries.map((entry: HistoryEntry) => (
|
||||
<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>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||
<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>
|
||||
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
||||
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
<HistoryEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
showTags
|
||||
onTagClick={handleTagClick}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue