feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,332 @@
// src/renderer/components/HotkeyRecordModal.tsx
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
// 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성
// 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록
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 { useI18n } from '../i18n'
import type { HotkeyBinding } from '@shared/types'
// ── 키 이름 매핑 (Windows) ──────────────────────────────
const KEY_DISPLAY_MAP: Record<number, string> = {
// Modifier keys
16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win',
160: 'Left Shift', 161: 'Right Shift',
162: 'Left Ctrl', 163: 'Right Ctrl',
164: 'Left Alt', 165: 'Right Alt',
// Common keys
8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock',
27: 'Esc', 32: 'Space',
33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home',
37: '←', 38: '↑', 39: '→', 40: '↓',
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',
// Numpad
96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4',
101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9',
106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/',
// Special
186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`',
219: '[', 220: '\\', 221: ']', 222: "'",
}
// 시스템 예약 조합
const RESERVED_COMBOS = [
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete',
]
const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165])
function getKeyName(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 isModifier(keyCode: number): boolean {
return MODIFIER_KEYCODES.has(keyCode)
}
interface HotkeyRecordModalProps {
open: boolean
onClose: () => void
onSave: (binding: HotkeyBinding) => void
currentBinding?: HotkeyBinding | null
title?: string
}
export function HotkeyRecordModal({
open,
onClose,
onSave,
currentBinding,
title,
}: HotkeyRecordModalProps): React.ReactElement {
const { t } = useI18n()
// 현재 눌려있는 키들을 실시간 추적
const [pressedKeys, setPressedKeys] = useState<Array<{ keyCode: number; name: string }>>([])
// 확정된 조합 (녹화 완료 후)
const [captured, setCaptured] = useState<Array<{ keyCode: number; name: string }> | null>(null)
const [error, setError] = useState<string | null>(null)
const pressedRef = useRef<Map<number, string>>(new Map())
// modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기)
const modifierTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지)
const lastPressedRef = useRef<Array<{ keyCode: number; name: string }>>([])
// Modal 열릴 때 리셋
useEffect(() => {
if (open) {
setPressedKeys([])
setCaptured(null)
setError(null)
pressedRef.current.clear()
lastPressedRef.current = []
if (modifierTimerRef.current) {
clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = null
}
}
}, [open])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (captured) return // 이미 확정됨
e.preventDefault()
e.stopPropagation()
// 새 키가 눌렸으므로 modifier-only 타이머 취소
if (modifierTimerRef.current) {
clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = null
}
const keyCode = e.keyCode || e.which
if (pressedRef.current.has(keyCode)) return
const name = getKeyName(keyCode, e.key)
pressedRef.current.set(keyCode, name)
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
setPressedKeys(keys)
lastPressedRef.current = keys
// modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등)
if (!isModifier(keyCode)) {
setCaptured(keys)
}
}, [captured])
const handleKeyUp = useCallback((e: KeyboardEvent) => {
if (captured) return // 이미 확정됨
const keyCode = e.keyCode || e.which
pressedRef.current.delete(keyCode)
// 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정
// 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능
if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) {
if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = setTimeout(() => {
// 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정
setCaptured(lastPressedRef.current)
modifierTimerRef.current = null
}, 500)
}
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
setPressedKeys(keys)
}, [captured])
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 displayKeys = captured ?? pressedKeys
const isReady = captured !== null && captured.length > 0
const handleSave = () => {
if (!captured || captured.length === 0) {
setError(t('hotkey.noKey'))
return
}
// 예약 단축키 체크
const label = captured.map(k => k.name).join('+')
if (RESERVED_COMBOS.includes(label)) {
setError(t('hotkey.reserved', { keys: label }))
handleReset()
return
}
// HotkeyBinding 생성
// 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier
const mainKey = captured.find(k => !isModifier(k.keyCode))
const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode
// modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지
// 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로)
// 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용)
const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163
const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165
const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161
const isMetaKey = (kc: number) => kc === 91 || kc === 92
// 주 키를 제외한 나머지 키들만 modifier로 취급
const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode)
const binding: HotkeyBinding = {
keyCode: primaryKeyCode,
ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)),
alt: modifierKeys.some(k => isAltKey(k.keyCode)),
shift: modifierKeys.some(k => isShiftKey(k.keyCode)),
meta: modifierKeys.some(k => isMetaKey(k.keyCode)),
displayLabel: label,
}
onSave(binding)
onClose()
}
const handleReset = () => {
setPressedKeys([])
setCaptured(null)
pressedRef.current.clear()
}
const handleCancel = () => {
handleReset()
onClose()
}
return (
<Dialog
open={open}
onClose={handleCancel}
maxWidth="xs"
fullWidth
// aria-hidden 에러 방지: disableEnforceFocus + disableAutoFocus
disableEnforceFocus
disableAutoFocus
disableRestoreFocus
>
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
sx={{
border: `2px solid ${
error
? d3roPalette.tag.red
: isReady
? d3roPalette.tag.green
: displayKeys.length > 0
? 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',
}}
>
{displayKeys.length > 0 ? (
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
{displayKeys.map((key, i) => (
<Chip
key={`${key.keyCode}-${i}`}
label={key.name}
sx={{
fontWeight: 700,
fontSize: '14px',
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '8px',
height: 40,
px: 1,
}}
/>
))}
</Stack>
) : (
<Typography
sx={{
color: d3roPalette.accent.amber,
fontSize: '13px',
textAlign: 'center',
}}
>
{t('hotkey.prompt')}
</Typography>
)}
</Box>
{/* 상태 표시 */}
{isReady && !error && (
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '12px', mt: 1, fontWeight: 600 }}>
{t('hotkey.ready', { keys: captured?.map(k => k.name).join(' + ') ?? '' })}
</Typography>
)}
{error && (
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
{error}
</Typography>
)}
{currentBinding && (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
{t('hotkey.current', { keys: currentBinding.displayLabel })}
</Typography>
)}
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
{t('hotkey.hint')}
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
{t('common.cancel')}
</Button>
{isReady && (
<Button onClick={handleReset} sx={{ color: d3roPalette.text.inactive }}>
{t('hotkey.reset')}
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={!isReady}
>
{t('common.save')}
</Button>
</DialogActions>
</Dialog>
)
}