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

@ -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>
)
}