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
42
CLAUDE.md
42
CLAUDE.md
|
|
@ -77,31 +77,11 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 7.5 완료 + UI WIP (Phase 1~7.5 + 3.5 기능 완료, UI 미완성)
|
Phase: 8 진행 중 (Phase 1~7.5 + 3.5 완료, Phase 8 UI+기능 보강 진행)
|
||||||
마지막 작업: 인스트루먼트 UI 시도 (DS 컴포넌트 + CRT 셰이더) — WIP
|
마지막 작업: Phase 8 — SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성
|
||||||
다음 작업: **UI 디자인 근본 재설계** (아래 "디자인 재설계 TODO" 참조)
|
다음 작업: **녹음→전사 파이프라인 E2E 테스트** (핫키 변경 후 실제 사이클 확인)
|
||||||
차단 이슈: @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: @nut-tree-fork/nut-js 포크 사용
|
||||||
|
|
||||||
### ⚠️ 디자인 재설계 TODO (다음 세션 최우선)
|
|
||||||
시안 A (Meteorological Instrument) 정체성을 앱 전체에 충실히 반영해야 함.
|
|
||||||
현재 WIP 상태에서 해결해야 할 문제:
|
|
||||||
|
|
||||||
1. **SSOT 위반**: 색상/폰트 매직넘버 41곳 → 전부 d3roPalette/d3roFontMono 참조로 교체
|
|
||||||
2. **시안 A 정체성 부족**: CRT+버튼은 만들었으나 "정밀 기기" 느낌이 안 살아남
|
|
||||||
- 메탈 다이얼 컴포넌트 미구현
|
|
||||||
- 서브페이지(History/Dict/Commands)가 여전히 평범한 리스트
|
|
||||||
3. **모달/팝업 방치**: Settings 모달, 히스토리 팝업(Ctrl+Shift+V) 스타일 미적용
|
|
||||||
4. **08-design-system.md 정비 필요**: 문서와 실제 구현의 괴리 해소
|
|
||||||
5. **테마 시스템**: dark+light+auto 유지하되, 나중에 커스텀 테마 추가 가능 구조
|
|
||||||
|
|
||||||
순서: 08 문서 정비 → DS 토큰/컴포넌트 정비 → Dashboard → 서브페이지 → 모달/팝업
|
|
||||||
|
|
||||||
참조 파일:
|
|
||||||
- 시안 A HTML: docs/design-refs/instrument-panel.html (또는 유저가 준 HTML)
|
|
||||||
- 디자인 시스템: docs/design/08-design-system.md
|
|
||||||
- DS 컴포넌트: src/renderer/components/ds/
|
|
||||||
- 메모리: memory/ui_design_lessons.md
|
|
||||||
|
|
||||||
### DS 컴포넌트 현황 (src/renderer/components/ds/)
|
### DS 컴포넌트 현황 (src/renderer/components/ds/)
|
||||||
- CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅
|
- CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅
|
||||||
- InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅
|
- InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅
|
||||||
|
|
@ -111,12 +91,16 @@ Phase: 7.5 완료 + UI WIP (Phase 1~7.5 + 3.5 기능 완료, UI 미완성)
|
||||||
- PhosphorText.tsx: 인광 텍스트 (hero/value/label/dim) ✅
|
- PhosphorText.tsx: 인광 텍스트 (hero/value/label/dim) ✅
|
||||||
- ❌ MetalDial: 미구현 (시안 A의 핵심 요소)
|
- ❌ MetalDial: 미구현 (시안 A의 핵심 요소)
|
||||||
|
|
||||||
### Phase 8 구현 내용 (WIP)
|
### Phase 8 구현 내용 (진행 중)
|
||||||
- theme.ts: d3roPalette(SSOT), createD3ROTheme('dark'|'light'), getTheme(mode, prefersDark)
|
- **SSOT 완료**: d3roPalette에 11개 토큰 추가 (sidebar, chassis, inactive, dimLabel 등)
|
||||||
- DS 컴포넌트 6개 생성 (CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText)
|
- **매직넘버 제거**: tsx/ts 파일 매직넘버 0개 달성 (theme.ts 제외)
|
||||||
- Dashboard: CRT WebGL + 물리 버튼 + LED — 시안 A 느낌 시도 중
|
- **DS 컴포넌트 SSOT**: 6개 모두 d3roPalette 참조로 교체
|
||||||
- 사이드바: 72px 미니 네비게이션 (물리 버튼 느낌)
|
- **HotkeyRecordModal**: 신규 — 커스텀 핫키 녹화 모달 (키 조합 입력→Chip 표시→저장)
|
||||||
- 문제: 매직넘버 41곳, 시안 A 정체성 부족, 모달/팝업 방치
|
- **SettingsModal 재작성**: 음성 모드 3개(받아쓰기/Agent/원터치) 토글+핫키 표시+변경 UI
|
||||||
|
- **DashboardPage 재작성**: 기능 중심 — Hero(핫키 표시) + 통계 4카드 + CRT 서비스 상태 + 최근 히스토리(날짜 그룹핑)
|
||||||
|
- **recording-tip 수정**: 웨이브 바/프로그레스 바 색상 #1F5DF2(파란)→#f25b29(앰버)
|
||||||
|
- **페이지 SSOT**: AppLayout, StatusBar, HistoryPage, DictionaryPage, CommandsPage 모두 완료
|
||||||
|
- **설계 문서**: docs/phases/phase-8.md, docs/re-findings/speakly-settings-ui.md 작성
|
||||||
|
|
||||||
### Phase 7.5 구현 내용
|
### Phase 7.5 구현 내용
|
||||||
- SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생
|
- SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생
|
||||||
|
|
|
||||||
213
docs/phases/phase-8.md
Normal file
213
docs/phases/phase-8.md
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
# Phase 8: 핵심 기능 보강 + UI 디자인 근본 재설계
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
1. **핫키 변경 UI 구현** (HotkeyRecordModal) — 앱 테스트를 위한 차단 해제
|
||||||
|
2. **Settings에 음성 모드 토글 3개 추가** (받아쓰기/Agent/원터치)
|
||||||
|
3. **d3roPalette SSOT 확장 및 매직넘버 ~40곳 제거**
|
||||||
|
4. **DashboardPage 기능 중심 재작성** (통계+히스토리+상태)
|
||||||
|
5. **HistoryPage 날짜 그룹핑**
|
||||||
|
6. **recording-tip 색상 버그 수정** (파란색 `#1F5DF2` → 앰버 `#f25b29`)
|
||||||
|
|
||||||
|
## 전제 조건
|
||||||
|
Phase 7.5 완료 (SoundEffect, AutoLaunch, HotkeyService, VoiceModeService 모두 동작)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### 8.1 HotkeyRecordModal 구현
|
||||||
|
- **파일**: `src/renderer/components/HotkeyRecordModal.tsx`
|
||||||
|
- Speakly HotkeyRecordModal 패턴 차용:
|
||||||
|
1. Modal 열림 → `document.addEventListener('keydown')` 리스너 등록
|
||||||
|
2. 키 조합 감지 → `HotkeyBinding` 형태로 Chip/태그 표시
|
||||||
|
3. 유효성 검증: 시스템 예약키 블랙리스트 (Ctrl+C, Ctrl+V, Alt+F4, Ctrl+Alt+Del 등)
|
||||||
|
4. 중복 핫키 충돌 감지 → 경고 메시지 표시
|
||||||
|
5. "저장" 클릭 → IPC `hotkey:setDictationShortcut` / `hotkey:setHandsFreeShortcut` 호출
|
||||||
|
6. "취소" 또는 ESC → 모달 닫기, 변경 없음
|
||||||
|
- 키 표시: platform-specific 라벨 (`Ctrl` → Windows, 키코드 → 표시 문자열 매핑)
|
||||||
|
- HotkeyBinding 구조:
|
||||||
|
```typescript
|
||||||
|
interface HotkeyBinding {
|
||||||
|
keyCode: number;
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
displayLabel: string; // 예: "Right Alt", "Ctrl+Shift+F5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **IPC 채널**: 기존 `hotkey:setDictationShortcut`, `hotkey:setHandsFreeShortcut`, `hotkey:setCommandShortcut` 활용
|
||||||
|
- **시스템 예약키 블랙리스트** (`src/shared/constants.ts`에 추가):
|
||||||
|
```typescript
|
||||||
|
export const RESERVED_HOTKEYS: Set<string> = new Set([
|
||||||
|
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S',
|
||||||
|
'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', 'Win+L', 'Win+D',
|
||||||
|
'Ctrl+Shift+Escape', 'PrintScreen',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Settings 핫키 섹션 UI
|
||||||
|
- **파일**: `src/renderer/components/SettingsModal.tsx`
|
||||||
|
- General 탭에 "Shortcuts" 섹션 추가 (기존 토글 설정 아래):
|
||||||
|
- 받아쓰기(Dictation) 핫키: 현재 바인딩 표시 + "변경" 버튼 → HotkeyRecordModal 열기
|
||||||
|
- 핸즈프리(Hands-free) 핫키: 동일 패턴
|
||||||
|
- Agent Mode 핫키: 더블프레스 기반이므로 "받아쓰기 키 2회 누름" 안내 텍스트만 표시
|
||||||
|
- 각 핫키 옆에 현재 바인딩을 `Chip` 컴포넌트로 표시 (예: `Right Alt`)
|
||||||
|
- 핫키 변경 성공 시 → `Chip` 갱신 + 토스트 알림
|
||||||
|
- IPC 호출: `hotkey:getDictationShortcut`, `hotkey:getHandsFreeShortcut`로 초기값 로드
|
||||||
|
|
||||||
|
### 8.3 음성 모드 토글 3개 (Settings 연동)
|
||||||
|
- **파일**: `src/renderer/components/SettingsModal.tsx` (General 탭)
|
||||||
|
- Speakly Settings의 음성 모드 섹션 패턴:
|
||||||
|
- **Dictation Mode**: `Switch` 토글 (기본 ON) + 핫키 Chip + "Hold-to-talk" 설명
|
||||||
|
- **Agent Mode**: `Switch` 토글 (기본 OFF) + "Dictation 키 더블프레스" 설명
|
||||||
|
- Dictation이 OFF이면 Agent도 비활성 (의존 관계 표시)
|
||||||
|
- **Hands-free (One-touch)**: `Switch` 토글 (기본 OFF) + 핫키 Chip + "Toggle on/off" 설명
|
||||||
|
- **IPC / ConfigService 연동**:
|
||||||
|
- `config:set({ key: 'dictationEnabled', value: boolean })`
|
||||||
|
- `config:set({ key: 'agentModeEnabled', value: boolean })`
|
||||||
|
- `config:set({ key: 'handsFreeEnabled', value: boolean })`
|
||||||
|
- AppConfig에 3개 필드 추가 (`src/shared/types.ts`)
|
||||||
|
- **HotkeyService 연동**: 모드가 비활성이면 해당 핫키 이벤트 무시
|
||||||
|
|
||||||
|
### 8.4 d3roPalette SSOT 확장
|
||||||
|
- **파일**: `src/renderer/theme.ts`
|
||||||
|
- 현재 `d3roPalette`에 누락된 시맨틱 토큰 추가:
|
||||||
|
```typescript
|
||||||
|
export const d3roPalette = {
|
||||||
|
// ...기존 유지...
|
||||||
|
interactive: {
|
||||||
|
/** 비활성 아이콘/라벨 (현재 '#77797c' 매직넘버로 사용 중) */
|
||||||
|
muted: '#77797c',
|
||||||
|
/** 호버 시 앰버로 전환할 때 사용 */
|
||||||
|
hoverAccent: '#f25b29',
|
||||||
|
/** 호버 시 위험 색상 */
|
||||||
|
hoverDanger: '#ef4444',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
/** 보조 정보 텍스트 (현재 '#5c2615' 매직넘버) */
|
||||||
|
dim: '#5c2615',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
bg: '#1e1f21',
|
||||||
|
activeBtn: '#242528',
|
||||||
|
activeBtnPressed: '#1a1a1c',
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
/** 물리 버튼 돌출 */
|
||||||
|
buttonRaised: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
|
||||||
|
/** 물리 버튼 눌림 */
|
||||||
|
buttonPressed: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||||
|
/** 인셋 패널 */
|
||||||
|
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
- 목표: 모든 컴포넌트에서 `'#77797c'`, `'#5c2615'`, `'#1e1f21'`, `'#242528'` 등 매직넘버를 `d3roPalette.*` 참조로 교체
|
||||||
|
|
||||||
|
### 8.5 매직넘버 ~40곳 제거
|
||||||
|
- **대상 파일** (grep 결과 기준):
|
||||||
|
- `src/renderer/components/AppLayout.tsx` (~15곳)
|
||||||
|
- `src/renderer/pages/HistoryPage.tsx` (~6곳)
|
||||||
|
- `src/renderer/pages/DictionaryPage.tsx` (~5곳)
|
||||||
|
- `src/renderer/pages/CommandsPage.tsx` (~5곳)
|
||||||
|
- `src/renderer/components/StatusBar.tsx` (~5곳)
|
||||||
|
- `src/renderer/components/ds/PhysicalButton.tsx` (~5곳)
|
||||||
|
- `src/renderer/components/ds/MetalCard.tsx` (~4곳)
|
||||||
|
- `src/renderer/components/ds/CrtDisplay.tsx` (~5곳)
|
||||||
|
- `src/renderer/components/ds/Led.tsx` (~4곳)
|
||||||
|
- **규칙**: `theme.ts`에서 `d3roPalette` / `d3roFontMono` export한 값만 사용
|
||||||
|
- **예외**: DS 컴포넌트 내부의 WebGL 셰이더 uniform 값은 매직넘버 허용 (JS→GLSL 변환 복잡)
|
||||||
|
- **검증**: `grep -rn '#[0-9a-fA-F]{6}' src/renderer/ --include='*.tsx'` 결과에서 `theme.ts` 외 0건
|
||||||
|
|
||||||
|
### 8.6 DashboardPage 기능 중심 재작성
|
||||||
|
- **파일**: `src/renderer/pages/DashboardPage.tsx`
|
||||||
|
- 현재 CRT+LED 인스트루먼트 패널 → **기능 중심 레이아웃**으로 교체:
|
||||||
|
1. **상단: 통계 카드 4개** (MetalCard 사용)
|
||||||
|
- 총 세션 수 / 오늘 세션 수 / 총 녹음 시간 / 연속 사용 일수
|
||||||
|
- PhosphorText로 값 표시, 각 카드에 Led 인디케이터
|
||||||
|
2. **중단: 최근 히스토리** (5~10건)
|
||||||
|
- 날짜 그룹핑 (오늘/어제/이번 주)
|
||||||
|
- 원본 텍스트 + 모드 Chip + 녹음 시간
|
||||||
|
- 클릭 시 히스토리 상세 or 복사
|
||||||
|
3. **하단: 시스템 상태 패널**
|
||||||
|
- Ollama 연결 상태 (Led green/red)
|
||||||
|
- STT 엔진 상태 (모델명 + ready/loading)
|
||||||
|
- 현재 핫키 바인딩 표시
|
||||||
|
- **IPC 호출**: `stats:getSummary`, `history:getAll({ page: 0, pageSize: 10 })`, `llm:getStatus`, `stt:getStatus`
|
||||||
|
- CRT 디스플레이는 제거하지 않고 **축소하여 시스템 상태 영역에 배치** (옵션)
|
||||||
|
|
||||||
|
### 8.7 HistoryPage 날짜 그룹핑
|
||||||
|
- **파일**: `src/renderer/pages/HistoryPage.tsx`
|
||||||
|
- 히스토리 목록을 날짜별 섹션으로 그룹핑:
|
||||||
|
- "오늘" / "어제" / "이번 주" / "이번 달" / "YYYY년 M월"
|
||||||
|
- 각 섹션 헤더: PhosphorText variant="label" + 항목 수
|
||||||
|
- 그룹핑 유틸 함수:
|
||||||
|
```typescript
|
||||||
|
function groupByDate(entries: HistoryEntry[]): Map<string, HistoryEntry[]> {
|
||||||
|
// createdAt 기준으로 그룹핑
|
||||||
|
// 오늘/어제/이번주/이번달/그외 구분
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 날짜 섹션 사이 시각적 구분선 (Divider + 앰버 강조)
|
||||||
|
- 스크롤 시 현재 날짜 섹션 sticky header (옵션)
|
||||||
|
|
||||||
|
### 8.8 recording-tip 색상 버그 수정
|
||||||
|
- **파일**: `src/renderer/popups/recording-tip/style.css`
|
||||||
|
- **문제**: 웨이브 바와 프로그레스 바 색상이 Speakly 기본값 `#1F5DF2` (파란색)으로 하드코딩됨
|
||||||
|
- **수정**: D3RO 앰버 악센트 `#f25b29`로 변경
|
||||||
|
```css
|
||||||
|
.wave-bar {
|
||||||
|
background: #f25b29; /* was: #1F5DF2 */
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
background: #f25b29; /* was: #1F5DF2 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **에러 아이콘**: 기존 `#D32F2F` → `#ef4444` (d3roPalette.tag.red)로 통일
|
||||||
|
|
||||||
|
### 8.9 AppConfig 타입 확장
|
||||||
|
- **파일**: `src/shared/types.ts`
|
||||||
|
- 음성 모드 토글 3개 필드 추가:
|
||||||
|
```typescript
|
||||||
|
export interface AppConfig {
|
||||||
|
// ...기존 필드 유지...
|
||||||
|
/** 받아쓰기 모드 활성화 (hold-to-talk) */
|
||||||
|
dictationEnabled: boolean;
|
||||||
|
/** Agent 모드 활성화 (더블프레스, dictation 의존) */
|
||||||
|
agentModeEnabled: boolean;
|
||||||
|
/** 핸즈프리 모드 활성화 (토글) */
|
||||||
|
handsFreeEnabled: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ConfigService 기본값 설정: `dictationEnabled: true`, `agentModeEnabled: false`, `handsFreeEnabled: false`
|
||||||
|
|
||||||
|
### 8.10 DS 컴포넌트 d3roPalette 참조 전환
|
||||||
|
- **대상 파일**:
|
||||||
|
- `src/renderer/components/ds/Led.tsx`: 색상 맵을 `d3roPalette.accent.*`, `d3roPalette.tag.*`에서 가져오기
|
||||||
|
- `src/renderer/components/ds/PhysicalButton.tsx`: `#242528`, `#1a1a1c`, `#f25b29`, `#77797c` → 팔레트 참조
|
||||||
|
- `src/renderer/components/ds/MetalCard.tsx`: `#1b1c1e`, `#242427`, `#2a2a2d` → 팔레트 참조
|
||||||
|
- `src/renderer/components/ds/CrtDisplay.tsx`: `#1a1a1c`, `#050605`, `#f25b29` → 팔레트 참조 (셰이더 uniform 제외)
|
||||||
|
- DS 컴포넌트가 직접 hex 리터럴을 갖지 않고, 반드시 `d3roPalette` 또는 `theme.palette`에서 읽도록 변경
|
||||||
|
- `import { d3roPalette } from '../../theme'` 패턴 통일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speakly RE 참조
|
||||||
|
- **Settings.js**: General 탭에 음성 모드 토글 3개 (Dictation/Agent/Hands-free), 각 모드별 핫키 Chip, 마이크 테스트
|
||||||
|
- **HotkeyRecordModal.js**: keydown 리스너 → 조합키 Chip 표시, 시스템 예약키 블랙리스트, platform-specific 키 라벨
|
||||||
|
- **CustomInstructionPage.js**: 커스텀 명령어별 핫키 바인딩 UI (재활용 패턴)
|
||||||
|
- **Dashboard.js**: 통계 카드 4개, 최근 히스토리 날짜 그룹핑 (오늘/어제/이전)
|
||||||
|
- **HotkeyConfig.js**: VK 코드 → 표시 문자열 매핑 테이블, 시스템 예약키 감지
|
||||||
|
|
||||||
|
## 완료 조건
|
||||||
|
- [ ] Settings에서 핫키 변경 가능 (HotkeyRecordModal 작동)
|
||||||
|
- [ ] 변경된 핫키로 녹음 → 전사 → 삽입 사이클 테스트 가능
|
||||||
|
- [ ] Settings에 음성 모드 토글 3개 표시 + 설정 저장/로드
|
||||||
|
- [ ] Dictation OFF → Agent 자동 비활성 연동 작동
|
||||||
|
- [ ] DashboardPage에 통계 + 최근 히스토리 + 시스템 상태 표시
|
||||||
|
- [ ] HistoryPage 날짜 그룹핑 (오늘/어제/이번 주 등)
|
||||||
|
- [ ] recording-tip 웨이브 바/프로그레스 바 색상이 앰버(`#f25b29`)
|
||||||
|
- [ ] `grep '#[0-9a-fA-F]{6}' src/renderer/**/*.tsx` 결과에서 `theme.ts` 외 매직넘버 0건
|
||||||
|
- [ ] `npm run typecheck` 통과
|
||||||
|
- [ ] 모든 DS 컴포넌트가 `d3roPalette` 참조 (hex 리터럴 직접 사용 없음)
|
||||||
472
docs/re-findings/speakly-settings-ui.md
Normal file
472
docs/re-findings/speakly-settings-ui.md
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
# Speakly Settings & Hotkey UI 분석
|
||||||
|
|
||||||
|
> Speakly의 Settings Modal 및 HotkeyRecordModal 리버스엔지니어링 결과.
|
||||||
|
> D3RO-VOICE Phase 8 구현 시 참조 자료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Settings Modal 구조
|
||||||
|
|
||||||
|
### 1.1 전체 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
Dialog (fullWidth, maxWidth='sm')
|
||||||
|
├── DialogTitle: "Settings" + 닫기(X) 아이콘
|
||||||
|
├── Tabs: [Account | General | About]
|
||||||
|
└── TabPanels
|
||||||
|
├── Account: 로그인 정보, 사용량, 구독 (D3RO 제거)
|
||||||
|
├── General: 핵심 설정 전체
|
||||||
|
└── About: 버전, 라이선스, 피드백 링크
|
||||||
|
```
|
||||||
|
|
||||||
|
D3RO-VOICE 적용: Account 탭 제거, General 탭만 유지 (또는 탭 분할 확장: General / Audio / STT / LLM).
|
||||||
|
|
||||||
|
### 1.2 General 탭 세부 섹션
|
||||||
|
|
||||||
|
General 탭은 상단부터 순서대로 다음 섹션으로 구성된다:
|
||||||
|
|
||||||
|
```
|
||||||
|
General Tab
|
||||||
|
├── 🎤 Voice Modes (음성 모드)
|
||||||
|
│ ├── Dictation Mode — Switch + Hotkey Chip
|
||||||
|
│ ├── Agent Mode — Switch + "Dictation 더블프레스" 설명
|
||||||
|
│ └── Hands-free (One-touch) — Switch + Hotkey Chip
|
||||||
|
│
|
||||||
|
├── 🔊 Audio
|
||||||
|
│ ├── Microphone — Select (디바이스 목록)
|
||||||
|
│ ├── Mic Test — Button + Level Meter
|
||||||
|
│ ├── Sound effects — Switch
|
||||||
|
│ └── Mute audio when dictating — Switch
|
||||||
|
│
|
||||||
|
├── 🌐 Language & Appearance
|
||||||
|
│ ├── UI Language — Select (ko/en)
|
||||||
|
│ ├── Theme — Select (light/dark/system)
|
||||||
|
│ └── Start minimized — Switch
|
||||||
|
│
|
||||||
|
└── ⚙️ System
|
||||||
|
├── Launch at startup — Switch
|
||||||
|
├── Close to tray — Switch
|
||||||
|
└── Reset all settings — Button (확인 다이얼로그)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 음성 모드 섹션 UI 패턴
|
||||||
|
|
||||||
|
각 모드는 동일한 행(row) 레이아웃을 따른다:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ [Switch] Dictation Mode [Right Alt] Chip │
|
||||||
|
│ Hold to talk – press and hold the shortcut key │
|
||||||
|
│ to start recording │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ [Switch] Agent Mode │
|
||||||
|
│ Double-press the dictation shortcut to activate │
|
||||||
|
│ (requires Dictation Mode enabled) │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ [Switch] Hands-free (One-touch) [Ctrl+\] Chip │
|
||||||
|
│ Toggle on/off – press once to start, again to stop │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**핵심 동작:**
|
||||||
|
- Dictation Switch OFF → Agent Mode Switch 자동 비활성 (disabled + 툴팁 "Dictation 필요")
|
||||||
|
- Hotkey Chip 클릭 → HotkeyRecordModal 열림
|
||||||
|
- Agent Mode에는 별도 핫키가 없음 (Dictation 핫키 더블프레스로 트리거)
|
||||||
|
|
||||||
|
### 1.4 마이크 선택 + 테스트 UI
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Microphone │
|
||||||
|
│ [Select: System Default (Realtek Audio)] │
|
||||||
|
│ │
|
||||||
|
│ [🎤 Test Microphone] ▓▓▓▓▓░░░░░ Level: 0.45 │
|
||||||
|
│ ← real-time RMS bar → │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 테스트 버튼 클릭 → `audio:testDevice` IPC (2초간 캡처)
|
||||||
|
- 캡처 중 실시간 RMS 레벨 바 표시 (100ms 간격 갱신)
|
||||||
|
- 완료 후 평균/피크 레벨 표시
|
||||||
|
- 오디오 없으면 경고: "마이크에서 소리가 감지되지 않습니다"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. HotkeyRecordModal 구현
|
||||||
|
|
||||||
|
### 2.1 Modal 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
Dialog (maxWidth='xs')
|
||||||
|
├── DialogTitle: "Record Shortcut"
|
||||||
|
├── DialogContent
|
||||||
|
│ ├── 안내 텍스트: "Press the key combination you want to use"
|
||||||
|
│ ├── 키 표시 영역: [Chip][Chip][+][Chip] (예: Ctrl + Shift + F5)
|
||||||
|
│ ├── 유효성 메시지 (성공/경고/에러)
|
||||||
|
│ └── 현재 바인딩 표시: "Current: Right Alt"
|
||||||
|
└── DialogActions
|
||||||
|
├── Cancel — 변경 없이 닫기
|
||||||
|
└── Save — 새 바인딩 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 키 감지 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Modal mount 시 리스너 등록
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const binding: HotkeyBinding = {
|
||||||
|
keyCode: e.keyCode, // deprecated지만 uiohook 호환용
|
||||||
|
ctrl: e.ctrlKey,
|
||||||
|
alt: e.altKey,
|
||||||
|
shift: e.shiftKey,
|
||||||
|
meta: e.metaKey,
|
||||||
|
displayLabel: buildDisplayLabel(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수정자 키만 눌린 경우 → 아직 완성 안 됨 (Chip은 표시하되 Save 비활성)
|
||||||
|
if (isModifierOnly(e)) {
|
||||||
|
setPendingBinding(binding);
|
||||||
|
setIsComplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 키 + 수정자 조합 → 완성
|
||||||
|
setPendingBinding(binding);
|
||||||
|
setIsComplete(true);
|
||||||
|
validate(binding);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 키 표시 라벨 매핑
|
||||||
|
|
||||||
|
Speakly는 platform-specific 키 라벨을 사용한다. Windows 기준:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const KEY_LABELS: Record<number, string> = {
|
||||||
|
// 수정자 키
|
||||||
|
0xA0: 'Left Shift', 0xA1: 'Right Shift',
|
||||||
|
0xA2: 'Left Ctrl', 0xA3: 'Right Ctrl',
|
||||||
|
0xA4: 'Left Alt', 0xA5: 'Right Alt',
|
||||||
|
0x5B: 'Left Win', 0x5C: 'Right Win',
|
||||||
|
|
||||||
|
// 기능 키
|
||||||
|
0x70: 'F1', 0x71: 'F2', 0x72: 'F3', 0x73: 'F4',
|
||||||
|
0x74: 'F5', 0x75: 'F6', 0x76: 'F7', 0x77: 'F8',
|
||||||
|
0x78: 'F9', 0x79: 'F10', 0x7A: 'F11', 0x7B: 'F12',
|
||||||
|
|
||||||
|
// 특수 키
|
||||||
|
0x1B: 'Escape', 0x09: 'Tab', 0x14: 'CapsLock',
|
||||||
|
0x20: 'Space', 0x0D: 'Enter', 0x08: 'Backspace',
|
||||||
|
0x2D: 'Insert', 0x2E: 'Delete', 0x24: 'Home',
|
||||||
|
0x23: 'End', 0x21: 'PageUp', 0x22: 'PageDown',
|
||||||
|
|
||||||
|
// 방향키
|
||||||
|
0x25: '←', 0x26: '↑', 0x27: '→', 0x28: '↓',
|
||||||
|
|
||||||
|
// 숫자패드
|
||||||
|
0x90: 'NumLock', 0x6F: 'Num/', 0x6A: 'Num*',
|
||||||
|
0x6D: 'Num-', 0x6B: 'Num+', 0x6E: 'Num.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDisplayLabel(e: KeyboardEvent): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.ctrlKey) parts.push('Ctrl');
|
||||||
|
if (e.altKey) parts.push('Alt');
|
||||||
|
if (e.shiftKey) parts.push('Shift');
|
||||||
|
if (e.metaKey) parts.push('Win');
|
||||||
|
|
||||||
|
// 수정자 키 자체는 중복 추가하지 않음
|
||||||
|
if (!isModifierOnly(e)) {
|
||||||
|
const label = KEY_LABELS[e.keyCode] ?? e.key?.toUpperCase() ?? `Key${e.keyCode}`;
|
||||||
|
parts.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' + ');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 유효성 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
message: string;
|
||||||
|
severity: 'success' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(binding: HotkeyBinding): ValidationResult {
|
||||||
|
const label = binding.displayLabel;
|
||||||
|
|
||||||
|
// 1. 시스템 예약키 체크
|
||||||
|
if (RESERVED_HOTKEYS.has(label)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `${label} is reserved by the system`,
|
||||||
|
severity: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 다른 모드와 중복 체크
|
||||||
|
const existing = findConflict(binding);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `Already used by "${existing.modeName}"`,
|
||||||
|
severity: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 단일 수정자 키 경고 (Right Alt 등은 허용하지만 경고)
|
||||||
|
if (isModifierOnly({ keyCode: binding.keyCode })) {
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
message: 'Modifier-only shortcuts may conflict with other apps',
|
||||||
|
severity: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: 'Shortcut available', severity: 'success' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 MUI Chip 표시
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 키 조합을 개별 Chip으로 표시
|
||||||
|
function KeyChips({ label }: { label: string }) {
|
||||||
|
const parts = label.split(' + ');
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<React.Fragment key={part}>
|
||||||
|
{i > 0 && <Typography sx={{ color: 'text.secondary' }}>+</Typography>}
|
||||||
|
<Chip
|
||||||
|
label={part}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 음성 모드 3가지 상세 분석
|
||||||
|
|
||||||
|
### 3.1 Dictation (받아쓰기)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 트리거 | 핫키 press (hold) |
|
||||||
|
| 녹음 방식 | hold-to-talk: 키 누르는 동안 녹음, 놓으면 종료 |
|
||||||
|
| 기본 핫키 | Right Alt (0xA5) |
|
||||||
|
| 더블프레스 | Agent Mode 트리거로 사용 (300ms 이내) |
|
||||||
|
| 최소 시간 | 700ms 이하 → accidentalPress 취소 |
|
||||||
|
| 후처리 | 기본: 원본 삽입, 설정에 따라 polish/translate |
|
||||||
|
|
||||||
|
```
|
||||||
|
키 press ─→ startRecording ─→ [holding] ─→ 키 release ─→ stopRecording
|
||||||
|
700ms↑ ↓
|
||||||
|
accidentalPress? STT → [LLM] → insert
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Agent Mode (에이전트)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 트리거 | Dictation 핫키 더블프레스 (300ms 이내) |
|
||||||
|
| 녹음 방식 | Dictation과 동일 (hold-to-talk) |
|
||||||
|
| 의존성 | Dictation Mode가 활성이어야 함 |
|
||||||
|
| 후처리 | 항상 LLM 처리 (커스텀 명령어 또는 기본 polish) |
|
||||||
|
| 별도 핫키 | 없음 (Dictation 핫키의 더블프레스) |
|
||||||
|
|
||||||
|
```
|
||||||
|
키 press ─→ 300ms 이내 재press ─→ "Agent Mode" 활성
|
||||||
|
│
|
||||||
|
hold-to-talk (녹음)
|
||||||
|
│
|
||||||
|
release → STT → LLM(agent) → insert
|
||||||
|
```
|
||||||
|
|
||||||
|
**HotkeyService 내부 처리:**
|
||||||
|
```typescript
|
||||||
|
// 더블프레스 감지
|
||||||
|
private onKeyDown(hotkeyId: string, config: HotkeyConfig): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastPress = this.lastPressTime.get(hotkeyId) ?? 0;
|
||||||
|
|
||||||
|
if (config.doublePressEnabled && (now - lastPress) < TIMING.DOUBLE_PRESS_DURATION) {
|
||||||
|
// 더블프레스 → Agent Mode
|
||||||
|
this.emit('double-press', { config, intervalMs: now - lastPress, timestamp: now });
|
||||||
|
this.lastPressTime.delete(hotkeyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPressTime.set(hotkeyId, now);
|
||||||
|
this.emit('hotkey-pressed', { config, timestamp: now });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Hands-free / One-touch (원터치)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 트리거 | 별도 핫키 press (toggle) |
|
||||||
|
| 녹음 방식 | 토글: 1회 누르면 녹음 시작, 다시 누르면 종료 |
|
||||||
|
| 기본 핫키 | 미설정 (사용자가 직접 바인딩) |
|
||||||
|
| 의존성 | 독립적 (Dictation과 무관) |
|
||||||
|
| 후처리 | 설정에 따라 분기 |
|
||||||
|
| VAD | 선택적 (침묵 감지 시 자동 종료 옵션) |
|
||||||
|
|
||||||
|
```
|
||||||
|
키 press(1회) ─→ startRecording ─→ [recording...] ─→ 키 press(2회) ─→ stopRecording
|
||||||
|
↓
|
||||||
|
STT → [LLM] → insert
|
||||||
|
```
|
||||||
|
|
||||||
|
**Speakly 구현 특징:**
|
||||||
|
- `holdMode: false`로 HotkeyConfig 등록
|
||||||
|
- 내부 `isRecording` 상태 토글
|
||||||
|
- ESC 키로 녹음 취소 (별도 리스너)
|
||||||
|
- UI에 녹음 중 상태 표시 (RecordingTip 지속 표시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. D3RO-VOICE 적용 방안
|
||||||
|
|
||||||
|
### 4.1 HotkeyRecordModal 구현 계획
|
||||||
|
|
||||||
|
Speakly의 HotkeyRecordModal을 D3RO-VOICE의 IPC 채널에 맞게 재구현한다:
|
||||||
|
|
||||||
|
```
|
||||||
|
렌더러 (React) 메인 프로세스
|
||||||
|
│ │
|
||||||
|
│── hotkey:getDictationShortcut ────────►│
|
||||||
|
│◄── HotkeyBinding ────────────────────│
|
||||||
|
│ │
|
||||||
|
│ [사용자가 키 조합 누름] │
|
||||||
|
│ → 로컬 keydown 리스너로 감지 │
|
||||||
|
│ → validate() 수행 │
|
||||||
|
│ → UI에 Chip 표시 │
|
||||||
|
│ │
|
||||||
|
│── hotkey:setDictationShortcut ────────►│
|
||||||
|
│ { binding: HotkeyBinding } │── HotkeyService.updateConfig()
|
||||||
|
│◄── { success: true } ─────────────────│── uiohook 핫키 재등록
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 음성 모드 Settings UI 구현
|
||||||
|
|
||||||
|
SettingsModal의 General 탭에 음성 모드 섹션을 추가한다:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Settings General 탭 — 음성 모드 섹션
|
||||||
|
<Typography variant="caption" sx={{ mb: 1 }}>VOICE MODES</Typography>
|
||||||
|
|
||||||
|
{/* Dictation */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box>
|
||||||
|
<Switch checked={dictationEnabled} onChange={...} />
|
||||||
|
<Typography>Dictation Mode</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Hold to talk — press and hold the shortcut key
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={dictationHotkey.displayLabel}
|
||||||
|
onClick={() => openHotkeyRecord('dictation')}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Agent */}
|
||||||
|
<Box>
|
||||||
|
<Switch
|
||||||
|
checked={agentEnabled}
|
||||||
|
onChange={...}
|
||||||
|
disabled={!dictationEnabled} // 의존성
|
||||||
|
/>
|
||||||
|
<Typography>Agent Mode</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Double-press the dictation shortcut
|
||||||
|
{!dictationEnabled && ' (requires Dictation Mode)'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hands-free */}
|
||||||
|
<Box>
|
||||||
|
<Switch checked={handsFreeEnabled} onChange={...} />
|
||||||
|
<Typography>Hands-free (One-touch)</Typography>
|
||||||
|
<Chip
|
||||||
|
label={handsFreeHotkey?.displayLabel ?? 'Not set'}
|
||||||
|
onClick={() => openHotkeyRecord('handsFree')}
|
||||||
|
clickable
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 IPC 채널 매핑
|
||||||
|
|
||||||
|
| Speakly 패턴 | D3RO-VOICE IPC 채널 | 비고 |
|
||||||
|
|-------------|---------------------|------|
|
||||||
|
| getHotkeyConfig('dictation') | `hotkey:getDictationShortcut` | 기존 |
|
||||||
|
| setHotkeyConfig('dictation', binding) | `hotkey:setDictationShortcut` | 기존 |
|
||||||
|
| getHotkeyConfig('handsFree') | `hotkey:getHandsFreeShortcut` | 기존 |
|
||||||
|
| setHotkeyConfig('handsFree', binding) | `hotkey:setHandsFreeShortcut` | 기존 |
|
||||||
|
| isVoiceModeEnabled('dictation') | `config:get({ key: 'dictationEnabled' })` | 신규 |
|
||||||
|
| setVoiceModeEnabled('dictation', bool) | `config:set({ key: 'dictationEnabled', value })` | 신규 |
|
||||||
|
| isVoiceModeEnabled('agent') | `config:get({ key: 'agentModeEnabled' })` | 신규 |
|
||||||
|
| isVoiceModeEnabled('handsFree') | `config:get({ key: 'handsFreeEnabled' })` | 신규 |
|
||||||
|
|
||||||
|
### 4.4 키코드 호환성
|
||||||
|
|
||||||
|
D3RO-VOICE는 두 가지 키코드 시스템을 사용한다:
|
||||||
|
|
||||||
|
| 컨텍스트 | 키코드 시스템 | 예시 (Right Alt) |
|
||||||
|
|---------|-------------|-----------------|
|
||||||
|
| ConfigService (저장) | Windows VK 코드 | `0xA5` (165) |
|
||||||
|
| uiohook-napi (런타임) | uiohook 키코드 | `UiohookKey.AltRight` (56) |
|
||||||
|
| KeyboardEvent (렌더러) | DOM keyCode | `18` (Alt) |
|
||||||
|
|
||||||
|
HotkeyRecordModal에서 DOM keyCode를 수신하여 Windows VK 코드로 변환 후 저장한다.
|
||||||
|
HotkeyService에서는 VK 코드를 uiohook 키코드로 변환하여 매칭한다 (`vkToUiohook` 맵 기존 구현 참조).
|
||||||
|
|
||||||
|
### 4.5 시스템 예약키 블랙리스트
|
||||||
|
|
||||||
|
D3RO-VOICE에서 차단할 키 조합:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Windows 시스템 예약키
|
||||||
|
const RESERVED = [
|
||||||
|
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+Y', // 편집
|
||||||
|
'Ctrl+A', 'Ctrl+S', 'Ctrl+P', 'Ctrl+F', // 일반
|
||||||
|
'Alt+F4', 'Alt+Tab', 'Alt+Escape', // 윈도우 관리
|
||||||
|
'Ctrl+Alt+Delete', 'Ctrl+Shift+Escape', // 시스템
|
||||||
|
'Win+L', 'Win+D', 'Win+E', 'Win+R', 'Win+Tab', // Win 단축키
|
||||||
|
'PrintScreen', 'Ctrl+PrintScreen', // 캡처
|
||||||
|
'F1', // 도움말
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*끝.*
|
||||||
|
|
@ -15,6 +15,7 @@ import { DictionaryPage } from '../pages/DictionaryPage'
|
||||||
import { CommandsPage } from '../pages/CommandsPage'
|
import { CommandsPage } from '../pages/CommandsPage'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
import { StatusBar } from './StatusBar'
|
import { StatusBar } from './StatusBar'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
|
||||||
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
|
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
|
||||||
|
|
||||||
|
|
@ -30,15 +31,15 @@ export function AppLayout(): React.ReactElement {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
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={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 72,
|
width: 72,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
bgcolor: '#1e1f21',
|
bgcolor: d3roPalette.bg.sidebar,
|
||||||
borderRight: '1px solid rgba(255,255,255,0.04)',
|
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -52,9 +53,9 @@ export function AppLayout(): React.ReactElement {
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
letterSpacing: '1.5px',
|
letterSpacing: '1.5px',
|
||||||
color: '#5c2615',
|
color: d3roPalette.text.dimLabel,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -79,11 +80,11 @@ export function AppLayout(): React.ReactElement {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
bgcolor: isActive ? '#242528' : 'transparent',
|
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
|
||||||
boxShadow: isActive
|
boxShadow: isActive
|
||||||
? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000'
|
? '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)',
|
: '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',
|
transition: 'all 0.05s linear',
|
||||||
transform: isActive ? 'translateY(1px)' : 'none',
|
transform: isActive ? 'translateY(1px)' : 'none',
|
||||||
'&:active': {
|
'&:active': {
|
||||||
|
|
@ -91,7 +92,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||||
},
|
},
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: isActive ? '#f25b29' : '#aaa',
|
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -99,7 +100,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '7px',
|
fontSize: '7px',
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
}}
|
}}
|
||||||
|
|
@ -126,14 +127,14 @@ export function AppLayout(): React.ReactElement {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
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)',
|
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',
|
transition: 'all 0.05s linear',
|
||||||
'&:active': {
|
'&:active': {
|
||||||
transform: 'translateY(2px)',
|
transform: 'translateY(2px)',
|
||||||
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
|
||||||
},
|
},
|
||||||
'&:hover': { color: '#aaa' },
|
'&:hover': { color: d3roPalette.text.hover },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||||
|
|
@ -147,9 +148,9 @@ export function AppLayout(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
bgcolor: '#19191b',
|
bgcolor: d3roPalette.bg.app,
|
||||||
// 미묘한 방사형 비네팅 (시안 A 배경)
|
// 미묘한 방사형 비네팅 (시안 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 />}
|
{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
|
// 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
|
@ -18,11 +18,18 @@ import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Divider,
|
Divider,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
FormControl
|
FormControl,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
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 { d3roPalette } from '../theme'
|
||||||
import type { ThemeMode, AppConfig } from '@shared/types'
|
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||||
|
import type { ThemeMode, AppConfig, HotkeyBinding } from '@shared/types'
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -40,183 +47,454 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
|
||||||
return <Box sx={{ pt: 2 }}>{children}</Box>
|
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 {
|
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
window.electronAPI.config
|
|
||||||
.getAll()
|
Promise.all([
|
||||||
.then((result) => {
|
window.electronAPI.config.getAll(),
|
||||||
if (result.success) {
|
window.electronAPI.hotkey.getDictationShortcut(),
|
||||||
setConfig(result.data)
|
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))
|
.finally(() => setLoading(false))
|
||||||
}, [open])
|
}, [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 }))
|
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||||
window.electronAPI.config.set({ 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>
|
if (loading) return <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
<>
|
||||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
Settings
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
설정
|
||||||
<CloseIcon />
|
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||||
</IconButton>
|
<CloseIcon />
|
||||||
</DialogTitle>
|
</IconButton>
|
||||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
</DialogTitle>
|
||||||
<DialogContent>
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
<DialogContent>
|
||||||
<Tab label="General" />
|
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
|
||||||
<Tab label="Audio" />
|
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 16 }} />} iconPosition="start" />
|
||||||
<Tab label="STT" />
|
<Tab label="오디오" />
|
||||||
<Tab label="LLM" />
|
<Tab label="STT" />
|
||||||
</Tabs>
|
<Tab label="LLM" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* General */}
|
{/* ── 일반 탭 ─────────────────────────────── */}
|
||||||
<TabPanel value={activeTab} index={0}>
|
<TabPanel value={activeTab} index={0}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
<FormControl size="small">
|
{/* 단축키 섹션 */}
|
||||||
<InputLabel>Theme</InputLabel>
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
<Select
|
단축키
|
||||||
label="Theme"
|
</Typography>
|
||||||
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>
|
|
||||||
|
|
||||||
<FormControl size="small">
|
<Stack spacing={1.5}>
|
||||||
<InputLabel>Language</InputLabel>
|
{/* 받아쓰기 모드 */}
|
||||||
<Select
|
<VoiceModeCard
|
||||||
label="Language"
|
title="받아쓰기"
|
||||||
value={config.language ?? 'ko'}
|
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
|
||||||
onChange={(e) => updateConfig('language', e.target.value)}
|
enabled={dictationEnabled}
|
||||||
>
|
onToggle={handleDictationToggle}
|
||||||
<MenuItem value="ko">한국어</MenuItem>
|
>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<HotkeyDisplay
|
||||||
</Select>
|
binding={dictationBinding}
|
||||||
</FormControl>
|
onEdit={() => openHotkeyModal('dictation')}
|
||||||
|
label="키"
|
||||||
|
/>
|
||||||
|
</VoiceModeCard>
|
||||||
|
|
||||||
<FormControlLabel
|
{/* Agent 모드 (더블프레스) */}
|
||||||
control={
|
<VoiceModeCard
|
||||||
<Switch
|
title="Agent 모드"
|
||||||
checked={config.closeToTray ?? true}
|
description={
|
||||||
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
|
dictationBinding
|
||||||
|
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
|
||||||
|
: '받아쓰기 핫키를 먼저 설정하세요.'
|
||||||
|
}
|
||||||
|
enabled={dictationEnabled}
|
||||||
|
onToggle={handleDictationToggle}
|
||||||
|
disabled={!dictationEnabled}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
label="Close to tray"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
{/* 원터치 모드 (핸즈프리) */}
|
||||||
control={
|
<VoiceModeCard
|
||||||
<Switch
|
title="원터치 모드"
|
||||||
checked={config.autoInsert ?? true}
|
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
|
||||||
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
|
enabled={handsFreeEnabled}
|
||||||
/>
|
onToggle={handleHandsFreeToggle}
|
||||||
}
|
disabled={!dictationEnabled}
|
||||||
label="Auto-insert text after transcription"
|
>
|
||||||
/>
|
<HotkeyDisplay
|
||||||
|
binding={handsFreeBinding}
|
||||||
|
onEdit={() => openHotkeyModal('handsFree')}
|
||||||
|
label="키"
|
||||||
|
/>
|
||||||
|
</VoiceModeCard>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<FormControlLabel
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.soundEnabled ?? true}
|
|
||||||
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Sound effects"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* Audio */}
|
{/* UI 설정 */}
|
||||||
<TabPanel value={activeTab} index={1}>
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
인터페이스
|
||||||
<Typography variant="body2" color="text.secondary">
|
</Typography>
|
||||||
Microphone device selection will be available in a future update.
|
|
||||||
Currently using the system default microphone.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
<InputLabel>Insert Method</InputLabel>
|
<InputLabel>테마</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
label="Insert Method"
|
label="테마"
|
||||||
value={config.insertMethod ?? 'clipboard'}
|
value={config.theme ?? 'auto'}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
|
||||||
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
|
>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
label="트레이로 최소화"
|
||||||
<MenuItem value="clipboard">Clipboard (Ctrl+V)</MenuItem>
|
/>
|
||||||
<MenuItem value="keyboard">Keyboard typing</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* STT */}
|
<FormControlLabel
|
||||||
<TabPanel value={activeTab} index={2}>
|
control={
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Switch
|
||||||
<FormControl size="small">
|
checked={config.autoLaunch ?? false}
|
||||||
<InputLabel>Whisper Model</InputLabel>
|
onChange={(e) => updateConfig('autoLaunch', e.target.checked)}
|
||||||
<Select
|
/>
|
||||||
label="Whisper Model"
|
}
|
||||||
value={config.sttModelId ?? 'base'}
|
label="시스템 시작 시 자동 실행"
|
||||||
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>
|
|
||||||
|
|
||||||
<FormControl size="small">
|
<FormControlLabel
|
||||||
<InputLabel>Language</InputLabel>
|
control={
|
||||||
<Select
|
<Switch
|
||||||
label="Language"
|
checked={config.autoInsert ?? true}
|
||||||
value={config.sttLanguage ?? 'auto'}
|
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
|
||||||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
/>
|
||||||
>
|
}
|
||||||
<MenuItem value="auto">Auto-detect</MenuItem>
|
label="전사 후 자동 텍스트 삽입"
|
||||||
<MenuItem value="ko">한국어</MenuItem>
|
/>
|
||||||
<MenuItem value="en">English</MenuItem>
|
|
||||||
<MenuItem value="ja">日本語</MenuItem>
|
|
||||||
<MenuItem value="zh">中文</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{/* LLM */}
|
<FormControlLabel
|
||||||
<TabPanel value={activeTab} index={3}>
|
control={
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Switch
|
||||||
<TextField
|
checked={config.soundEnabled ?? true}
|
||||||
label="Ollama Server URL"
|
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
|
||||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
/>
|
||||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
}
|
||||||
fullWidth
|
label="효과음"
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
{/* ── 오디오 탭 ────────────────────────────── */}
|
||||||
LLM model selection will be available after Ollama integration (Phase 4).
|
<TabPanel value={activeTab} index={1}>
|
||||||
</Typography>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
</Box>
|
<Typography variant="body2" color="text.secondary">
|
||||||
</TabPanel>
|
현재 시스템 기본 마이크를 사용합니다.
|
||||||
</DialogContent>
|
마이크 선택 및 테스트 기능은 추후 업데이트에서 제공됩니다.
|
||||||
</Dialog>
|
</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 { useState, useEffect } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { Led } from './ds'
|
import { Led } from './ds'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { LLMStatus } from '@shared/types'
|
import type { LLMStatus } from '@shared/types'
|
||||||
|
|
||||||
const MONO = 'MONO_PLACEHOLDER'
|
|
||||||
|
|
||||||
export function StatusBar(): React.ReactElement {
|
export function StatusBar(): React.ReactElement {
|
||||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||||
|
|
||||||
|
|
@ -36,27 +35,27 @@ export function StatusBar(): React.ReactElement {
|
||||||
gap: 2,
|
gap: 2,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
borderTop: '1px solid rgba(255,255,255,0.04)',
|
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
bgcolor: '#1e1f21',
|
bgcolor: d3roPalette.bg.sidebar,
|
||||||
minHeight: 28,
|
minHeight: 28,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<Led color={connected ? 'green' : 'red'} size={6} />
|
<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'}
|
{connected ? 'OLLAMA' : 'OFFLINE'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{llmStatus?.activeModel && (
|
{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()}
|
{llmStatus.activeModel.toUpperCase()}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flex: 1 }} />
|
<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
|
PRECISION DATA LINK
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import { useRef, useEffect, useCallback } from 'react'
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||||
|
|
||||||
// ── WebGL 유틸 ─────────────────────────────────────────
|
// ── WebGL 유틸 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -218,7 +219,7 @@ export function CrtDisplay({
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height,
|
height,
|
||||||
bgcolor: '#1a1a1c',
|
bgcolor: d3roPalette.bg.crtBezel,
|
||||||
borderRadius: '8px',
|
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)',
|
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',
|
overflow: 'hidden',
|
||||||
|
|
@ -230,7 +231,7 @@ export function CrtDisplay({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: '2px',
|
inset: '2px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
bgcolor: '#050605',
|
bgcolor: d3roPalette.bg.crtGlass,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)',
|
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)',
|
||||||
}}
|
}}
|
||||||
|
|
@ -261,10 +262,10 @@ export function CrtDisplay({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
color: '#f25b29',
|
color: d3roPalette.accent.amber,
|
||||||
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
|
||||||
|
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||||
|
|
||||||
interface InstrumentPanelProps {
|
interface InstrumentPanelProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
@ -23,11 +24,11 @@ export function InstrumentPanel({
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
bgcolor: '#242528',
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
borderRadius: '24px',
|
borderRadius: '24px',
|
||||||
p: 3,
|
p: 3,
|
||||||
boxShadow:
|
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로 시뮬레이션
|
// 메탈 노이즈는 CSS로 시뮬레이션
|
||||||
'&::before': {
|
'&::before': {
|
||||||
content: '""',
|
content: '""',
|
||||||
|
|
@ -62,10 +63,10 @@ function Engraving({ children, sx }: { children: string; sx: Record<string, unkn
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
letterSpacing: '1.5px',
|
letterSpacing: '1.5px',
|
||||||
color: '#1a1a1c',
|
color: d3roPalette.text.engraving,
|
||||||
textShadow: '0 1px 0 rgba(255,255,255,0.08)',
|
textShadow: '0 1px 0 rgba(255,255,255,0.08)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
...sx,
|
...sx,
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@
|
||||||
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
|
||||||
|
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
|
import { d3roPalette } from '../../theme'
|
||||||
|
|
||||||
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
|
||||||
|
|
||||||
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
|
||||||
amber: { bg: '#f25b29', glow: 'rgba(242, 91, 41, 0.6)' },
|
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
|
||||||
green: { bg: '#22c55e', glow: 'rgba(34, 197, 94, 0.6)' },
|
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
|
||||||
red: { bg: '#ef4444', glow: 'rgba(239, 68, 68, 0.6)' },
|
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
|
||||||
orange: { bg: '#f59e0b', glow: 'rgba(245, 158, 11, 0.6)' },
|
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
|
||||||
off: { bg: '#111', glow: 'transparent' },
|
off: { bg: d3roPalette.led.off, glow: 'transparent' },
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LedProps {
|
interface LedProps {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
|
||||||
|
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
|
import { d3roPalette } from '../../theme'
|
||||||
|
|
||||||
interface MetalCardProps {
|
interface MetalCardProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|
@ -12,15 +13,15 @@ export function MetalCard({ children, inset = false }: MetalCardProps): React.Re
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: inset ? '#1b1c1e' : '#242427',
|
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
||||||
borderRadius: inset ? '12px' : '22px',
|
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
|
boxShadow: inset
|
||||||
? 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)'
|
? '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)',
|
: '0 8px 30px rgba(0,0,0,0.3)',
|
||||||
p: inset ? '6px' : 3,
|
p: inset ? '6px' : 3,
|
||||||
transition: 'background-color 0.2s ease',
|
transition: 'background-color 0.2s ease',
|
||||||
'&:hover': inset ? {} : { bgcolor: '#2a2a2d' },
|
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
|
||||||
|
|
||||||
import { Typography, type TypographyProps } from '@mui/material'
|
import { Typography, type TypographyProps } from '@mui/material'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||||
|
|
||||||
type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim'
|
type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim'
|
||||||
|
|
||||||
const VARIANTS: Record<PhosphorVariant, { fontSize: string; color: string; glow: string; fontWeight: number }> = {
|
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 },
|
hero: { fontSize: '42px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
|
||||||
value: { fontSize: '20px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
|
value: { fontSize: '20px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
|
||||||
label: { fontSize: '10px', color: '#5c2615', glow: 'none', fontWeight: 700 },
|
label: { fontSize: '10px', color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: 700 },
|
||||||
dim: { fontSize: '10px', color: '#77797c', glow: 'none', fontWeight: 400 },
|
dim: { fontSize: '10px', color: d3roPalette.text.inactive, glow: 'none', fontWeight: 400 },
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
|
||||||
|
|
@ -23,7 +24,7 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
|
||||||
<Typography
|
<Typography
|
||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
fontSize: v.fontSize,
|
fontSize: v.fontSize,
|
||||||
fontWeight: v.fontWeight,
|
fontWeight: v.fontWeight,
|
||||||
color: v.color,
|
color: v.color,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
|
||||||
|
|
||||||
import { Button, type ButtonProps } from '@mui/material'
|
import { Button, type ButtonProps } from '@mui/material'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||||
|
|
||||||
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
|
|
@ -13,11 +14,11 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
|
||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
height: 44,
|
height: 44,
|
||||||
bgcolor: selected ? '#1a1a1c' : '#242528',
|
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: selected ? '#f25b29' : '#77797c',
|
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
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)',
|
boxShadow: '0 1px 2px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)',
|
||||||
},
|
},
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: selected ? '#1a1a1c' : '#2a2b2e',
|
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
|
||||||
},
|
},
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||||
|
import { d3roPalette } from '../theme'
|
||||||
import type { IPCResult } from '@shared/errors'
|
import type { IPCResult } from '@shared/errors'
|
||||||
|
|
||||||
interface CustomInstruction {
|
interface CustomInstruction {
|
||||||
|
|
@ -42,7 +43,7 @@ export function CommandsPage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
LLM INSTRUCTIONS — {instructions.length} COMMANDS
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button>
|
||||||
|
|
@ -64,16 +65,16 @@ export function CommandsPage(): React.ReactElement {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||||
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{inst.name}</Box>
|
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{inst.name}</Box>
|
||||||
<Box sx={{ fontSize: '11px', color: '#77797c', mt: 0.25 }}>{inst.description}</Box>
|
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>{inst.description}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
|
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||||
<EditIcon sx={{ fontSize: 16 }} />
|
<EditIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{!inst.isBuiltin && (
|
{!inst.isBuiltin && (
|
||||||
<IconButton size="small" sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
<IconButton size="small" sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,182 +1,354 @@
|
||||||
// src/renderer/pages/DashboardPage.tsx
|
// src/renderer/pages/DashboardPage.tsx
|
||||||
// 시안 A 인스트루먼트 패널: CRT 디스플레이 + LED 클러스터 + 물리 버튼 + 통계
|
// 기능 중심 대시보드: 통계 카드 + 서비스 상태 + 최근 히스토리 (정밀기기 비주얼)
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography, IconButton, Tooltip } from '@mui/material'
|
||||||
import { CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText } from '../components/ds'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import type { StatsSummary } from '@shared/types'
|
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText } from '../components/ds'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
|
import type { StatsSummary, HistoryEntry, HistoryPage as HistoryPageData, HotkeyBinding } from '@shared/types'
|
||||||
|
|
||||||
function formatTime(ms: number): string {
|
// ── 유틸 ──────────────────────────────────────────────
|
||||||
const totalSec = Math.round(ms / 1000)
|
|
||||||
const h = Math.floor(totalSec / 3600)
|
function formatRecordingTime(ms: number): string {
|
||||||
const m = Math.floor((totalSec % 3600) / 60)
|
const totalMin = Math.round(ms / 60000)
|
||||||
const s = totalSec % 60
|
if (totalMin >= 60) {
|
||||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
const h = Math.floor(totalMin / 60)
|
||||||
|
const m = totalMin % 60
|
||||||
|
return `${h}:${m.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${totalMin}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecordingTimeUnit(ms: number): string {
|
||||||
|
const totalMin = Math.round(ms / 60000)
|
||||||
|
return totalMin >= 60 ? '시간' : '분'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
|
||||||
|
return `${n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.round(sec % 60)
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayMode = 'stats' | 'voice' | 'sys'
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateLabel(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (d.getTime() === today.getTime()) return 'TODAY'
|
||||||
|
if (d.getTime() === yesterday.getTime()) return 'YESTERDAY'
|
||||||
|
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateKey(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 통계 카드 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit?: string
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<MetalCard>
|
||||||
|
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||||
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||||
|
{label}
|
||||||
|
</PhosphorText>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
|
||||||
|
<PhosphorText variant="hero" sx={{ fontSize: '32px' }}>
|
||||||
|
{value}
|
||||||
|
</PhosphorText>
|
||||||
|
{unit && (
|
||||||
|
<PhosphorText variant="dim" sx={{ fontSize: '12px' }}>
|
||||||
|
{unit}
|
||||||
|
</PhosphorText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</MetalCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메인 컴포넌트 ─────────────────────────────────────
|
||||||
|
|
||||||
export function DashboardPage(): React.ReactElement {
|
export function DashboardPage(): React.ReactElement {
|
||||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
const [ollamaConnected, setOllamaConnected] = useState(false)
|
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('stats')
|
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||||
const [glitchTrigger, setGlitchTrigger] = useState(0)
|
|
||||||
|
|
||||||
const loadStats = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
window.electronAPI.stats.getSummary().then((result) => {
|
window.electronAPI.stats.getSummary().then((r) => {
|
||||||
if (result.success) setStats(result.data)
|
if (r.success) setStats(r.data)
|
||||||
})
|
})
|
||||||
window.electronAPI.llm.getStatus().then((result) => {
|
window.electronAPI.llm.getStatus().then((r) => {
|
||||||
if (result.success) setOllamaConnected(result.data.connectionState === 'connected')
|
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
|
||||||
|
})
|
||||||
|
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
|
||||||
|
if (r.success) setHistory(r.data.entries)
|
||||||
|
})
|
||||||
|
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
||||||
|
if (r.success && r.data) setDictationBinding(r.data)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats()
|
loadData()
|
||||||
const interval = setInterval(loadStats, 30000)
|
const interval = setInterval(loadData, 30000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [loadStats])
|
}, [loadData])
|
||||||
|
|
||||||
const switchMode = (mode: DisplayMode) => {
|
// 날짜별 그룹핑
|
||||||
setDisplayMode(mode)
|
const groupedHistory = useMemo(() => {
|
||||||
setGlitchTrigger((prev) => prev + 1)
|
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
|
||||||
}
|
for (const entry of history) {
|
||||||
|
const key = getDateKey(entry.createdAt)
|
||||||
|
if (!groups[key]) {
|
||||||
|
groups[key] = { label: getDateLabel(entry.createdAt), entries: [] }
|
||||||
|
}
|
||||||
|
groups[key].entries.push(entry)
|
||||||
|
}
|
||||||
|
return Object.values(groups)
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
// 서비스 상태 목록
|
||||||
|
const services = [
|
||||||
|
{ name: 'STT ENGINE', status: 'READY', ok: true },
|
||||||
|
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' : 'OFFLINE', ok: ollamaConnected },
|
||||||
|
{ name: 'HOTKEY HOOK', status: 'ACTIVE', ok: true },
|
||||||
|
{ name: 'AUDIO INPUT', status: 'STANDBY', ok: true },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100%', p: 3 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
|
||||||
|
{/* ── 1. HERO 영역 ──────────────────────────── */}
|
||||||
<InstrumentPanel
|
<InstrumentPanel
|
||||||
engravingLeft="D3RO-VOICE SYS."
|
engravingLeft="D3RO-VOICE"
|
||||||
engravingRight="MOD-01 / TERMINAL"
|
engravingRight="v1.0.0"
|
||||||
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: 3, minHeight: 320 }}>
|
<Box sx={{ py: 1 }}>
|
||||||
{/* ── 좌측: CRT 디스플레이 ─────────────────── */}
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
<CrtDisplay
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
amplitude={displayMode === 'voice' ? 0.4 : 0.1}
|
<Led color="amber" pulse size={8} />
|
||||||
frequency={displayMode === 'voice' ? 15 : 8}
|
<PhosphorText variant="value" sx={{ fontSize: '16px' }}>
|
||||||
glitchTrigger={glitchTrigger}
|
타이핑 없이, D3RO-VOICE만으로
|
||||||
height={320}
|
</PhosphorText>
|
||||||
>
|
|
||||||
{displayMode === 'stats' && (
|
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<PhosphorText variant="label">TOTAL SESSIONS</PhosphorText>
|
|
||||||
<PhosphorText variant="label">D3RO</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 'auto', mb: 3 }}>
|
|
||||||
<PhosphorText variant="hero">
|
|
||||||
{stats?.totalSessionCount ?? 0}
|
|
||||||
</PhosphorText>
|
|
||||||
<PhosphorText variant="label" sx={{ mt: 1 }}>
|
|
||||||
{formatTime(stats?.totalRecordingTimeMs ?? 0)} RECORDED
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<Box>
|
|
||||||
<PhosphorText variant="label">WORDS</PhosphorText>
|
|
||||||
<PhosphorText variant="value">{stats?.totalWordCount ?? 0}</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<PhosphorText variant="label">TODAY</PhosphorText>
|
|
||||||
<PhosphorText variant="value">{stats?.todaySessionCount ?? 0}</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ textAlign: 'right' }}>
|
|
||||||
<PhosphorText variant="label">STREAK</PhosphorText>
|
|
||||||
<PhosphorText variant="value">
|
|
||||||
{stats?.streakDays ?? 0}<PhosphorText variant="dim" component="span" sx={{ ml: 0.5 }}>D</PhosphorText>
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayMode === 'voice' && (
|
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<PhosphorText variant="label">VOICE MODE</PhosphorText>
|
|
||||||
<PhosphorText variant="label">STANDBY</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 'auto', mb: 3, textAlign: 'center' }}>
|
|
||||||
<PhosphorText variant="hero">IDLE</PhosphorText>
|
|
||||||
<PhosphorText variant="label" sx={{ mt: 1 }}>
|
|
||||||
PRESS RIGHT ALT TO DICTATE
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<Box>
|
|
||||||
<PhosphorText variant="label">MODE</PhosphorText>
|
|
||||||
<PhosphorText variant="value">DICT</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ textAlign: 'right' }}>
|
|
||||||
<PhosphorText variant="label">HOTKEY</PhosphorText>
|
|
||||||
<PhosphorText variant="value">R.ALT</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayMode === 'sys' && (
|
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<PhosphorText variant="label">SYSTEM STATUS</PhosphorText>
|
|
||||||
<PhosphorText variant="label">DIAG</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
{[
|
|
||||||
{ name: 'STT ENGINE', status: 'READY' as const },
|
|
||||||
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' as const : 'OFFLINE' as const },
|
|
||||||
{ name: 'HOTKEY HOOK', status: 'ACTIVE' as const },
|
|
||||||
{ name: 'AUDIO INPUT', status: 'STANDBY' as const },
|
|
||||||
].map((item) => (
|
|
||||||
<Box key={item.name} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5 }}>
|
|
||||||
<PhosphorText variant="label">{item.name}</PhosphorText>
|
|
||||||
<PhosphorText
|
|
||||||
variant="value"
|
|
||||||
sx={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: item.status === 'OFFLINE' ? '#ef4444' : '#f25b29',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.status}
|
|
||||||
</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 'auto' }}>
|
|
||||||
<PhosphorText variant="label">VERSION</PhosphorText>
|
|
||||||
<PhosphorText variant="value" sx={{ fontSize: '12px' }}>v1.0.0</PhosphorText>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CrtDisplay>
|
|
||||||
|
|
||||||
{/* ── 우측: 컨트롤 패널 ────────────────────── */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
||||||
{/* LED 상태 클러스터 */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
|
|
||||||
<Led color="green" pulse />
|
|
||||||
<Led color={ollamaConnected ? 'green' : 'red'} />
|
|
||||||
<Led color="amber" pulse />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 모드 버튼 그룹 */}
|
{/* 핫키 표시 */}
|
||||||
<MetalCard inset>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
{dictationBinding ? (
|
||||||
<PhysicalButton selected={displayMode === 'stats'} onClick={() => switchMode('stats')} fullWidth>
|
<Box
|
||||||
STAT
|
sx={{
|
||||||
</PhysicalButton>
|
display: 'inline-flex',
|
||||||
<PhysicalButton selected={displayMode === 'voice'} onClick={() => switchMode('voice')} fullWidth>
|
alignItems: 'center',
|
||||||
VOICE
|
gap: 0.5,
|
||||||
</PhysicalButton>
|
px: 1.5,
|
||||||
<PhysicalButton selected={displayMode === 'sys'} onClick={() => switchMode('sys')} fullWidth>
|
py: 0.5,
|
||||||
SYS
|
borderRadius: '8px',
|
||||||
</PhysicalButton>
|
border: `1px solid ${d3roPalette.border.default}`,
|
||||||
|
bgcolor: d3roPalette.bg.inset,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dictationBinding.displayLabel.split(' + ').map((key) => (
|
||||||
|
<Typography
|
||||||
|
key={key}
|
||||||
|
sx={{
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: '4px',
|
||||||
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
|
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
) : (
|
||||||
|
<PhosphorText variant="dim">핫키 미설정</PhosphorText>
|
||||||
|
)}
|
||||||
|
<PhosphorText variant="dim">
|
||||||
|
키를 누른 상태에서 받아쓰기. 더블클릭하면 Agent 모드.
|
||||||
|
</PhosphorText>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</InstrumentPanel>
|
</InstrumentPanel>
|
||||||
|
|
||||||
|
{/* ── 2. 통계 카드 ──────────────────────────── */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 2,
|
||||||
|
mt: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
label="RECORDING"
|
||||||
|
value={formatRecordingTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||||
|
unit={formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="WORDS"
|
||||||
|
value={formatNumber(stats?.totalWordCount ?? 0)}
|
||||||
|
unit="단어"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="TODAY"
|
||||||
|
value={`${stats?.todaySessionCount ?? 0}`}
|
||||||
|
unit="세션"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="STREAK"
|
||||||
|
value={`${stats?.streakDays ?? 0}`}
|
||||||
|
unit="일"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<CrtDisplay amplitude={0.05} frequency={6} height={120}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 1,
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{services.map((svc) => (
|
||||||
|
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
||||||
|
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||||
|
{svc.name}
|
||||||
|
</PhosphorText>
|
||||||
|
<PhosphorText
|
||||||
|
variant="value"
|
||||||
|
sx={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{svc.status}
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</CrtDisplay>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── 4. 최근 히스토리 ──────────────────────── */}
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||||
|
RECENT TRANSCRIPTIONS
|
||||||
|
</PhosphorText>
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<MetalCard>
|
||||||
|
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
<PhosphorText variant="dim">
|
||||||
|
히스토리 없음 — 핫키를 눌러 녹음을 시작하세요
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
</MetalCard>
|
||||||
|
) : (
|
||||||
|
groupedHistory.map((group) => (
|
||||||
|
<Box key={group.label} sx={{ mb: 3 }}>
|
||||||
|
{/* 날짜 구분자 */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
|
||||||
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
||||||
|
{group.label}
|
||||||
|
</PhosphorText>
|
||||||
|
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||||
|
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
|
||||||
|
더 보기
|
||||||
|
</PhosphorText>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 히스토리 항목 */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{group.entries.map((entry) => (
|
||||||
|
<MetalCard key={entry.id}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: d3roPalette.text.primary,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.polishedText || entry.originalText}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 2,
|
||||||
|
mt: 1,
|
||||||
|
fontFamily: d3roFontMono,
|
||||||
|
fontSize: '10px',
|
||||||
|
color: d3roPalette.text.dimLabel,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatTime(entry.createdAt)}</span>
|
||||||
|
<span>{formatDuration(entry.duration)}</span>
|
||||||
|
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
||||||
|
<span>{entry.mode.toUpperCase()}</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="복사" arrow>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
|
||||||
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</MetalCard>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import SearchIcon from '@mui/icons-material/Search'
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
|
||||||
|
|
||||||
export function DictionaryPage(): React.ReactElement {
|
export function DictionaryPage(): React.ReactElement {
|
||||||
const [data, setData] = useState<DictPageData | null>(null)
|
const [data, setData] = useState<DictPageData | null>(null)
|
||||||
|
|
@ -40,7 +40,7 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
CUSTOM DICTIONARY — {data?.total ?? 0} WORDS
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small">
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small">
|
||||||
|
|
@ -53,8 +53,8 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' } }}
|
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' } }}
|
||||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment> } }}
|
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment> } }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -72,17 +72,17 @@ export function DictionaryPage(): React.ReactElement {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
|
||||||
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{entry.word}</Box>
|
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{entry.word}</Box>
|
||||||
{entry.pronunciation && (
|
{entry.pronunciation && (
|
||||||
<Box sx={{ fontFamily: MONO, fontSize: '11px', color: '#5c2615' }}>[{entry.pronunciation}]</Box>
|
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ fontFamily: MONO, fontSize: '10px', color: '#77797c', mt: 0.5, letterSpacing: '0.5px' }}>
|
<Box sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5, letterSpacing: '0.5px' }}>
|
||||||
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
{entry.category.toUpperCase()} · {entry.usageCount}× USED
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
|
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
|
||||||
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import SearchIcon from '@mui/icons-material/Search'
|
||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
import { MetalCard, PhosphorText, Led } from '../components/ds'
|
||||||
|
import { d3roPalette, d3roFontMono } from '../theme'
|
||||||
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return new Date(ts).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
@ -40,7 +40,7 @@ export function HistoryPage(): React.ReactElement {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
|
||||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: '#77797c' }}>
|
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||||
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
|
|
||||||
|
|
@ -51,11 +51,11 @@ export function HistoryPage(): React.ReactElement {
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{
|
sx={{
|
||||||
mb: 3,
|
mb: 3,
|
||||||
'& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' },
|
'& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' },
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment>,
|
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment>,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -76,13 +76,13 @@ export function HistoryPage(): React.ReactElement {
|
||||||
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
|
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
fontSize: '13px', color: '#fff', lineHeight: 1.5,
|
fontSize: '13px', color: d3roPalette.text.primary, lineHeight: 1.5,
|
||||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||||
}}>
|
}}>
|
||||||
{entry.polishedText || entry.originalText}
|
{entry.polishedText || entry.originalText}
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: MONO, fontSize: '10px', color: '#5c2615', letterSpacing: '0.5px' }}>
|
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
|
||||||
<span>{formatDate(entry.createdAt)}</span>
|
<span>{formatDate(entry.createdAt)}</span>
|
||||||
<span>{formatDuration(entry.duration)}</span>
|
<span>{formatDuration(entry.duration)}</span>
|
||||||
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
|
||||||
|
|
@ -91,11 +91,11 @@ export function HistoryPage(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||||
<IconButton size="small" onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
|
<IconButton size="small" onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
|
||||||
sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
|
||||||
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
|
||||||
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ body {
|
||||||
|
|
||||||
.wave-bar {
|
.wave-bar {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
background: #1F5DF2;
|
background: #f25b29; /* d3roPalette.accent.amber */
|
||||||
border-radius: 1.5px;
|
border-radius: 1.5px;
|
||||||
transition: height 100ms ease-out;
|
transition: height 100ms ease-out;
|
||||||
min-height: 2px;
|
min-height: 2px;
|
||||||
|
|
@ -64,7 +64,7 @@ body {
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: #1F5DF2;
|
background: #f25b29; /* d3roPalette.accent.amber */
|
||||||
border-radius: 1.5px;
|
border-radius: 1.5px;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
transition: width 100ms linear;
|
transition: width 100ms linear;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ export const d3roPalette = {
|
||||||
cardHover: '#2a2a2d',
|
cardHover: '#2a2a2d',
|
||||||
elevated: '#2e2e32',
|
elevated: '#2e2e32',
|
||||||
input: '#1e1e21',
|
input: '#1e1e21',
|
||||||
|
sidebar: '#1e1f21',
|
||||||
|
inset: '#1b1c1e',
|
||||||
|
chassis: '#242528',
|
||||||
|
crtBezel: '#1a1a1c',
|
||||||
|
crtGlass: '#050605',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
amber: '#f25b29',
|
amber: '#f25b29',
|
||||||
|
|
@ -27,12 +32,20 @@ export const d3roPalette = {
|
||||||
redBg: 'rgba(239, 68, 68, 0.12)',
|
redBg: 'rgba(239, 68, 68, 0.12)',
|
||||||
green: '#22c55e',
|
green: '#22c55e',
|
||||||
greenBg: 'rgba(34, 197, 94, 0.12)',
|
greenBg: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
greenGlow: 'rgba(34, 197, 94, 0.6)',
|
||||||
|
redGlow: 'rgba(239, 68, 68, 0.6)',
|
||||||
|
orangeGlow: 'rgba(245, 158, 11, 0.6)',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: '#ffffff',
|
primary: '#ffffff',
|
||||||
secondary: '#8e8e93',
|
secondary: '#8e8e93',
|
||||||
label: '#7c7c82',
|
label: '#7c7c82',
|
||||||
disabled: '#4a4a4e',
|
disabled: '#4a4a4e',
|
||||||
|
engraving: '#1a1a1c',
|
||||||
|
inactive: '#77797c',
|
||||||
|
dimLabel: '#5c2615',
|
||||||
|
muted: '#3a3b3f',
|
||||||
|
hover: '#aaaaaa',
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
subtle: 'rgba(255, 255, 255, 0.04)',
|
subtle: 'rgba(255, 255, 255, 0.04)',
|
||||||
|
|
@ -45,6 +58,9 @@ export const d3roPalette = {
|
||||||
scanline: 'rgba(0, 0, 0, 0.15)',
|
scanline: 'rgba(0, 0, 0, 0.15)',
|
||||||
bg: '#242528',
|
bg: '#242528',
|
||||||
},
|
},
|
||||||
|
led: {
|
||||||
|
off: '#111111',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const d3roFontSans = [
|
export const d3roFontSans = [
|
||||||
|
|
@ -59,20 +75,20 @@ export const d3roFontMono = [
|
||||||
|
|
||||||
// ── 모드별 변동 팔레트 ────────────────────────────────────
|
// ── 모드별 변동 팔레트 ────────────────────────────────────
|
||||||
interface ModePalette {
|
interface ModePalette {
|
||||||
bg: { app: string; card: string; cardHover: string; elevated: string; input: string }
|
bg: { app: string; card: string; cardHover: string; elevated: string; input: string; sidebar: string; inset: string; chassis: string; crtBezel: string; crtGlass: string }
|
||||||
text: { primary: string; secondary: string; label: string; disabled: string }
|
text: { primary: string; secondary: string; label: string; disabled: string; engraving: string; inactive: string; dimLabel: string; muted: string; hover: string }
|
||||||
border: { subtle: string; default: string; strong: string }
|
border: { subtle: string; default: string; strong: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
const darkPalette: ModePalette = {
|
const darkPalette: ModePalette = {
|
||||||
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21' },
|
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21', sidebar: '#1e1f21', inset: '#1b1c1e', chassis: '#242528', crtBezel: '#1a1a1c', crtGlass: '#050605' },
|
||||||
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e' },
|
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e', engraving: '#1a1a1c', inactive: '#77797c', dimLabel: '#5c2615', muted: '#3a3b3f', hover: '#aaaaaa' },
|
||||||
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
|
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const lightPalette: ModePalette = {
|
const lightPalette: ModePalette = {
|
||||||
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff' },
|
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e0e0e3', crtBezel: '#d0d0d3', crtGlass: '#f8f8fa' },
|
||||||
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc' },
|
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#c4845a', muted: '#b0b0b4', hover: '#555555' },
|
||||||
border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' },
|
border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue