Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
26
src/renderer/App.tsx
Normal file
26
src/renderer/App.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// src/renderer/App.tsx — 루트 컴포넌트
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
|
||||
import { lightTheme, darkTheme } from './theme'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import type { ThemeMode } from '@shared/types'
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const [themeMode] = useState<ThemeMode>('auto')
|
||||
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
|
||||
const theme = useMemo(() => {
|
||||
if (themeMode === 'auto') {
|
||||
return prefersDark ? darkTheme : lightTheme
|
||||
}
|
||||
return themeMode === 'dark' ? darkTheme : lightTheme
|
||||
}, [themeMode, prefersDark])
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AppLayout />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
110
src/renderer/components/AppLayout.tsx
Normal file
110
src/renderer/components/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Typography,
|
||||
Chip
|
||||
} 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 SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { DashboardPage } from '../pages/DashboardPage'
|
||||
|
||||
type Route = 'dashboard' | 'history' | 'dictionary'
|
||||
|
||||
const DRAWER_WIDTH = 240
|
||||
|
||||
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
|
||||
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
|
||||
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> }
|
||||
]
|
||||
|
||||
export function AppLayout(): React.ReactElement {
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||
{/* Sidebar Drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
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 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Bottom */}
|
||||
<List>
|
||||
<ListItemButton sx={{ my: 0.5 }}>
|
||||
<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' && (
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
History (Phase 5)
|
||||
</Typography>
|
||||
)}
|
||||
{currentRoute === 'dictionary' && (
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
Dictionary (Phase 5)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
8
src/renderer/electron.d.ts
vendored
Normal file
8
src/renderer/electron.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// src/renderer/electron.d.ts
|
||||
import type { ElectronAPI } from '../preload/index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>D3RO Voice</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
src/renderer/main.tsx
Normal file
14
src/renderer/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// src/renderer/main.tsx — 렌더러 진입점
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
67
src/renderer/pages/DashboardPage.tsx
Normal file
67
src/renderer/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
|
||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import TimerIcon from '@mui/icons-material/Timer'
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||
import TodayIcon from '@mui/icons-material/Today'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Recent Sessions
|
||||
</Typography>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
No sessions yet. Press the hotkey to start recording.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
148
src/renderer/theme.ts
Normal file
148
src/renderer/theme.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
|
||||
|
||||
import { createTheme, type ThemeOptions } from '@mui/material/styles'
|
||||
|
||||
const commonOptions: ThemeOptions = {
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif'
|
||||
].join(','),
|
||||
h4: { fontWeight: 600, fontSize: '1.5rem' },
|
||||
h5: { fontWeight: 600, fontSize: '1.25rem' },
|
||||
h6: { fontWeight: 600, fontSize: '1rem' },
|
||||
subtitle1: { fontWeight: 500 },
|
||||
body1: { fontSize: '0.9375rem' },
|
||||
body2: { fontSize: '0.8125rem' },
|
||||
button: { textTransform: 'none' as const, fontWeight: 500 }
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableElevation: true
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderRadius: 8,
|
||||
padding: '8px 16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
defaultProps: {
|
||||
elevation: 0
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
border: '1px solid'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
width: 240,
|
||||
borderRight: 'none'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
marginLeft: 8,
|
||||
marginRight: 8
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTextField: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
variant: 'outlined'
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 6,
|
||||
fontWeight: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
...commonOptions,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: 'rgb(31, 93, 242)',
|
||||
light: 'rgb(71, 133, 255)',
|
||||
dark: 'rgb(20, 65, 180)',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgb(108, 117, 125)',
|
||||
light: 'rgb(173, 181, 189)',
|
||||
dark: 'rgb(73, 80, 87)'
|
||||
},
|
||||
background: {
|
||||
default: '#F9F9F9',
|
||||
paper: '#FFFFFF'
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(0, 0, 0, 0.87)',
|
||||
secondary: 'rgba(0, 0, 0, 0.6)'
|
||||
},
|
||||
divider: 'rgba(0, 0, 0, 0.08)',
|
||||
error: { main: '#D32F2F' },
|
||||
success: { main: '#2E7D32' },
|
||||
warning: { main: '#ED6C02' }
|
||||
}
|
||||
})
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
...commonOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: 'rgb(71, 133, 255)',
|
||||
light: 'rgb(120, 170, 255)',
|
||||
dark: 'rgb(31, 93, 242)',
|
||||
contrastText: '#FFFFFF'
|
||||
},
|
||||
secondary: {
|
||||
main: 'rgb(173, 181, 189)',
|
||||
light: 'rgb(206, 212, 218)',
|
||||
dark: 'rgb(108, 117, 125)'
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1E1E1E'
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(255, 255, 255, 0.87)',
|
||||
secondary: 'rgba(255, 255, 255, 0.6)'
|
||||
},
|
||||
divider: 'rgba(255, 255, 255, 0.08)',
|
||||
error: { main: '#EF5350' },
|
||||
success: { main: '#4CAF50' },
|
||||
warning: { main: '#FFA726' }
|
||||
}
|
||||
})
|
||||
|
||||
export function getTheme(mode: 'light' | 'dark') {
|
||||
return mode === 'dark' ? darkTheme : lightTheme
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue