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,4 +1,5 @@
// src/renderer/components/AppLayout.tsx
// 08-design-system.md SSOT 적용. 앰버 악센트, LED, 다크 카드.
import { useState } from 'react'
import {
@ -9,14 +10,14 @@ import {
ListItemIcon,
ListItemText,
Divider,
Typography,
Chip
Typography
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import SettingsIcon from '@mui/icons-material/Settings'
import { d3roPalette, d3roFontMono } from '../theme'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
@ -41,73 +42,139 @@ export function AppLayout(): React.ReactElement {
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
D3RO Voice
</Typography>
<Chip label="v1.0" size="small" variant="outlined" />
</Box>
<Divider />
{/* Navigation */}
<List sx={{ flex: 1, pt: 1 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header — 앰버 악센트 로고 */}
<Box sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
{/* LED indicator */}
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: d3roPalette.accent.amber,
boxShadow: `0 0 6px ${d3roPalette.accent.amberGlow}, 0 0 16px ${d3roPalette.accent.amberDim}`,
animation: 'led-pulse 1.5s ease-in-out infinite',
'@keyframes led-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.5 },
},
flexShrink: 0,
}}
/>
<Typography
variant="h6"
noWrap
sx={{
fontWeight: 700,
fontSize: '14px',
letterSpacing: '0.05em',
color: d3roPalette.text.primary,
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
D3RO VOICE
</Typography>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '10px',
color: d3roPalette.text.label,
letterSpacing: '0.05em',
}}
>
v1.0
</Typography>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Navigation label */}
<Typography
sx={{
px: 2.5,
pt: 2,
pb: 1,
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: d3roPalette.text.label,
}}
>
Navigation
</Typography>
<List sx={{ flex: 1, pt: 0 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
>
<ListItemIcon
sx={{
minWidth: 36,
color: currentRoute === item.route
? d3roPalette.accent.amber
: d3roPalette.text.label,
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontSize: '14px',
fontWeight: currentRoute === item.route ? 600 : 400,
}}
/>
</ListItemButton>
))}
</List>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Bottom settings */}
<List sx={{ pb: 1 }}>
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.label }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText
primary="Settings"
primaryTypographyProps={{ fontSize: '14px' }}
/>
</ListItemButton>
))}
</List>
</List>
</Drawer>
<Divider />
{/* Bottom */}
<List>
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</List>
</Drawer>
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
overflow: 'auto',
bgcolor: 'background.default'
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
overflow: 'auto',
bgcolor: d3roPalette.bg.app,
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
</Box>
</Box>
</Box>
<StatusBar />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Box>