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:
parent
f41fc277e3
commit
28371a9d1a
19 changed files with 1879 additions and 403 deletions
|
|
@ -15,6 +15,7 @@ import { DictionaryPage } from '../pages/DictionaryPage'
|
|||
import { CommandsPage } from '../pages/CommandsPage'
|
||||
import { SettingsModal } from './SettingsModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
|
||||
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
|
||||
|
||||
|
|
@ -30,15 +31,15 @@ export function AppLayout(): React.ReactElement {
|
|||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: '#19191b' }}>
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 72,
|
||||
flexShrink: 0,
|
||||
bgcolor: '#1e1f21',
|
||||
borderRight: '1px solid rgba(255,255,255,0.04)',
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
|
|
@ -52,9 +53,9 @@ export function AppLayout(): React.ReactElement {
|
|||
<Typography
|
||||
sx={{
|
||||
fontSize: '8px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontFamily: d3roFontMono,
|
||||
letterSpacing: '1.5px',
|
||||
color: '#5c2615',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
|
|
@ -79,11 +80,11 @@ export function AppLayout(): React.ReactElement {
|
|||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
bgcolor: isActive ? '#242528' : 'transparent',
|
||||
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
||||
boxShadow: isActive
|
||||
? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000'
|
||||
: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
|
||||
color: isActive ? '#f25b29' : '#77797c',
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
transition: 'all 0.05s linear',
|
||||
transform: isActive ? 'translateY(1px)' : 'none',
|
||||
'&:active': {
|
||||
|
|
@ -91,7 +92,7 @@ export function AppLayout(): React.ReactElement {
|
|||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||
},
|
||||
'&:hover': {
|
||||
color: isActive ? '#f25b29' : '#aaa',
|
||||
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -99,7 +100,7 @@ export function AppLayout(): React.ReactElement {
|
|||
<Typography
|
||||
sx={{
|
||||
fontSize: '7px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
|
|
@ -126,14 +127,14 @@ export function AppLayout(): React.ReactElement {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: '#77797c',
|
||||
color: d3roPalette.text.inactive,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
|
||||
transition: 'all 0.05s linear',
|
||||
'&:active': {
|
||||
transform: 'translateY(2px)',
|
||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||
},
|
||||
'&:hover': { color: '#aaa' },
|
||||
'&:hover': { color: d3roPalette.text.hover },
|
||||
}}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||
|
|
@ -147,9 +148,9 @@ export function AppLayout(): React.ReactElement {
|
|||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: 'auto',
|
||||
bgcolor: '#19191b',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
// 미묘한 방사형 비네팅 (시안 A 배경)
|
||||
background: 'radial-gradient(circle at 50% 30%, #252628 0%, #19191b 70%)',
|
||||
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
|
|
|
|||
334
src/renderer/components/HotkeyRecordModal.tsx
Normal file
334
src/renderer/components/HotkeyRecordModal.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// src/renderer/components/HotkeyRecordModal.tsx
|
||||
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { HotkeyBinding } from '@shared/types'
|
||||
|
||||
// ── 키 이름 매핑 (Windows 전용) ──────────────────────────
|
||||
const KEY_DISPLAY_MAP: Record<number, string> = {
|
||||
// Modifier keys
|
||||
160: 'Left Shift',
|
||||
161: 'Right Shift',
|
||||
162: 'Left Ctrl',
|
||||
163: 'Right Ctrl',
|
||||
164: 'Left Alt',
|
||||
165: 'Right Alt',
|
||||
91: 'Left Win',
|
||||
92: 'Right Win',
|
||||
// Common keys
|
||||
8: 'Backspace',
|
||||
9: 'Tab',
|
||||
13: 'Enter',
|
||||
19: 'Pause',
|
||||
20: 'Caps Lock',
|
||||
27: 'Escape',
|
||||
32: 'Space',
|
||||
33: 'Page Up',
|
||||
34: 'Page Down',
|
||||
35: 'End',
|
||||
36: 'Home',
|
||||
37: 'Left',
|
||||
38: 'Up',
|
||||
39: 'Right',
|
||||
40: 'Down',
|
||||
45: 'Insert',
|
||||
46: 'Delete',
|
||||
// F-keys
|
||||
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
|
||||
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
|
||||
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
|
||||
// Number keys
|
||||
48: '0', 49: '1', 50: '2', 51: '3', 52: '4',
|
||||
53: '5', 54: '6', 55: '7', 56: '8', 57: '9',
|
||||
}
|
||||
|
||||
// 시스템 예약 단축키 블랙리스트 (Windows)
|
||||
const RESERVED_COMBOS: Array<{ ctrl: boolean; alt: boolean; shift: boolean; key: string }> = [
|
||||
{ ctrl: true, alt: false, shift: false, key: 'c' }, // Copy
|
||||
{ ctrl: true, alt: false, shift: false, key: 'v' }, // Paste
|
||||
{ ctrl: true, alt: false, shift: false, key: 'x' }, // Cut
|
||||
{ ctrl: true, alt: false, shift: false, key: 'z' }, // Undo
|
||||
{ ctrl: true, alt: false, shift: false, key: 'a' }, // Select All
|
||||
{ ctrl: true, alt: false, shift: false, key: 's' }, // Save
|
||||
{ ctrl: true, alt: false, shift: false, key: 'w' }, // Close tab
|
||||
{ ctrl: false, alt: true, shift: false, key: 'F4' }, // Close window
|
||||
{ ctrl: false, alt: false, shift: false, key: 'F5' }, // Refresh
|
||||
]
|
||||
|
||||
function getKeyDisplayName(keyCode: number, key: string): string {
|
||||
if (KEY_DISPLAY_MAP[keyCode]) return KEY_DISPLAY_MAP[keyCode]
|
||||
if (key.length === 1) return key.toUpperCase()
|
||||
return key
|
||||
}
|
||||
|
||||
function isModifierKey(keyCode: number): boolean {
|
||||
return (keyCode >= 160 && keyCode <= 165) || keyCode === 91 || keyCode === 92
|
||||
}
|
||||
|
||||
function isReservedCombo(ctrl: boolean, alt: boolean, shift: boolean, key: string): boolean {
|
||||
return RESERVED_COMBOS.some(
|
||||
(c) => c.ctrl === ctrl && c.alt === alt && c.shift === shift && c.key === key
|
||||
)
|
||||
}
|
||||
|
||||
interface HotkeyRecordModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSave: (binding: HotkeyBinding) => void
|
||||
currentBinding?: HotkeyBinding | null
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface RecordedKey {
|
||||
keyCode: number
|
||||
displayName: string
|
||||
isModifier: boolean
|
||||
}
|
||||
|
||||
export function HotkeyRecordModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
currentBinding,
|
||||
title = '단축키 설정',
|
||||
}: HotkeyRecordModalProps): React.ReactElement {
|
||||
const [recordedKeys, setRecordedKeys] = useState<RecordedKey[]>([])
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const keysRef = useRef<Set<number>>(new Set())
|
||||
|
||||
// Modal 열릴 때 녹화 시작
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecordedKeys([])
|
||||
setValidationError(null)
|
||||
setIsRecording(true)
|
||||
keysRef.current.clear()
|
||||
} else {
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!isRecording) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const keyCode = e.keyCode || e.which
|
||||
if (keysRef.current.has(keyCode)) return
|
||||
keysRef.current.add(keyCode)
|
||||
|
||||
const displayName = getKeyDisplayName(keyCode, e.key)
|
||||
const isMod = isModifierKey(keyCode)
|
||||
|
||||
setRecordedKeys((prev) => {
|
||||
const next = [...prev, { keyCode, displayName, isModifier: isMod }]
|
||||
// modifier가 아닌 키가 들어오면 녹화 완료 (modifier + key 조합)
|
||||
if (!isMod) {
|
||||
setIsRecording(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setValidationError(null)
|
||||
},
|
||||
[isRecording]
|
||||
)
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const keyCode = e.keyCode || e.which
|
||||
keysRef.current.delete(keyCode)
|
||||
|
||||
// modifier만 눌렀다 놓은 경우: 단일 modifier 키 등록
|
||||
if (isRecording && isModifierKey(keyCode) && keysRef.current.size === 0) {
|
||||
setRecordedKeys((prev) => {
|
||||
if (prev.length > 0 && prev.every((k) => k.isModifier)) {
|
||||
setIsRecording(false)
|
||||
return prev
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
},
|
||||
[isRecording]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('keyup', handleKeyUp, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('keyup', handleKeyUp, true)
|
||||
}
|
||||
}, [open, handleKeyDown, handleKeyUp])
|
||||
|
||||
const handleSave = () => {
|
||||
if (recordedKeys.length === 0) {
|
||||
setValidationError('키를 입력해주세요')
|
||||
return
|
||||
}
|
||||
|
||||
const hasModifier = recordedKeys.some((k) => k.isModifier)
|
||||
const mainKey = recordedKeys.find((k) => !k.isModifier)
|
||||
|
||||
// 시스템 예약 단축키 체크
|
||||
if (mainKey) {
|
||||
const ctrl = recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163)
|
||||
const alt = recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165)
|
||||
const shift = recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161)
|
||||
|
||||
if (isReservedCombo(ctrl, alt, shift, mainKey.displayName)) {
|
||||
setValidationError('시스템 예약 단축키입니다')
|
||||
resetRecording()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HotkeyBinding 생성
|
||||
const displayLabel = recordedKeys.map((k) => k.displayName).join(' + ')
|
||||
const primaryKeyCode = mainKey?.keyCode ?? recordedKeys[0].keyCode
|
||||
|
||||
const binding: HotkeyBinding = {
|
||||
keyCode: primaryKeyCode,
|
||||
ctrl: recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163),
|
||||
alt: recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165),
|
||||
shift: recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161),
|
||||
meta: recordedKeys.some((k) => k.keyCode === 91 || k.keyCode === 92),
|
||||
displayLabel,
|
||||
}
|
||||
|
||||
// 단일 modifier만 입력한 경우 (예: Right Alt만)
|
||||
if (!mainKey && hasModifier && recordedKeys.length === 1) {
|
||||
const modKey = recordedKeys[0]
|
||||
binding.keyCode = modKey.keyCode
|
||||
binding.ctrl = modKey.keyCode === 162 || modKey.keyCode === 163
|
||||
binding.alt = modKey.keyCode === 164 || modKey.keyCode === 165
|
||||
binding.shift = modKey.keyCode === 160 || modKey.keyCode === 161
|
||||
binding.meta = modKey.keyCode === 91 || modKey.keyCode === 92
|
||||
}
|
||||
|
||||
onSave(binding)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const resetRecording = () => {
|
||||
setRecordedKeys([])
|
||||
setIsRecording(true)
|
||||
keysRef.current.clear()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setRecordedKeys([])
|
||||
setIsRecording(false)
|
||||
keysRef.current.clear()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleCancel} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 녹화 영역 */}
|
||||
<Box
|
||||
sx={{
|
||||
border: `2px solid ${
|
||||
validationError
|
||||
? d3roPalette.tag.red
|
||||
: isRecording
|
||||
? d3roPalette.accent.amber
|
||||
: d3roPalette.border.strong
|
||||
}`,
|
||||
borderRadius: '12px',
|
||||
p: 3,
|
||||
minHeight: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{recordedKeys.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
|
||||
{recordedKeys.map((key, i) => (
|
||||
<Chip
|
||||
key={`${key.keyCode}-${i}`}
|
||||
label={key.displayName}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '13px',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: '8px',
|
||||
height: 36,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: isRecording ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{isRecording ? '키 조합을 눌러주세요...' : '키를 입력하세요'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 검증 오류 */}
|
||||
{validationError && (
|
||||
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
|
||||
{validationError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 현재 설정 표시 */}
|
||||
{currentBinding && (
|
||||
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
|
||||
현재: {currentBinding.displayLabel}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 안내 */}
|
||||
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
|
||||
단일 키(예: Right Alt) 또는 조합키(예: Ctrl+Shift+Q)를 입력하세요
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
|
||||
취소
|
||||
</Button>
|
||||
{!isRecording && recordedKeys.length > 0 && (
|
||||
<Button onClick={resetRecording} sx={{ color: d3roPalette.text.inactive }}>
|
||||
다시 입력
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={isRecording || recordedKeys.length === 0}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Led } from './ds'
|
||||
import { d3roPalette, d3roFontMono } from '../theme'
|
||||
import type { LLMStatus } from '@shared/types'
|
||||
|
||||
const MONO = 'MONO_PLACEHOLDER'
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
|
||||
|
|
@ -36,27 +35,27 @@ export function StatusBar(): React.ReactElement {
|
|||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: '1px solid rgba(255,255,255,0.04)',
|
||||
bgcolor: '#1e1f21',
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
minHeight: 28,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color={connected ? 'green' : 'red'} size={6} />
|
||||
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#77797c', letterSpacing: '1px', fontWeight: 700 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.inactive, letterSpacing: '1px', fontWeight: 700 }}>
|
||||
{connected ? 'OLLAMA' : 'OFFLINE'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{llmStatus?.activeModel && (
|
||||
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#5c2615', letterSpacing: '0.5px' }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
|
||||
{llmStatus.activeModel.toUpperCase()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#3a3b3f', letterSpacing: '1px', fontWeight: 700 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.muted, letterSpacing: '1px', fontWeight: 700 }}>
|
||||
PRECISION DATA LINK
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
|
||||
// ── WebGL 유틸 ─────────────────────────────────────────
|
||||
|
||||
|
|
@ -218,7 +219,7 @@ export function CrtDisplay({
|
|||
sx={{
|
||||
position: 'relative',
|
||||
height,
|
||||
bgcolor: '#1a1a1c',
|
||||
bgcolor: d3roPalette.bg.crtBezel,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)',
|
||||
overflow: 'hidden',
|
||||
|
|
@ -230,7 +231,7 @@ export function CrtDisplay({
|
|||
position: 'absolute',
|
||||
inset: '2px',
|
||||
borderRadius: '6px',
|
||||
bgcolor: '#050605',
|
||||
bgcolor: d3roPalette.bg.crtGlass,
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
|
|
@ -261,10 +262,10 @@ export function CrtDisplay({
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
color: '#f25b29',
|
||||
color: d3roPalette.accent.amber,
|
||||
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontFamily: d3roFontMono,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
|
||||
interface InstrumentPanelProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -23,11 +24,11 @@ export function InstrumentPanel({
|
|||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
bgcolor: '#242528',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
borderRadius: '24px',
|
||||
p: 3,
|
||||
boxShadow:
|
||||
'0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #111112, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)',
|
||||
`0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 ${d3roPalette.led.off}, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)`,
|
||||
// 메탈 노이즈는 CSS로 시뮬레이션
|
||||
'&::before': {
|
||||
content: '""',
|
||||
|
|
@ -62,10 +63,10 @@ function Engraving({ children, sx }: { children: string; sx: Record<string, unkn
|
|||
position: 'absolute',
|
||||
fontSize: '9px',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#1a1a1c',
|
||||
color: d3roPalette.text.engraving,
|
||||
textShadow: '0 1px 0 rgba(255,255,255,0.08)',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontFamily: d3roFontMono,
|
||||
zIndex: 2,
|
||||
userSelect: 'none',
|
||||
...sx,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||
|
||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||
amber: { bg: '#f25b29', glow: 'rgba(242, 91, 41, 0.6)' },
|
||||
green: { bg: '#22c55e', glow: 'rgba(34, 197, 94, 0.6)' },
|
||||
red: { bg: '#ef4444', glow: 'rgba(239, 68, 68, 0.6)' },
|
||||
orange: { bg: '#f59e0b', glow: 'rgba(245, 158, 11, 0.6)' },
|
||||
off: { bg: '#111', glow: 'transparent' },
|
||||
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
|
||||
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
||||
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
||||
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
||||
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
||||
}
|
||||
|
||||
interface LedProps {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette } from '../../theme'
|
||||
|
||||
interface MetalCardProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -12,15 +13,15 @@ export function MetalCard({ children, inset = false }: MetalCardProps): React.Re
|
|||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: inset ? '#1b1c1e' : '#242427',
|
||||
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||
borderRadius: inset ? '12px' : '22px',
|
||||
borderTop: inset ? 'none' : '1px solid rgba(255,255,255,0.04)',
|
||||
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: inset
|
||||
? 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)'
|
||||
: '0 8px 30px rgba(0,0,0,0.3)',
|
||||
p: inset ? '6px' : 3,
|
||||
transition: 'background-color 0.2s ease',
|
||||
'&:hover': inset ? {} : { bgcolor: '#2a2a2d' },
|
||||
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
||||
|
||||
import { Typography, type TypographyProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
|
||||
type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim'
|
||||
|
||||
const VARIANTS: Record<PhosphorVariant, { fontSize: string; color: string; glow: string; fontWeight: number }> = {
|
||||
hero: { fontSize: '42px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
|
||||
value: { fontSize: '20px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
|
||||
label: { fontSize: '10px', color: '#5c2615', glow: 'none', fontWeight: 700 },
|
||||
dim: { fontSize: '10px', color: '#77797c', glow: 'none', fontWeight: 400 },
|
||||
hero: { fontSize: '42px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
|
||||
value: { fontSize: '20px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
|
||||
label: { fontSize: '10px', color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: 700 },
|
||||
dim: { fontSize: '10px', color: d3roPalette.text.inactive, glow: 'none', fontWeight: 400 },
|
||||
}
|
||||
|
||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||
|
|
@ -23,7 +24,7 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
|
|||
<Typography
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: v.fontSize,
|
||||
fontWeight: v.fontWeight,
|
||||
color: v.color,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
||||
|
||||
import { Button, type ButtonProps } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
|
||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
selected?: boolean
|
||||
|
|
@ -13,11 +14,11 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
|
|||
{...props}
|
||||
sx={{
|
||||
height: 44,
|
||||
bgcolor: selected ? '#1a1a1c' : '#242528',
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
color: selected ? '#f25b29' : '#77797c',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
|
|
@ -31,7 +32,7 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
|
|||
boxShadow: '0 1px 2px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)',
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: selected ? '#1a1a1c' : '#2a2b2e',
|
||||
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
|
||||
},
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue