diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 0a2a95c..0c39c7d 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -1,23 +1,14 @@ // src/renderer/components/AppLayout.tsx -// 08-design-system.md SSOT 적용. 앰버 악센트, LED, 다크 카드. +// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역 import { useState } from 'react' -import { - Box, - Drawer, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Divider, - Typography -} from '@mui/material' +import { Box, Typography, Tooltip } from '@mui/material' import DashboardIcon from '@mui/icons-material/Dashboard' import HistoryIcon from '@mui/icons-material/History' import MenuBookIcon from '@mui/icons-material/MenuBook' import ExtensionIcon from '@mui/icons-material/Extension' import SettingsIcon from '@mui/icons-material/Settings' -import { d3roPalette, d3roFontMono } from '../theme' +import { Led } from './ds' import { DashboardPage } from '../pages/DashboardPage' import { HistoryPage } from '../pages/HistoryPage' import { DictionaryPage } from '../pages/DictionaryPage' @@ -27,13 +18,11 @@ import { StatusBar } from './StatusBar' type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' -const DRAWER_WIDTH = 240 - const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [ - { route: 'dashboard', label: 'Dashboard', icon: }, - { route: 'history', label: 'History', icon: }, - { route: 'dictionary', label: 'Dictionary', icon: }, - { route: 'commands', label: 'Commands', icon: } + { route: 'dashboard', label: 'DASH', icon: }, + { route: 'history', label: 'HIST', icon: }, + { route: 'dictionary', label: 'DICT', icon: }, + { route: 'commands', label: 'CMD', icon: }, ] export function AppLayout(): React.ReactElement { @@ -41,132 +30,126 @@ export function AppLayout(): React.ReactElement { const [settingsOpen, setSettingsOpen] = useState(false) return ( - + - {/* Sidebar Drawer */} - - {/* Header — 앰버 악센트 로고 */} - - {/* LED indicator */} - + {/* 로고 LED */} + + - D3RO VOICE - - - v1.0 + D3RO - - - {/* Navigation label */} - - Navigation - - - - {NAV_ITEMS.map((item) => ( - setCurrentRoute(item.route)} - sx={{ my: 0.5 }} - > - { + const isActive = currentRoute === item.route + return ( + + setCurrentRoute(item.route)} sx={{ - minWidth: 36, - color: currentRoute === item.route - ? d3roPalette.accent.amber - : d3roPalette.text.label, + width: 48, + height: 48, + borderRadius: '8px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0.5, + cursor: 'pointer', + bgcolor: isActive ? '#242528' : 'transparent', + boxShadow: isActive + ? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000' + : '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)', + color: isActive ? '#f25b29' : '#77797c', + transition: 'all 0.05s linear', + transform: isActive ? 'translateY(1px)' : 'none', + '&:active': { + transform: 'translateY(2px)', + boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)', + }, + '&:hover': { + color: isActive ? '#f25b29' : '#aaa', + }, }} > {item.icon} - - - - ))} - + + {item.label} + + + + ) + })} - + {/* 스페이서 */} + - {/* Bottom settings */} - - setSettingsOpen(true)}> - - - - - - - + {/* Settings */} + + setSettingsOpen(true)} + sx={{ + width: 48, + height: 48, + borderRadius: '8px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: '#77797c', + boxShadow: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)', + transition: 'all 0.05s linear', + '&:active': { + transform: 'translateY(2px)', + boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)', + }, + '&:hover': { color: '#aaa' }, + }} + > + + + + - {/* Content Area */} + {/* ── 콘텐츠 영역 ────────────────────────── */} {currentRoute === 'dashboard' && } diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index 72b8b77..e8b4dee 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -1,50 +1,29 @@ // src/renderer/components/StatusBar.tsx -// 하단 상태 표시: LED 인디케이터 + 태그 시스템. 08-design-system.md SSOT. +// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 import { useState, useEffect } from 'react' -import { Box, Typography, Chip } from '@mui/material' -import { d3roPalette, d3roFontMono } from '../theme' +import { Box, Typography } from '@mui/material' +import { Led } from './ds' import type { LLMStatus } from '@shared/types' -function Led({ active, color }: { active: boolean; color?: string }): React.ReactElement { - const c = color ?? d3roPalette.tag.green - return ( - - ) -} +const MONO = 'MONO_PLACEHOLDER' export function StatusBar(): React.ReactElement { const [llmStatus, setLlmStatus] = useState(null) useEffect(() => { - window.electronAPI.llm.getStatus().then((result) => { - if (result.success) setLlmStatus(result.data) - }) - - const unsub = window.electronAPI.llm.onStatusChanged((event) => { - setLlmStatus(event.status) + window.electronAPI.llm.getStatus().then((r) => { + if (r.success) setLlmStatus(r.data) }) + const unsub = window.electronAPI.llm.onStatusChanged((e) => setLlmStatus(e.status)) const interval = setInterval(() => { - window.electronAPI.llm.getStatus().then((result) => { - if (result.success) setLlmStatus(result.data) + window.electronAPI.llm.getStatus().then((r) => { + if (r.success) setLlmStatus(r.data) }) }, 5000) - return () => { - unsub() - clearInterval(interval) - } + return () => { unsub(); clearInterval(interval) } }, []) const connected = llmStatus?.connectionState === 'connected' @@ -56,50 +35,29 @@ export function StatusBar(): React.ReactElement { alignItems: 'center', gap: 2, px: 2, - py: 0.75, - borderTop: `1px solid ${d3roPalette.border.subtle}`, - bgcolor: 'background.paper', - minHeight: 32, + py: 0.5, + borderTop: '1px solid rgba(255,255,255,0.04)', + bgcolor: '#1e1f21', + minHeight: 28, }} > - {/* Ollama 상태 */} - - + + {connected ? 'OLLAMA' : 'OFFLINE'} - {/* 활성 모델 태그 */} {llmStatus?.activeModel && ( - + + {llmStatus.activeModel.toUpperCase()} + )} - {/* 스페이서 */} - {/* 핫키 힌트 */} - - RIGHT ALT — DICTATE + + PRECISION DATA LINK ) diff --git a/src/renderer/components/ds/CrtDisplay.tsx b/src/renderer/components/ds/CrtDisplay.tsx new file mode 100644 index 0000000..8b2a6b2 --- /dev/null +++ b/src/renderer/components/ds/CrtDisplay.tsx @@ -0,0 +1,274 @@ +// src/renderer/components/ds/CrtDisplay.tsx +// 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형) + +import { useRef, useEffect, useCallback } from 'react' +import { Box } from '@mui/material' + +// ── WebGL 유틸 ───────────────────────────────────────── + +function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { + const shader = gl.createShader(type) + if (!shader) return null + gl.shaderSource(shader, source) + gl.compileShader(shader) + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + gl.deleteShader(shader) + return null + } + return shader +} + +function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null { + const vs = createShader(gl, gl.VERTEX_SHADER, vsSource) + const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource) + if (!vs || !fs) return null + const prog = gl.createProgram() + if (!prog) return null + gl.attachShader(prog, vs) + gl.attachShader(prog, fs) + gl.linkProgram(prog) + return prog +} + +const VERTEX_SHADER = ` + attribute vec2 position; + varying vec2 vUv; + void main() { + gl_Position = vec4(position, 0.0, 1.0); + vUv = position * 0.5 + 0.5; + } +` + +const FRAGMENT_SHADER = ` + precision highp float; + varying vec2 vUv; + uniform float u_time; + uniform float u_glitch; + uniform float u_amp; + uniform float u_freq; + + float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); } + + void main() { + vec2 uv = vUv; + + // Glitch displacement + if (u_glitch > 0.0) { + float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1; + uv.x += gOffset; + } + + // Base phosphor background + vec3 color = vec3(0.02, 0.015, 0.01); + + // Background Grid + float gridX = step(0.98, fract(uv.x * 15.0)); + float gridY = step(0.98, fract(uv.y * 10.0)); + color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch); + + // Oscilloscope Waveform + float t = u_time * 0.5 + u_glitch * random(uv) * 0.1; + float waveY = sin((uv.x * u_freq) + t) * u_amp; + float waveDist = abs((uv.y - 0.5) - waveY); + float lineThick = 0.005 + (u_glitch * 0.02); + float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist); + float waveCore = smoothstep(lineThick, 0.0, waveDist); + + // Amber wave (#f25b29) + vec3 waveColor = vec3(0.95, 0.35, 0.16); + color += waveColor * waveCore; + color += waveColor * 0.4 * waveGlow; + + // Scanlines + float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04; + color -= scanline; + + // Noise + float noise = random(uv + u_time) * 0.08; + color += noise; + + // Vignette + float dist = distance(vUv, vec2(0.5)); + float vig = smoothstep(0.8, 0.4, dist); + color *= vig; + + // Edge darkening + color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x); + color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y); + + gl_FragColor = vec4(color, 1.0); + } +` + +const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]) + +// ── Props ────────────────────────────────────────────── + +interface CrtDisplayProps { + /** 파형 진폭 (0.0 ~ 1.0) */ + amplitude?: number + /** 파형 주파수 */ + frequency?: number + /** 글리치 트리거 (변경 시 글리치 발생) */ + glitchTrigger?: number + /** 오버레이 콘텐츠 (인광 텍스트 등) */ + children?: React.ReactNode + /** 높이 (기본 280px) */ + height?: number | string +} + +export function CrtDisplay({ + amplitude = 0.1, + frequency = 8.0, + glitchTrigger = 0, + children, + height = 280, +}: CrtDisplayProps): React.ReactElement { + const canvasRef = useRef(null) + const glRef = useRef<{ + gl: WebGLRenderingContext + uTime: WebGLUniformLocation | null + uGlitch: WebGLUniformLocation | null + uAmp: WebGLUniformLocation | null + uFreq: WebGLUniformLocation | null + } | null>(null) + const animRef = useRef(0) + const startTimeRef = useRef(Date.now()) + const glitchRef = useRef(0) + const ampRef = useRef(amplitude) + const freqRef = useRef(frequency) + const currentAmpRef = useRef(amplitude) + const currentFreqRef = useRef(frequency) + + // amplitude/frequency 변경 추적 + useEffect(() => { + ampRef.current = amplitude + freqRef.current = frequency + }, [amplitude, frequency]) + + // 글리치 트리거 + useEffect(() => { + if (glitchTrigger > 0) { + glitchRef.current = 1.0 + } + }, [glitchTrigger]) + + const render = useCallback(() => { + const ctx = glRef.current + if (!ctx) return + + const { gl, uTime, uGlitch, uAmp, uFreq } = ctx + + // Smoothing + currentAmpRef.current += (ampRef.current - currentAmpRef.current) * 0.1 + currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1 + glitchRef.current *= 0.85 + + gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000) + gl.uniform1f(uGlitch, glitchRef.current) + gl.uniform1f(uAmp, currentAmpRef.current) + gl.uniform1f(uFreq, currentFreqRef.current) + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) + animRef.current = requestAnimationFrame(render) + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const gl = canvas.getContext('webgl', { alpha: true }) + if (!gl) return + + canvas.width = canvas.clientWidth * 2 + canvas.height = canvas.clientHeight * 2 + gl.viewport(0, 0, canvas.width, canvas.height) + + const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER) + if (!prog) return + + gl.useProgram(prog) + + const buffer = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buffer) + gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW) + + const posLoc = gl.getAttribLocation(prog, 'position') + gl.enableVertexAttribArray(posLoc) + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0) + + glRef.current = { + gl, + uTime: gl.getUniformLocation(prog, 'u_time'), + uGlitch: gl.getUniformLocation(prog, 'u_glitch'), + uAmp: gl.getUniformLocation(prog, 'u_amp'), + uFreq: gl.getUniformLocation(prog, 'u_freq'), + } + + startTimeRef.current = Date.now() + animRef.current = requestAnimationFrame(render) + + return () => { + cancelAnimationFrame(animRef.current) + } + }, [render]) + + return ( + + {/* Glass surface */} + + + + {/* Glass reflection */} + + + + {/* Content overlay (phosphor text) */} + + {children} + + + ) +} diff --git a/src/renderer/components/ds/InstrumentPanel.tsx b/src/renderer/components/ds/InstrumentPanel.tsx new file mode 100644 index 0000000..0b66069 --- /dev/null +++ b/src/renderer/components/ds/InstrumentPanel.tsx @@ -0,0 +1,77 @@ +// src/renderer/components/ds/InstrumentPanel.tsx +// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감 + +import { Box, Typography } from '@mui/material' + +interface InstrumentPanelProps { + children: React.ReactNode + /** 상단 좌측 각인 */ + engravingLeft?: string + /** 상단 우측 각인 */ + engravingRight?: string + /** 하단 중앙 각인 */ + engravingBottom?: string +} + +export function InstrumentPanel({ + children, + engravingLeft = 'D3RO-VOICE SYS.', + engravingRight = 'MOD-01 / TERMINAL', + engravingBottom = 'LOCAL AI VOICE ASSISTANT', +}: InstrumentPanelProps): React.ReactElement { + return ( + + {/* 각인 텍스트 */} + {engravingLeft} + {engravingRight} + {engravingBottom} + + {/* 콘텐츠 (z-index 5로 노이즈 위) */} + + {children} + + + ) +} + +function Engraving({ children, sx }: { children: string; sx: Record }): React.ReactElement { + return ( + + {children} + + ) +} diff --git a/src/renderer/components/ds/Led.tsx b/src/renderer/components/ds/Led.tsx new file mode 100644 index 0000000..fb8fe80 --- /dev/null +++ b/src/renderer/components/ds/Led.tsx @@ -0,0 +1,50 @@ +// src/renderer/components/ds/Led.tsx +// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse + +import { Box } from '@mui/material' + +type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off' + +const LED_COLORS: Record = { + amber: { bg: '#f25b29', glow: 'rgba(242, 91, 41, 0.6)' }, + green: { bg: '#22c55e', glow: 'rgba(34, 197, 94, 0.6)' }, + red: { bg: '#ef4444', glow: 'rgba(239, 68, 68, 0.6)' }, + orange: { bg: '#f59e0b', glow: 'rgba(245, 158, 11, 0.6)' }, + off: { bg: '#111', glow: 'transparent' }, +} + +interface LedProps { + color?: LedColor + pulse?: boolean + size?: number +} + +export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React.ReactElement { + const c = LED_COLORS[color] + const isActive = color !== 'off' + + return ( + + ) +} diff --git a/src/renderer/components/ds/MetalCard.tsx b/src/renderer/components/ds/MetalCard.tsx new file mode 100644 index 0000000..c72bf79 --- /dev/null +++ b/src/renderer/components/ds/MetalCard.tsx @@ -0,0 +1,29 @@ +// src/renderer/components/ds/MetalCard.tsx +// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널 + +import { Box } from '@mui/material' + +interface MetalCardProps { + children: React.ReactNode + inset?: boolean +} + +export function MetalCard({ children, inset = false }: MetalCardProps): React.ReactElement { + return ( + + {children} + + ) +} diff --git a/src/renderer/components/ds/PhosphorText.tsx b/src/renderer/components/ds/PhosphorText.tsx new file mode 100644 index 0000000..c65f276 --- /dev/null +++ b/src/renderer/components/ds/PhosphorText.tsx @@ -0,0 +1,39 @@ +// src/renderer/components/ds/PhosphorText.tsx +// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌 + +import { Typography, type TypographyProps } from '@mui/material' + +type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim' + +const VARIANTS: Record = { + hero: { fontSize: '42px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 }, + value: { fontSize: '20px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 }, + label: { fontSize: '10px', color: '#5c2615', glow: 'none', fontWeight: 700 }, + dim: { fontSize: '10px', color: '#77797c', glow: 'none', fontWeight: 400 }, +} + +interface PhosphorTextProps extends Omit { + variant?: PhosphorVariant +} + +export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextProps): React.ReactElement { + const v = VARIANTS[variant] + + return ( + + ) +} diff --git a/src/renderer/components/ds/PhysicalButton.tsx b/src/renderer/components/ds/PhysicalButton.tsx new file mode 100644 index 0000000..c4257db --- /dev/null +++ b/src/renderer/components/ds/PhysicalButton.tsx @@ -0,0 +1,43 @@ +// src/renderer/components/ds/PhysicalButton.tsx +// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태 + +import { Button, type ButtonProps } from '@mui/material' + +interface PhysicalButtonProps extends Omit { + selected?: boolean +} + +export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButtonProps): React.ReactElement { + return ( + + + + + LLM INSTRUCTIONS — {instructions.length} COMMANDS + + {loading ? ( - Loading... + LOADING... ) : instructions.length === 0 ? ( - - - - Built-in commands: Translate, Summarize, Formal Rewrite, Code Explain, Free Prompt. - - - + + + BUILT-IN: TRANSLATE, SUMMARIZE, FORMAL, CODE, FREE + + ) : ( - + {instructions.map((inst) => ( - - - - - - {inst.name} - - + + + + + + {inst.name} + {inst.description} - - {inst.description} - - - openEdit(inst)} - sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }} - > - + + openEdit(inst)} sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}> + {!inst.isBuiltin && ( - - + + )} - - + + ))} )} - {/* Dialog */} setDialogOpen(false)} maxWidth="sm" fullWidth> {editId ? 'Edit Command' : 'Add Command'} diff --git a/src/renderer/pages/DashboardPage.tsx b/src/renderer/pages/DashboardPage.tsx index 4caa676..cbfd26d 100644 --- a/src/renderer/pages/DashboardPage.tsx +++ b/src/renderer/pages/DashboardPage.tsx @@ -1,263 +1,182 @@ // src/renderer/pages/DashboardPage.tsx -// 08-design-system.md 3.7 Dashboard 레이아웃. -// hero 수치, 카드 그리드, StatusPanel, 태그 시스템. +// 시안 A 인스트루먼트 패널: CRT 디스플레이 + LED 클러스터 + 물리 버튼 + 통계 -import { useState, useEffect } from 'react' -import { Box, Card, CardContent, Typography, Chip } from '@mui/material' -import MicIcon from '@mui/icons-material/Mic' -import TimerIcon from '@mui/icons-material/Timer' -import TextFieldsIcon from '@mui/icons-material/TextFields' -import WhatshotIcon from '@mui/icons-material/Whatshot' -import { d3roPalette, d3roFontMono } from '../theme' -import { useTheme } from '@mui/material/styles' +import { useState, useEffect, useCallback } from 'react' +import { Box, Typography } from '@mui/material' +import { CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText } from '../components/ds' import type { StatsSummary } from '@shared/types' -// ── StatCard 컴포넌트 ──────────────────────────────────── - -interface StatCardProps { - label: string - value: string - icon: React.ReactElement - tag?: { text: string; color: 'primary' | 'success' | 'warning' | 'error' } -} - -function StatCard({ label, value, icon, tag }: StatCardProps): React.ReactElement { - return ( - - - {/* Label row */} - - - {label} - - {tag && ( - - )} - - - {/* Hero value */} - - {icon} - - {value} - - - - - ) -} - -// ── LED 인디케이터 ─────────────────────────────────────── - -function Led({ status }: { status: 'active' | 'warning' | 'error' | 'off' }): React.ReactElement { - const colors = { - active: { bg: d3roPalette.tag.green, shadow: d3roPalette.tag.green }, - warning: { bg: d3roPalette.tag.orange, shadow: d3roPalette.tag.orange }, - error: { bg: d3roPalette.tag.red, shadow: d3roPalette.tag.red }, - off: { bg: d3roPalette.text.disabled, shadow: 'transparent' }, - } - const c = colors[status] - - return ( - - ) -} - -// ── 유틸 ───────────────────────────────────────────────── - function formatTime(ms: number): string { const totalSec = Math.round(ms / 1000) - const hours = Math.floor(totalSec / 3600) - const minutes = Math.floor((totalSec % 3600) / 60) - const seconds = totalSec % 60 - if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` - return `${minutes}:${seconds.toString().padStart(2, '0')}` + const h = Math.floor(totalSec / 3600) + const m = Math.floor((totalSec % 3600) / 60) + const s = totalSec % 60 + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` + return `${m}:${s.toString().padStart(2, '0')}` } -// ── DashboardPage ──────────────────────────────────────── +type DisplayMode = 'stats' | 'voice' | 'sys' export function DashboardPage(): React.ReactElement { const [stats, setStats] = useState(null) const [ollamaConnected, setOllamaConnected] = useState(false) + const [displayMode, setDisplayMode] = useState('stats') + const [glitchTrigger, setGlitchTrigger] = useState(0) - useEffect(() => { + const loadStats = useCallback(() => { window.electronAPI.stats.getSummary().then((result) => { if (result.success) setStats(result.data) }) - window.electronAPI.llm.getStatus().then((result) => { if (result.success) setOllamaConnected(result.data.connectionState === 'connected') }) - - const interval = setInterval(() => { - window.electronAPI.stats.getSummary().then((result) => { - if (result.success) setStats(result.data) - }) - }, 30000) - - return () => clearInterval(interval) }, []) + useEffect(() => { + loadStats() + const interval = setInterval(loadStats, 30000) + return () => clearInterval(interval) + }, [loadStats]) + + const switchMode = (mode: DisplayMode) => { + setDisplayMode(mode) + setGlitchTrigger((prev) => prev + 1) + } + return ( - - {/* Header */} - - - Dashboard - - - Voice assistant overview - - + + + + {/* ── 좌측: CRT 디스플레이 ─────────────────── */} + + {displayMode === 'stats' && ( + <> + + TOTAL SESSIONS + D3RO + + + + {stats?.totalSessionCount ?? 0} + + + {formatTime(stats?.totalRecordingTimeMs ?? 0)} RECORDED + + + + + WORDS + {stats?.totalWordCount ?? 0} + + + TODAY + {stats?.todaySessionCount ?? 0} + + + STREAK + + {stats?.streakDays ?? 0}D + + + + + )} - {/* Status Panel (서비스 상태) */} - - - - - - - STT Ready - - - - - - {ollamaConnected ? 'Ollama Connected' : 'Ollama Offline'} - - - - - - Hotkey Active - + {displayMode === 'voice' && ( + <> + + VOICE MODE + STANDBY + + + IDLE + + PRESS RIGHT ALT TO DICTATE + + + + + MODE + DICT + + + HOTKEY + R.ALT + + + + )} + + {displayMode === 'sys' && ( + <> + + SYSTEM STATUS + DIAG + + + {[ + { 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) => ( + + {item.name} + + {item.status} + + + ))} + + + VERSION + v1.0.0 + + + )} + + + {/* ── 우측: 컨트롤 패널 ────────────────────── */} + + {/* LED 상태 클러스터 */} + + + + + + {/* 모드 버튼 그룹 */} + + + switchMode('stats')} fullWidth> + STAT + + switchMode('voice')} fullWidth> + VOICE + + switchMode('sys')} fullWidth> + SYS + + + - - - - {/* Stat Cards Grid */} - - } - /> - } - /> - } - /> - } - tag={stats?.streakDays && stats.streakDays > 0 ? { text: 'ACTIVE', color: 'success' } : undefined} - /> - - - {/* Today Section */} - - Today - - - - - - - Sessions - - - {stats?.todaySessionCount ?? 0} - - - - - - - Time - - - {formatTime(stats?.todayRecordingTimeMs ?? 0)} - - - - - - - Words - - - {stats?.todayWordCount ?? 0} - - - - + + ) } diff --git a/src/renderer/pages/DictionaryPage.tsx b/src/renderer/pages/DictionaryPage.tsx index 02cc985..bf592f3 100644 --- a/src/renderer/pages/DictionaryPage.tsx +++ b/src/renderer/pages/DictionaryPage.tsx @@ -1,25 +1,16 @@ // src/renderer/pages/DictionaryPage.tsx -// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템 +// 인스트루먼트 미학: MetalCard + PhosphorText import { useState, useEffect, useCallback } from 'react' -import { - Box, Typography, TextField, Button, IconButton, Chip, - Dialog, DialogTitle, DialogContent, DialogActions, - Card, CardContent, InputAdornment -} from '@mui/material' +import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, InputAdornment } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import SearchIcon from '@mui/icons-material/Search' -import { d3roPalette, d3roFontMono } from '../theme' +import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds' import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types' const PAGE_SIZE = 50 - -const CATEGORY_COLOR: Record = { - user: 'primary', - auto: 'warning', - technical: 'secondary', -} +const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace' export function DictionaryPage(): React.ReactElement { const [data, setData] = useState(null) @@ -42,119 +33,69 @@ export function DictionaryPage(): React.ReactElement { const handleAdd = async () => { if (!newWord.trim()) return - await window.electronAPI.dictionary.add({ - word: newWord.trim(), - pronunciation: newPronunciation.trim() || undefined - }) - setNewWord('') - setNewPronunciation('') - setAddOpen(false) - loadData() - } - - const handleDelete = async (id: string) => { - await window.electronAPI.dictionary.delete({ id }) - loadData() + await window.electronAPI.dictionary.add({ word: newWord.trim(), pronunciation: newPronunciation.trim() || undefined }) + setNewWord(''); setNewPronunciation(''); setAddOpen(false); loadData() } return ( - - {/* Header */} - - - Dictionary - - Custom words for better STT accuracy - - - - {/* Search */} setSearch(e.target.value)} fullWidth - sx={{ mb: 3 }} - slotProps={{ - input: { - startAdornment: ( - - - - ) - } - }} + sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' } }} + slotProps={{ input: { startAdornment: } }} /> {loading ? ( - Loading... + LOADING... ) : !data || data.entries.length === 0 ? ( - - - - {search ? 'No words found.' : 'No words yet. Add custom words to improve recognition.'} - - - + + + {search ? 'NO RESULTS' : 'NO WORDS — ADD CUSTOM WORDS FOR BETTER STT'} + + ) : ( {data.entries.map((entry: DictionaryEntry) => ( - - - + + + - - {entry.word} - + {entry.word} {entry.pronunciation && ( - - [{entry.pronunciation}] - + [{entry.pronunciation}] )} - - - - {entry.usageCount}× used - + + {entry.category.toUpperCase()} · {entry.usageCount}× USED - handleDelete(entry.id)} - sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }} - > - + { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }} + sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}> + - - + + ))} )} - {/* Add Word Dialog */} setAddOpen(false)} maxWidth="xs" fullWidth> Add Word - setNewWord(e.target.value)} - fullWidth - autoFocus - sx={{ mt: 1 }} - /> - setNewPronunciation(e.target.value)} - fullWidth - sx={{ mt: 2 }} - /> + setNewWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} /> + setNewPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} /> diff --git a/src/renderer/pages/HistoryPage.tsx b/src/renderer/pages/HistoryPage.tsx index 80ca43d..9d3695b 100644 --- a/src/renderer/pages/HistoryPage.tsx +++ b/src/renderer/pages/HistoryPage.tsx @@ -1,30 +1,19 @@ // src/renderer/pages/HistoryPage.tsx -// 08-design-system.md SSOT: 다크 카드, 앰버 악센트, 태그 시스템 +// 인스트루먼트 미학: MetalCard + PhosphorText + Led import { useState, useEffect, useCallback } from 'react' -import { - Box, - Typography, - TextField, - IconButton, - Chip, - Pagination, - Card, - CardContent, - InputAdornment -} from '@mui/material' +import { Box, TextField, IconButton, InputAdornment } from '@mui/material' import SearchIcon from '@mui/icons-material/Search' import DeleteIcon from '@mui/icons-material/Delete' import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import { d3roPalette, d3roFontMono } from '../theme' +import { MetalCard, PhosphorText, Led } from '../components/ds' import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types' const PAGE_SIZE = 20 +const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace' 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' }) } function formatDuration(sec: number): string { @@ -33,158 +22,87 @@ function formatDuration(sec: number): string { return `${m}:${s.toString().padStart(2, '0')}` } -const MODE_TAG: Record = { - dictation: 'primary', - translate: 'secondary', - command: 'warning', -} - export function HistoryPage(): React.ReactElement { const [data, setData] = useState(null) - const [page, setPage] = useState(0) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) const loadData = useCallback(async () => { setLoading(true) const result = search.trim() - ? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE }) - : await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE }) + ? await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE }) + : await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE }) if (result.success) setData(result.data) setLoading(false) - }, [page, search]) + }, [search]) useEffect(() => { loadData() }, [loadData]) - const handleDelete = async (id: string) => { - await window.electronAPI.history.delete({ id }) - loadData() - } - - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text) - } - return ( - - {/* Header */} - - History - - Transcription history - - + + + TRANSCRIPTION LOG — {data?.total ?? 0} ENTRIES + - {/* Search */} { setSearch(e.target.value); setPage(0) }} + onChange={(e) => setSearch(e.target.value)} fullWidth - sx={{ mb: 3 }} + sx={{ + mb: 3, + '& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' }, + }} slotProps={{ input: { - startAdornment: ( - - - - ) - } + startAdornment: , + }, }} /> {loading ? ( - Loading... + LOADING... ) : !data || data.entries.length === 0 ? ( - - - - {search ? 'No results found.' : 'No history yet. Start recording!'} - - - - ) : ( - <> - - {data.entries.map((entry: HistoryEntry) => ( - - - - {/* Text */} - - - {entry.polishedText || entry.originalText} - - - {/* Meta row */} - - - {formatDate(entry.createdAt)} - - - {entry.detectedLanguage && ( - - )} - - - - - {/* Actions */} - - handleCopy(entry.polishedText || entry.originalText)} - sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.accent.amber } }} - > - - - handleDelete(entry.id)} - sx={{ color: d3roPalette.text.label, '&:hover': { color: d3roPalette.tag.red } }} - > - - - - - - - ))} + + + {search ? 'NO RESULTS' : 'NO HISTORY — START RECORDING'} - - {data.totalPages > 1 && ( - - setPage(p - 1)} - sx={{ - '& .Mui-selected': { - bgcolor: `${d3roPalette.accent.amberDim} !important`, - color: d3roPalette.accent.amber, - } - }} - /> - - )} - + + ) : ( + + {data.entries.map((entry: HistoryEntry) => ( + + + + + + {entry.polishedText || entry.originalText} + + + {formatDate(entry.createdAt)} + {formatDuration(entry.duration)} + {entry.detectedLanguage && {entry.detectedLanguage.toUpperCase()}} + {entry.mode.toUpperCase()} + + + + navigator.clipboard.writeText(entry.polishedText || entry.originalText)} + sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}> + + + { window.electronAPI.history.delete({ id: entry.id }); loadData() }} + sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}> + + + + + + ))} + )} )