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:
parent
ed5541f769
commit
3f4d0c5828
40 changed files with 6034 additions and 580 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
FormControl
|
||||
} from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { ThemeMode, AppConfig } from '@shared/types'
|
||||
|
||||
interface SettingsModalProps {
|
||||
|
|
@ -66,13 +67,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||
Settings
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||
<Tab label="General" />
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
// src/renderer/components/StatusBar.tsx
|
||||
// 하단 상태 표시: Ollama 연결 상태
|
||||
// 하단 상태 표시: LED 인디케이터 + 태그 시스템. 08-design-system.md SSOT.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Chip } from '@mui/material'
|
||||
import CircleIcon from '@mui/icons-material/Circle'
|
||||
import { Box, Typography, Chip } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { LLMStatus } from '@shared/types'
|
||||
|
||||
function Led({ active, color }: { active: boolean; color?: string }): React.ReactElement {
|
||||
const c = color ?? d3roPalette.tag.green
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: active ? c : d3roPalette.text.disabled,
|
||||
boxShadow: active ? `0 0 4px ${c}, 0 0 8px ${c}40` : 'none',
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.3s ease, box-shadow 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 상태 조회
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setLlmStatus(result.data)
|
||||
})
|
||||
|
||||
// 상태 변경 구독
|
||||
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
|
||||
setLlmStatus(event.status)
|
||||
})
|
||||
|
||||
// 5초마다 폴링 (main에서 이벤트를 보내지 않을 수 있으므로)
|
||||
const interval = setInterval(() => {
|
||||
window.electronAPI.llm.getStatus().then((result) => {
|
||||
if (result.success) setLlmStatus(result.data)
|
||||
|
|
@ -40,30 +54,53 @@ export function StatusBar(): React.ReactElement {
|
|||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper'
|
||||
py: 0.75,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: 'background.paper',
|
||||
minHeight: 32,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<CircleIcon sx={{ fontSize: 8 }} />}
|
||||
label={connected ? 'Ollama Connected' : 'Ollama Offline'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={connected ? 'success' : 'default'}
|
||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||
/>
|
||||
{/* Ollama 상태 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led active={connected} color={connected ? d3roPalette.tag.green : d3roPalette.tag.red} />
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
color: d3roPalette.text.secondary,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{connected ? 'OLLAMA' : 'OFFLINE'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 활성 모델 태그 */}
|
||||
{llmStatus?.activeModel && (
|
||||
<Chip
|
||||
label={llmStatus.activeModel}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||
color="primary"
|
||||
sx={{ height: 20, '& .MuiChip-label': { px: 1, fontSize: '10px' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 스페이서 */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* 핫키 힌트 */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '10px',
|
||||
color: d3roPalette.text.label,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
RIGHT ALT — DICTATE
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue