Phase 7~8 구현: 테스트 + 빌드 + CI/CD + SoundEffect + AutoLaunch + UI 리디자인

- vitest 41개 단위 테스트 (HistoryService, DictionaryService, CustomInstructionService, VoiceModeService, D3ROError)
- electron-builder.yml (NSIS, asarUnpack, extraResources)
- .gitlab-ci.yml (lint, typecheck, test, build, release)
- SoundEffectService: WAV 프리로드 + PowerShell 재생 + VoiceMode 연동
- AutoLaunchService: app.setLoginItemSettings + ConfigService 동기화
- TextInsertService: 간이 삽입 검증 (EditMonitor 경량)
- 번들링 인프라: SoX 다운로드 스크립트, PyInstaller 빌드, 경로 해상도 유틸
- AudioCaptureService/LocalSTTService: 번들 경로 자동 감지
- 08-design-system.md 기반 MUI 테마 (다크+라이트+auto 테마 시스템)
- 전체 UI 컴포넌트 리디자인: AppLayout, Dashboard, StatusBar, History, Dictionary, Commands, Settings
- 효과음 WAV 생성: recording-start, recording-stop, error
- EPIPE 에러 핸들링 추가
This commit is contained in:
Yun Chan 2026-04-05 09:12:56 +09:00
parent ed5541f769
commit 3f4d0c5828
40 changed files with 6034 additions and 580 deletions

View file

@ -1,13 +1,11 @@
// src/renderer/pages/HistoryPage.tsx
// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템
import { useState, useEffect, useCallback } from 'react'
import {
Box,
Typography,
TextField,
List,
ListItem,
ListItemText,
IconButton,
Chip,
Pagination,
@ -18,10 +16,29 @@ import {
import SearchIcon from '@mui/icons-material/Search'
import DeleteIcon from '@mui/icons-material/Delete'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { d3roPalette, d3roFontMono } from '../theme'
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
const PAGE_SIZE = 20
function formatDate(ts: number): string {
return new Date(ts).toLocaleString('ko-KR', {
month: 'short', day: 'numeric', 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')}`
}
const MODE_TAG: Record<string, 'primary' | 'secondary' | 'warning'> = {
dictation: 'primary',
translate: 'secondary',
command: 'warning',
}
export function HistoryPage(): React.ReactElement {
const [data, setData] = useState<HistoryPageData | null>(null)
const [page, setPage] = useState(0)
@ -33,16 +50,11 @@ export function HistoryPage(): React.ReactElement {
const result = search.trim()
? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE })
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
if (result.success) {
setData(result.data)
}
if (result.success) setData(result.data)
setLoading(false)
}, [page, search])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => { loadData() }, [loadData])
const handleDelete = async (id: string) => {
await window.electronAPI.history.delete({ id })
@ -53,41 +65,28 @@ export function HistoryPage(): React.ReactElement {
navigator.clipboard.writeText(text)
}
const formatDate = (ts: number) => {
return new Date(ts).toLocaleString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatDuration = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.round(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
History
</Typography>
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 5 }}>
{/* Header */}
<Box sx={{ mb: 3 }}>
<Typography sx={{ fontSize: '22px', fontWeight: 700 }}>History</Typography>
<Typography sx={{ fontSize: '14px', color: 'text.secondary', mt: 0.5 }}>
Transcription history
</Typography>
</Box>
{/* Search */}
<TextField
placeholder="Search transcriptions..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
fullWidth
sx={{ mb: 2 }}
sx={{ mb: 3 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
<SearchIcon sx={{ color: d3roPalette.text.label }} />
</InputAdornment>
)
}
@ -98,59 +97,90 @@ export function HistoryPage(): React.ReactElement {
<Typography color="text.secondary">Loading...</Typography>
) : !data || data.entries.length === 0 ? (
<Card>
<CardContent>
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
{search ? 'No results found.' : 'No history yet.'}
<CardContent sx={{ py: 6, textAlign: 'center' }}>
<Typography color="text.secondary">
{search ? 'No results found.' : 'No history yet. Start recording!'}
</Typography>
</CardContent>
</Card>
) : (
<>
<List>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{data.entries.map((entry: HistoryEntry) => (
<ListItem
key={entry.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={entry.polishedText || entry.originalText}
secondary={
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
{formatDate(entry.createdAt)}
<Card key={entry.id} sx={{ p: 0 }}>
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
{/* Text */}
<Box sx={{ flex: 1, mr: 2 }}>
<Typography
sx={{
fontSize: '14px',
lineHeight: 1.5,
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{entry.polishedText || entry.originalText}
</Typography>
<Chip label={formatDuration(entry.duration)} size="small" variant="outlined" />
{entry.detectedLanguage && (
<Chip label={entry.detectedLanguage} size="small" variant="outlined" />
)}
<Chip label={entry.mode} size="small" variant="outlined" />
{/* Meta row */}
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.text.label,
}}
>
{formatDate(entry.createdAt)}
</Typography>
<Chip label={formatDuration(entry.duration)} size="small" color="primary" />
{entry.detectedLanguage && (
<Chip label={entry.detectedLanguage.toUpperCase()} size="small" color="secondary" />
)}
<Chip label={entry.mode.toUpperCase()} size="small" color={MODE_TAG[entry.mode] ?? 'primary'} />
</Box>
</Box>
}
primaryTypographyProps={{ sx: { pr: 8 } }}
/>
</ListItem>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<IconButton
size="small"
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(entry.id)}
sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</CardContent>
</Card>
))}
</List>
</Box>
{data.totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={data.totalPages}
page={page + 1}
onChange={(_, p) => setPage(p - 1)}
sx={{
'& .Mui-selected': {
bgcolor: `${d3roPalette.accent.amberDim} !important`,
color: d3roPalette.accent.amber,
}
}}
/>
</Box>
)}