Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성

- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등)
- DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여)
- HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달
- SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경
- DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑
- recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버)
- 설계 문서: phase-8.md, speakly-settings-ui.md
This commit is contained in:
Yun Chan 2026-04-05 09:54:55 +09:00
parent f41fc277e3
commit 28371a9d1a
19 changed files with 1879 additions and 403 deletions

View file

@ -1,7 +1,7 @@
// src/renderer/components/SettingsModal.tsx
// 설계서 03: Settings React Modal (일반/오디오/핫키 탭)
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
@ -18,11 +18,18 @@ import {
IconButton,
Divider,
InputLabel,
FormControl
FormControl,
Button,
Chip,
Stack,
Paper,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import KeyboardIcon from '@mui/icons-material/Keyboard'
import EditIcon from '@mui/icons-material/Edit'
import { d3roPalette } from '../theme'
import type { ThemeMode, AppConfig } from '@shared/types'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import type { ThemeMode, AppConfig, HotkeyBinding } from '@shared/types'
interface SettingsModalProps {
open: boolean
@ -40,183 +47,454 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
return <Box sx={{ pt: 2 }}>{children}</Box>
}
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
function HotkeyDisplay({
binding,
onEdit,
label,
}: {
binding: HotkeyBinding | null
onEdit: () => void
label: string
}): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, minWidth: 40 }}>
{label}
</Typography>
{binding ? (
<Stack direction="row" spacing={0.5} alignItems="center">
{binding.displayLabel.split(' + ').map((key) => (
<Chip
key={key}
label={key}
size="small"
sx={{
fontWeight: 700,
fontSize: '11px',
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '6px',
height: 28,
}}
/>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
</Typography>
)}
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
)
}
// ── 음성 모드 카드 ──────────────────────────────────────
function VoiceModeCard({
title,
description,
enabled,
onToggle,
disabled,
children,
}: {
title: string
description: string
enabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
children?: React.ReactNode
}): React.ReactElement {
return (
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.card,
borderRadius: '12px',
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<FormControlLabel
control={
<Switch
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
disabled={disabled}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.green,
},
}}
/>
}
label={
<Chip
label={enabled ? '활성화됨' : '비활성화'}
size="small"
sx={{
fontSize: '10px',
fontWeight: 700,
height: 20,
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
border: enabled ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
}}
/>
}
sx={{ ml: 0, mr: 0 }}
/>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, fontSize: '12px' }}>
{description}
</Typography>
</Box>
</Box>
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
</Paper>
)
}
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
const [activeTab, setActiveTab] = useState(0)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
// 핫키 녹화 모달
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree'>('dictation')
// 설정 로드
useEffect(() => {
if (!open) return
setLoading(true)
window.electronAPI.config
.getAll()
.then((result) => {
if (result.success) {
setConfig(result.data)
Promise.all([
window.electronAPI.config.getAll(),
window.electronAPI.hotkey.getDictationShortcut(),
window.electronAPI.hotkey.getHandsFreeShortcut(),
window.electronAPI.hotkey.isEnabled(),
])
.then(([configResult, dictResult, hfResult, enabledResult]) => {
if (configResult.success) setConfig(configResult.data)
if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data)
if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data)
if (enabledResult.success) {
setHotkeyGlobalEnabled(enabledResult.data)
setDictationEnabled(enabledResult.data)
}
})
.finally(() => setLoading(false))
}, [open])
const updateConfig = (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
window.electronAPI.config.set({ key, value })
}
}, [])
// 음성 모드 토글
const handleDictationToggle = useCallback(
(enabled: boolean) => {
setDictationEnabled(enabled)
window.electronAPI.hotkey.setEnabled({ enabled })
// 받아쓰기 비활성화 시 핸즈프리도 비활성화
if (!enabled && handsFreeEnabled) {
setHandsFreeEnabled(false)
}
},
[handsFreeEnabled]
)
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
if (enabled && !handsFreeBinding) {
// 핫키 설정 없으면 녹화 모달 열기
setHotkeyModalTarget('handsFree')
setHotkeyModalOpen(true)
return
}
setHandsFreeEnabled(enabled)
}, [handsFreeBinding])
// 핫키 저장
const handleHotkeySave = useCallback(
(binding: HotkeyBinding) => {
if (hotkeyModalTarget === 'dictation') {
setDictationBinding(binding)
window.electronAPI.hotkey.setDictationShortcut({ binding })
setDictationEnabled(true)
window.electronAPI.hotkey.setEnabled({ enabled: true })
} else {
setHandsFreeBinding(binding)
window.electronAPI.hotkey.setHandsFreeShortcut({ binding })
setHandsFreeEnabled(true)
}
},
[hotkeyModalTarget]
)
const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree') => {
setHotkeyModalTarget(target)
setHotkeyModalOpen(true)
}, [])
if (loading) return <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
Settings
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab label="General" />
<Tab label="Audio" />
<Tab label="STT" />
<Tab label="LLM" />
</Tabs>
<>
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent>
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 16 }} />} iconPosition="start" />
<Tab label="오디오" />
<Tab label="STT" />
<Tab label="LLM" />
</Tabs>
{/* General */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Theme</InputLabel>
<Select
label="Theme"
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
{/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* 단축키 섹션 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControl size="small">
<InputLabel>Language</InputLabel>
<Select
label="Language"
value={config.language ?? 'ko'}
onChange={(e) => updateConfig('language', e.target.value)}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
</Select>
</FormControl>
<Stack spacing={1.5}>
{/* 받아쓰기 모드 */}
<VoiceModeCard
title="받아쓰기"
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
enabled={dictationEnabled}
onToggle={handleDictationToggle}
>
<HotkeyDisplay
binding={dictationBinding}
onEdit={() => openHotkeyModal('dictation')}
label="키"
/>
</VoiceModeCard>
<FormControlLabel
control={
<Switch
checked={config.closeToTray ?? true}
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
{/* Agent 모드 (더블프레스) */}
<VoiceModeCard
title="Agent 모드"
description={
dictationBinding
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
: '받아쓰기 핫키를 먼저 설정하세요.'
}
enabled={dictationEnabled}
onToggle={handleDictationToggle}
disabled={!dictationEnabled}
/>
}
label="Close to tray"
/>
<FormControlLabel
control={
<Switch
checked={config.autoInsert ?? true}
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
/>
}
label="Auto-insert text after transcription"
/>
{/* 원터치 모드 (핸즈프리) */}
<VoiceModeCard
title="원터치 모드"
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
enabled={handsFreeEnabled}
onToggle={handleHandsFreeToggle}
disabled={!dictationEnabled}
>
<HotkeyDisplay
binding={handsFreeBinding}
onEdit={() => openHotkeyModal('handsFree')}
label="키"
/>
</VoiceModeCard>
</Stack>
<FormControlLabel
control={
<Switch
checked={config.soundEnabled ?? true}
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
/>
}
label="Sound effects"
/>
</Box>
</TabPanel>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Audio */}
<TabPanel value={activeTab} index={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Microphone device selection will be available in a future update.
Currently using the system default microphone.
</Typography>
{/* UI 설정 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControl size="small">
<InputLabel>Insert Method</InputLabel>
<Select
label="Insert Method"
value={config.insertMethod ?? 'clipboard'}
onChange={(e) =>
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
<FormControl size="small">
<InputLabel></InputLabel>
<Select
label="테마"
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto"></MenuItem>
<MenuItem value="light"></MenuItem>
<MenuItem value="dark"></MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select
label="언어"
value={config.language ?? 'ko'}
onChange={(e) => updateConfig('language', e.target.value)}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* 앱 동작 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControlLabel
control={
<Switch
checked={config.closeToTray ?? true}
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
/>
}
>
<MenuItem value="clipboard">Clipboard (Ctrl+V)</MenuItem>
<MenuItem value="keyboard">Keyboard typing</MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
label="트레이로 최소화"
/>
{/* STT */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Whisper Model</InputLabel>
<Select
label="Whisper Model"
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">tiny (39 MB, fastest)</MenuItem>
<MenuItem value="base">base (74 MB, balanced)</MenuItem>
<MenuItem value="small">small (244 MB, better)</MenuItem>
<MenuItem value="medium">medium (769 MB, good)</MenuItem>
<MenuItem value="large-v3">large-v3 (1.5 GB, best)</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={config.autoLaunch ?? false}
onChange={(e) => updateConfig('autoLaunch', e.target.checked)}
/>
}
label="시스템 시작 시 자동 실행"
/>
<FormControl size="small">
<InputLabel>Language</InputLabel>
<Select
label="Language"
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto">Auto-detect</MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="zh"></MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
<FormControlLabel
control={
<Switch
checked={config.autoInsert ?? true}
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
/>
}
label="전사 후 자동 텍스트 삽입"
/>
{/* LLM */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ollama Server URL"
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={config.soundEnabled ?? true}
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
/>
}
label="효과음"
/>
</Box>
</TabPanel>
<Typography variant="body2" color="text.secondary">
LLM model selection will be available after Ollama integration (Phase 4).
</Typography>
</Box>
</TabPanel>
</DialogContent>
</Dialog>
{/* ── 오디오 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
.
.
</Typography>
<FormControl size="small">
<InputLabel> </InputLabel>
<Select
label="삽입 방식"
value={config.insertMethod ?? 'clipboard'}
onChange={(e) =>
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
}
>
<MenuItem value="clipboard"> (Ctrl+V)</MenuItem>
<MenuItem value="keyboard"> </MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Whisper </InputLabel>
<Select
label="Whisper 모델"
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">tiny (39 MB, )</MenuItem>
<MenuItem value="base">base (74 MB, )</MenuItem>
<MenuItem value="small">small (244 MB, )</MenuItem>
<MenuItem value="medium">medium (769 MB, )</MenuItem>
<MenuItem value="large-v3">large-v3 (1.5 GB, )</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel> </InputLabel>
<Select
label="인식 언어"
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto"> </MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="zh"></MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ollama 서버 URL"
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<Typography variant="body2" color="text.secondary">
Ollama가 .
Ollama에서 pull하세요 (: ollama pull qwen3:4b).
</Typography>
</Box>
</TabPanel>
</DialogContent>
</Dialog>
{/* 핫키 녹화 모달 */}
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding}
title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
/>
</>
)
}