diff --git a/AGENTS.md b/AGENTS.md index 88431b1..9669154 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ VoicePaste: phone as microphone via browser → LAN WebSocket → Go server → ## Tech Stack - **Backend**: Go 1.25+, Fiber v3, fasthttp/websocket, CGO required (robotgo + clipboard) -- **Frontend**: TypeScript, Vite 7, Biome 2, bun (package manager + runtime) +- **Frontend**: React 19, TypeScript, Zustand, Vite 7, Tailwind CSS v4, Biome 2, bun (package manager + runtime) - **Tooling**: Taskfile (not Make), mise (Go + bun + task) - **ASR**: Doubao Seed-ASR-2.0 via custom binary WebSocket protocol @@ -72,13 +72,29 @@ internal/ asr/client.go # WSS client to Doubao, audio streaming, result forwarding paste/paste.go # clipboard.Write + robotgo key simulation (Ctrl+V / Cmd+V) web/ - app.ts # Main app: WS client, audio pipeline, recording, history, UI - audio-processor.ts # AudioWorklet: PCM capture, 200ms frame accumulation - index.html # Mobile-first UI (all Chinese) - style.css # Dark theme - vite.config.ts # Vite config - biome.json # Biome config - tsconfig.json # TypeScript strict config + index.html # HTML shell with React root + vite.config.ts # Vite config (React + Tailwind plugins) + biome.json # Biome config (lint, format, Tailwind class sorting) + tsconfig.json # TypeScript strict config (React JSX) + src/ + main.tsx # React entry point + App.tsx # Root component: composes hooks + layout + app.css # Tailwind imports, design tokens (@theme), keyframes + stores/ + app-store.ts # Zustand store: connection, recording, preview, history, toast + hooks/ + useWebSocket.ts # WS client hook: connect, reconnect, message dispatch + useRecorder.ts # Audio pipeline hook: getUserMedia, AudioWorklet, resample + components/ + StatusBadge.tsx # Connection status indicator + PreviewBox.tsx # Real-time transcription preview + MicButton.tsx # Push-to-talk button with animations + HistoryList.tsx # Transcription history with re-send + Toast.tsx # Auto-dismiss toast notifications + lib/ + resample.ts # Linear interpolation resampler (native rate → 16kHz Int16) + workers/ + audio-processor.ts # AudioWorklet: PCM capture, 200ms frame accumulation ``` ## Code Style — Go @@ -137,13 +153,15 @@ Per-connection loggers via `slog.With("remote", addr)`. - Target: ES2022, module: ESNext, bundler resolution - DOM + DOM.Iterable libs -### Patterns -- No framework — vanilla TypeScript with direct DOM manipulation -- State object pattern: single `AppState` interface with mutable fields +- React 19 with functional components and hooks +- Zustand for global state management (connection, recording, preview, history, toast) +- Custom hooks for imperative APIs: `useWebSocket`, `useRecorder` +- Zustand `getState()` in hooks/callbacks to avoid stale closures - Pointer Events for touch/mouse (not touch + mouse separately) - AudioWorklet for audio capture (not MediaRecorder) - `?worker&url` Vite import for AudioWorklet files - WebSocket: binary for audio frames, JSON text for control messages +- Tailwind CSS v4 with `@theme` design tokens; minimal custom CSS (keyframes only) ## Language & Locale diff --git a/web/app.ts b/web/app.ts deleted file mode 100644 index 0618667..0000000 --- a/web/app.ts +++ /dev/null @@ -1,395 +0,0 @@ -import "./style.css"; -import audioProcessorUrl from "./audio-processor.ts?worker&url"; - -/** - * VoicePaste — Main application logic. - * - * Modules: - * 1. WebSocket client (token auth, reconnect) - * 2. Audio pipeline (getUserMedia → AudioWorklet → resample → WS binary) - * 3. Recording controls (touch/mouse, state machine) - * 4. History (localStorage, tap to re-send) - * 5. UI state management - */ - -// ── Types ── -interface HistoryItem { - text: string; - ts: number; -} - -interface ServerMessage { - type: "partial" | "final" | "pasted" | "error"; - text?: string; - message?: string; -} - -interface AppState { - ws: WebSocket | null; - connected: boolean; - recording: boolean; - pendingStart: boolean; - abortController: AbortController | null; - audioCtx: AudioContext | null; - workletNode: AudioWorkletNode | null; - stream: MediaStream | null; - reconnectDelay: number; - reconnectTimer: ReturnType | null; -} - -// ── Constants ── -const TARGET_SAMPLE_RATE = 16000; -const WS_RECONNECT_BASE = 1000; -const WS_RECONNECT_MAX = 16000; -const HISTORY_KEY = "voicepaste_history"; -const HISTORY_MAX = 50; - -// ── DOM refs ── -function q(sel: string): HTMLElement { - const el = document.querySelector(sel); - if (!el) throw new Error(`Element not found: ${sel}`); - return el; -} -const statusEl = q("#status"); -const statusText = q("#status-text"); -const previewText = q("#preview-text"); -const previewBox = q("#preview"); -const micBtn = q("#mic-btn") as HTMLButtonElement; -const historyList = q("#history-list"); -const historyEmpty = q("#history-empty"); -const clearHistoryBtn = document.querySelector("#clear-history"); - -// ── State ── -const state: AppState = { - ws: null, - connected: false, - recording: false, - pendingStart: false, - abortController: null, - audioCtx: null, - workletNode: null, - stream: null, - reconnectDelay: WS_RECONNECT_BASE, - reconnectTimer: null, -}; - -// ── Utility ── -function getToken(): string { - const params = new URLSearchParams(location.search); - return params.get("token") || ""; -} - -function formatTime(ts: number): string { - const d = new Date(ts); - const hh = String(d.getHours()).padStart(2, "0"); - const mm = String(d.getMinutes()).padStart(2, "0"); - return `${hh}:${mm}`; -} - -// ── Resampler (linear interpolation, native rate → 16kHz 16-bit mono) ── -function resampleTo16kInt16( - float32: Float32Array, - srcRate: number, -): Int16Array { - const ratio = srcRate / TARGET_SAMPLE_RATE; - const outLen = Math.floor(float32.length / ratio); - const out = new Int16Array(outLen); - for (let i = 0; i < outLen; i++) { - const srcIdx = i * ratio; - const lo = Math.floor(srcIdx); - const hi = Math.min(lo + 1, float32.length - 1); - const frac = srcIdx - lo; - const sample = float32[lo] + frac * (float32[hi] - float32[lo]); - // Clamp to [-1, 1] then scale to Int16 - out[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); - } - return out; -} -// ── WebSocket ── -function wsUrl(): string { - const proto = location.protocol === "https:" ? "wss:" : "ws:"; - const token = getToken(); - const q = token ? `?token=${encodeURIComponent(token)}` : ""; - return `${proto}//${location.host}/ws${q}`; -} -function setStatus(cls: string, text: string): void { - statusEl.classList.remove("connected", "disconnected", "connecting"); - statusEl.classList.add(cls); - statusText.textContent = text; -} -function connectWS(): void { - if (state.ws) return; - setStatus("connecting", "连接中…"); - const ws = new WebSocket(wsUrl()); - ws.binaryType = "arraybuffer"; - ws.onopen = () => { - state.connected = true; - state.reconnectDelay = WS_RECONNECT_BASE; - setStatus("connected", "已连接"); - micBtn.disabled = false; - }; - ws.onmessage = (e: MessageEvent) => handleServerMsg(e.data); - ws.onclose = () => { - state.connected = false; - state.ws = null; - micBtn.disabled = true; - if (state.recording) stopRecording(); - if (state.pendingStart) { - state.abortController?.abort(); - state.pendingStart = false; - micBtn.classList.remove("recording"); - } - setStatus("disconnected", "已断开"); - scheduleReconnect(); - }; - ws.onerror = () => ws.close(); - state.ws = ws; -} -function scheduleReconnect(): void { - if (state.reconnectTimer !== null) clearTimeout(state.reconnectTimer); - state.reconnectTimer = setTimeout(() => { - connectWS(); - }, state.reconnectDelay); - state.reconnectDelay = Math.min(state.reconnectDelay * 2, WS_RECONNECT_MAX); -} -function sendJSON(obj: Record): void { - if (state.ws && state.ws.readyState === WebSocket.OPEN) { - state.ws.send(JSON.stringify(obj)); - } -} -function sendBinary(int16arr: Int16Array): void { - if (state.ws && state.ws.readyState === WebSocket.OPEN) { - state.ws.send(int16arr.buffer); - } -} -// ── Server message handler ── -function handleServerMsg(data: unknown): void { - if (typeof data !== "string") return; - let msg: ServerMessage; - try { - msg = JSON.parse(data) as ServerMessage; - } catch { - return; - } - switch (msg.type) { - case "partial": - setPreview(msg.text || "", false); - break; - case "final": - setPreview(msg.text || "", true); - if (msg.text) addHistory(msg.text); - break; - case "pasted": - showToast("✅ 已粘贴"); - break; - case "error": - showToast(`❌ ${msg.message || "错误"}`); - break; - } -} -function setPreview(text: string, isFinal: boolean): void { - if (!text) { - previewText.textContent = "按住说话…"; - previewText.classList.add("placeholder"); - previewBox.classList.remove("active"); - return; - } - previewText.textContent = text; - previewText.classList.remove("placeholder"); - previewBox.classList.toggle("active", !isFinal); -} -function showToast(msg: string): void { - const toast = q("#toast"); - toast.textContent = msg; - toast.classList.add("show"); - const timer = ( - toast as HTMLElement & { _timer?: ReturnType } - )._timer; - if (timer) clearTimeout(timer); - (toast as HTMLElement & { _timer?: ReturnType })._timer = - setTimeout(() => { - toast.classList.remove("show"); - }, 2000); -} -// ── Audio pipeline ── -async function initAudio(): Promise { - if (state.audioCtx) return; - // Use device native sample rate — we resample to 16kHz in software - const audioCtx = new AudioContext(); - // Chrome requires resume() after user gesture - if (audioCtx.state === "suspended") { - await audioCtx.resume(); - } - await audioCtx.audioWorklet.addModule(audioProcessorUrl); - state.audioCtx = audioCtx; -} -async function startRecording(): Promise { - if (state.recording || state.pendingStart) return; - state.pendingStart = true; - const abortController = new AbortController(); - state.abortController = abortController; - try { - await initAudio(); - if (abortController.signal.aborted) { - state.pendingStart = false; - return; - } - const audioCtx = state.audioCtx as AudioContext; - if (audioCtx.state === "suspended") { - await audioCtx.resume(); - } - if (abortController.signal.aborted) { - state.pendingStart = false; - return; - } - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - channelCount: 1, - }, - }); - if (abortController.signal.aborted) { - stream.getTracks().forEach((t) => { - t.stop(); - }); - state.pendingStart = false; - return; - } - state.stream = stream; - const source = audioCtx.createMediaStreamSource(stream); - const worklet = new AudioWorkletNode(audioCtx, "audio-processor"); - worklet.port.onmessage = (e: MessageEvent) => { - if (e.data.type === "audio") { - const int16 = resampleTo16kInt16(e.data.samples, e.data.sampleRate); - sendBinary(int16); - } - }; - source.connect(worklet); - worklet.port.postMessage({ command: "start" }); - state.workletNode = worklet; - state.pendingStart = false; - state.abortController = null; - state.recording = true; - sendJSON({ type: "start" }); - micBtn.classList.add("recording"); - setPreview("", false); - } catch (err) { - state.pendingStart = false; - state.abortController = null; - showToast(`麦克风错误: ${(err as Error).message}`); - } -} -function stopRecording(): void { - if (state.pendingStart) { - state.abortController?.abort(); - state.abortController = null; - micBtn.classList.remove("recording"); - return; - } - if (!state.recording) return; - state.recording = false; - if (state.workletNode) { - state.workletNode.port.postMessage({ command: "stop" }); - state.workletNode.disconnect(); - state.workletNode = null; - } - if (state.stream) { - state.stream.getTracks().forEach((t) => { - t.stop(); - }); - state.stream = null; - } - sendJSON({ type: "stop" }); - micBtn.classList.remove("recording"); -} -// ── History (localStorage) ── -function loadHistory(): HistoryItem[] { - try { - return JSON.parse( - localStorage.getItem(HISTORY_KEY) || "[]", - ) as HistoryItem[]; - } catch { - return []; - } -} -function saveHistory(items: HistoryItem[]): void { - localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); -} -function addHistory(text: string): void { - const items = loadHistory(); - items.unshift({ text, ts: Date.now() }); - if (items.length > HISTORY_MAX) items.length = HISTORY_MAX; - saveHistory(items); - renderHistory(); -} -function clearHistory(): void { - localStorage.removeItem(HISTORY_KEY); - renderHistory(); -} -function renderHistory(): void { - const items = loadHistory(); - historyList.innerHTML = ""; - if (!items.length) { - (historyEmpty as HTMLElement).style.display = ""; - return; - } - (historyEmpty as HTMLElement).style.display = "none"; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const li = document.createElement("li"); - li.style.setProperty("--i", String(Math.min(i, 10))); - li.innerHTML = - `${escapeHtml(item.text)}` + - `${formatTime(item.ts)}`; - li.addEventListener("click", () => { - sendJSON({ type: "paste", text: item.text }); - showToast("发送粘贴…"); - }); - historyList.appendChild(li); - } -} -function escapeHtml(s: string): string { - const d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; -} -// ── Event bindings ── -function bindMicButton(): void { - // Pointer Events: unified touch + mouse, no double-trigger - micBtn.addEventListener("pointerdown", (e: PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - // Capture pointer so pointerleave won't fire while held - micBtn.setPointerCapture(e.pointerId); - startRecording(); - }); - micBtn.addEventListener("pointerup", (e: PointerEvent) => { - e.preventDefault(); - micBtn.releasePointerCapture(e.pointerId); - stopRecording(); - }); - micBtn.addEventListener("pointerleave", () => { - if (state.recording || state.pendingStart) stopRecording(); - }); - micBtn.addEventListener("pointercancel", () => { - if (state.recording || state.pendingStart) stopRecording(); - }); - // Prevent context menu on long press - micBtn.addEventListener("contextmenu", (e) => e.preventDefault()); -} -// ── Init ── -function init(): void { - micBtn.disabled = true; - bindMicButton(); - if (clearHistoryBtn) { - clearHistoryBtn.addEventListener("click", clearHistory); - } - renderHistory(); - connectWS(); -} -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); -} else { - init(); -} diff --git a/web/bun.lock b/web/bun.lock index 37c5a76..f3893ff 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -4,9 +4,17 @@ "workspaces": { "": { "name": "web", + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "zustand": "^5.0.11", + }, "devDependencies": { "@biomejs/biome": "^2.4.4", "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -14,6 +22,44 @@ }, }, "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], @@ -94,6 +140,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], @@ -174,24 +222,62 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001775", "", {}, "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], @@ -216,18 +302,34 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], @@ -240,8 +342,14 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/web/index.html b/web/index.html index 8ebd46e..57af0b9 100644 --- a/web/index.html +++ b/web/index.html @@ -9,50 +9,7 @@ VoicePaste -
-
-

VoicePaste

-
- - 连接中… -
-
- -
-
-

按住说话…

-
-
- -
-
- - -
-

按住说话

-
- -
-
-

历史记录

- -
-
    -

    暂无记录

    -
    -
    -
    - - +
    + diff --git a/web/package.json b/web/package.json index 1d577e5..b27a3fa 100644 --- a/web/package.json +++ b/web/package.json @@ -12,8 +12,16 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^7.3.1" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "zustand": "^5.0.11" } } diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..09d2813 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,33 @@ +import { HistoryList } from "./components/HistoryList"; +import { MicButton } from "./components/MicButton"; +import { PreviewBox } from "./components/PreviewBox"; +import { StatusBadge } from "./components/StatusBadge"; +import { Toast } from "./components/Toast"; +import { useRecorder } from "./hooks/useRecorder"; +import { useWebSocket } from "./hooks/useWebSocket"; + +export function App() { + const { sendJSON, sendBinary } = useWebSocket(); + const { startRecording, stopRecording } = useRecorder({ + sendJSON, + sendBinary, + }); + + return ( + <> +
    +
    +

    + VoicePaste +

    + +
    + + + + +
    + + + ); +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..9223713 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,112 @@ +@import "tailwindcss"; + +/* ─── Design Tokens ─── */ +@theme { + --color-bg: #08080d; + --color-surface: #111117; + --color-surface-hover: #17171e; + --color-surface-active: #1c1c25; + --color-edge: #1e1e2a; + --color-edge-active: #2c2c3e; + --color-fg: #eaeaef; + --color-fg-secondary: #9e9eb5; + --color-fg-dim: #5a5a6e; + --color-accent: #6366f1; + --color-accent-hover: #818cf8; + --color-danger: #f43f5e; + --color-success: #34d399; + --radius-card: 14px; + + --animate-pulse-dot: pulse-dot 1.4s ease-in-out infinite; + --animate-mic-breathe: mic-breathe 1.8s ease-in-out infinite; + --animate-ring-expand: ring-expand 2.4s cubic-bezier(0.2, 0, 0.2, 1) infinite; + --animate-slide-up: slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* ─── Base ─── */ +@layer base { + html, + body { + font-family: + "SF Pro Display", -apple-system, BlinkMacSystemFont, "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + } + + body::after { + content: ""; + @apply pointer-events-none fixed z-0; + top: -30%; + left: 50%; + transform: translateX(-50%); + width: 600px; + height: 600px; + background: radial-gradient( + circle, + rgba(99, 102, 241, 0.04) 0%, + transparent 70% + ); + } +} + +/* ─── Scrollbar ─── */ +.scrollbar-thin::-webkit-scrollbar { + width: 3px; +} +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} +.scrollbar-thin::-webkit-scrollbar-thumb { + @apply bg-edge; + border-radius: 3px; +} + +/* ─── Keyframes ─── */ +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes mic-breathe { + 0%, + 100% { + box-shadow: + 0 0 32px rgba(99, 102, 241, 0.35), + 0 0 80px rgba(99, 102, 241, 0.2); + } + 50% { + box-shadow: + 0 0 48px rgba(99, 102, 241, 0.35), + 0 0 120px rgba(99, 102, 241, 0.2), + 0 0 200px rgba(99, 102, 241, 0.08); + } +} + +@keyframes ring-expand { + 0% { + transform: scale(1); + opacity: 0.35; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/web/src/components/HistoryList.tsx b/web/src/components/HistoryList.tsx new file mode 100644 index 0000000..26c1203 --- /dev/null +++ b/web/src/components/HistoryList.tsx @@ -0,0 +1,67 @@ +import { useCallback } from "react"; +import { useAppStore } from "../stores/app-store"; + +function formatTime(ts: number): string { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +interface HistoryListProps { + sendJSON: (obj: Record) => void; +} + +export function HistoryList({ sendJSON }: HistoryListProps) { + const history = useAppStore((s) => s.history); + const clearHistory = useAppStore((s) => s.clearHistory); + const showToast = useAppStore((s) => s.showToast); + + const handleItemClick = useCallback( + (text: string) => { + sendJSON({ type: "paste", text }); + showToast("\u53d1\u9001\u7c98\u8d34\u2026"); + }, + [sendJSON, showToast], + ); + + return ( +
    +
    +

    + {"\u5386\u53f2\u8bb0\u5f55"} +

    + +
    + + {history.length === 0 ? ( +

    + {"\u6682\u65e0\u8bb0\u5f55"} +

    + ) : ( +
    + {history.map((item, i) => ( + + ))} +
    + )} +
    + ); +} diff --git a/web/src/components/MicButton.tsx b/web/src/components/MicButton.tsx new file mode 100644 index 0000000..af8764e --- /dev/null +++ b/web/src/components/MicButton.tsx @@ -0,0 +1,106 @@ +import { useCallback } from "react"; +import { useAppStore } from "../stores/app-store"; + +interface MicButtonProps { + onStart: () => void; + onStop: () => void; +} + +export function MicButton({ onStart, onStop }: MicButtonProps) { + const connected = useAppStore((s) => s.connectionStatus === "connected"); + const recording = useAppStore((s) => s.recording); + const pendingStart = useAppStore((s) => s.pendingStart); + const isActive = recording || pendingStart; + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + onStart(); + }, + [onStart], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.currentTarget.releasePointerCapture(e.pointerId); + onStop(); + }, + [onStop], + ); + + // Read latest state to avoid stale closures + const handlePointerLeave = useCallback(() => { + const s = useAppStore.getState(); + if (s.recording || s.pendingStart) onStop(); + }, [onStop]); + + const handlePointerCancel = useCallback(() => { + const s = useAppStore.getState(); + if (s.recording || s.pendingStart) onStop(); + }, [onStop]); + + const disabledClasses = + "cursor-not-allowed border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary opacity-30 shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]"; + const activeClasses = + "animate-mic-breathe scale-[1.06] border-accent-hover bg-accent text-white shadow-[0_0_32px_rgba(99,102,241,0.35),0_0_80px_rgba(99,102,241,0.2)]"; + const idleClasses = + "border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]"; + + return ( +
    +
    + + + {/* Wave rings */} + +
    +

    + {"\u6309\u4f4f\u8bf4\u8bdd"} +

    +
    + ); +} diff --git a/web/src/components/PreviewBox.tsx b/web/src/components/PreviewBox.tsx new file mode 100644 index 0000000..5d7fc93 --- /dev/null +++ b/web/src/components/PreviewBox.tsx @@ -0,0 +1,25 @@ +import { useAppStore } from "../stores/app-store"; + +export function PreviewBox() { + const text = useAppStore((s) => s.previewText); + const active = useAppStore((s) => s.previewActive); + const hasText = text.length > 0; + + return ( +
    +
    +

    + {hasText ? text : "\u6309\u4f4f\u8bf4\u8bdd\u2026"} +

    +
    +
    + ); +} diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..dd48550 --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -0,0 +1,35 @@ +import { useAppStore } from "../stores/app-store"; + +const statusConfig = { + connected: { + text: "\u5df2\u8fde\u63a5", + dotClass: "bg-success shadow-[0_0_6px_rgba(52,211,153,0.5)]", + borderClass: "border-success/15", + }, + disconnected: { + text: "\u5df2\u65ad\u5f00", + dotClass: "bg-danger shadow-[0_0_6px_rgba(244,63,94,0.4)]", + borderClass: "border-edge", + }, + connecting: { + text: "\u8fde\u63a5\u4e2d\u2026", + dotClass: "bg-accent animate-pulse-dot", + borderClass: "border-edge", + }, +} as const; + +export function StatusBadge() { + const status = useAppStore((s) => s.connectionStatus); + const { text, dotClass, borderClass } = statusConfig[status]; + + return ( +
    + + {text} +
    + ); +} diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx new file mode 100644 index 0000000..9f692d7 --- /dev/null +++ b/web/src/components/Toast.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; +import { useAppStore } from "../stores/app-store"; + +export function Toast() { + const toast = useAppStore((s) => s.toast); + const dismissToast = useAppStore((s) => s.dismissToast); + const timerRef = useRef | undefined>(undefined); + + useEffect(() => { + if (!toast) return; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(dismissToast, 2000); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [toast, dismissToast]); + + return ( +
    + {toast} +
    + ); +} diff --git a/web/src/hooks/useRecorder.ts b/web/src/hooks/useRecorder.ts new file mode 100644 index 0000000..ad48d9f --- /dev/null +++ b/web/src/hooks/useRecorder.ts @@ -0,0 +1,127 @@ +import { useCallback, useRef } from "react"; +import { resampleTo16kInt16 } from "../lib/resample"; +import { useAppStore } from "../stores/app-store"; +import audioProcessorUrl from "../workers/audio-processor.ts?worker&url"; + +interface UseRecorderOptions { + sendJSON: (obj: Record) => void; + sendBinary: (data: Int16Array) => void; +} + +export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) { + const audioCtxRef = useRef(null); + const workletRef = useRef(null); + const streamRef = useRef(null); + const abortRef = useRef(null); + + // Keep stable refs so callbacks never go stale + const sendJSONRef = useRef(sendJSON); + const sendBinaryRef = useRef(sendBinary); + sendJSONRef.current = sendJSON; + sendBinaryRef.current = sendBinary; + + const initAudio = useCallback(async () => { + if (audioCtxRef.current) return; + // Use device native sample rate — we resample to 16kHz in software + const ctx = new AudioContext(); + // Chrome requires resume() after user gesture + if (ctx.state === "suspended") await ctx.resume(); + await ctx.audioWorklet.addModule(audioProcessorUrl); + audioCtxRef.current = ctx; + }, []); + + const startRecording = useCallback(async () => { + const store = useAppStore.getState(); + if (store.recording || store.pendingStart) return; + + store.setPendingStart(true); + const abort = new AbortController(); + abortRef.current = abort; + + try { + await initAudio(); + if (abort.signal.aborted) { + store.setPendingStart(false); + return; + } + + const ctx = audioCtxRef.current as AudioContext; + if (ctx.state === "suspended") await ctx.resume(); + if (abort.signal.aborted) { + store.setPendingStart(false); + return; + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + channelCount: 1, + }, + }); + if (abort.signal.aborted) { + stream.getTracks().forEach((t) => { + t.stop(); + }); + store.setPendingStart(false); + return; + } + + streamRef.current = stream; + const source = ctx.createMediaStreamSource(stream); + const worklet = new AudioWorkletNode(ctx, "audio-processor"); + worklet.port.onmessage = (e: MessageEvent) => { + if (e.data.type === "audio") { + sendBinaryRef.current( + resampleTo16kInt16(e.data.samples, e.data.sampleRate), + ); + } + }; + source.connect(worklet); + worklet.port.postMessage({ command: "start" }); + workletRef.current = worklet; + + store.setPendingStart(false); + abortRef.current = null; + store.setRecording(true); + sendJSONRef.current({ type: "start" }); + store.clearPreview(); + } catch (err) { + useAppStore.getState().setPendingStart(false); + abortRef.current = null; + useAppStore + .getState() + .showToast(`\u9ea6\u514b\u98ce\u9519\u8bef: ${(err as Error).message}`); + } + }, [initAudio]); + + const stopRecording = useCallback(() => { + const store = useAppStore.getState(); + + if (store.pendingStart) { + abortRef.current?.abort(); + abortRef.current = null; + store.setPendingStart(false); + return; + } + + if (!store.recording) return; + store.setRecording(false); + + if (workletRef.current) { + workletRef.current.port.postMessage({ command: "stop" }); + workletRef.current.disconnect(); + workletRef.current = null; + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => { + t.stop(); + }); + streamRef.current = null; + } + + sendJSONRef.current({ type: "stop" }); + }, []); + + return { startRecording, stopRecording }; +} diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..6133a34 --- /dev/null +++ b/web/src/hooks/useWebSocket.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useAppStore } from "../stores/app-store"; + +function getWsUrl(): string { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const params = new URLSearchParams(location.search); + const token = params.get("token") || ""; + const q = token ? `?token=${encodeURIComponent(token)}` : ""; + return `${proto}//${location.host}/ws${q}`; +} + +const WS_RECONNECT_BASE = 1000; +const WS_RECONNECT_MAX = 16000; + +export function useWebSocket() { + const wsRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const reconnectDelayRef = useRef(WS_RECONNECT_BASE); + + const sendJSON = useCallback((obj: Record) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)); + } + }, []); + + const sendBinary = useCallback((data: Int16Array) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(data.buffer); + } + }, []); + + useEffect(() => { + function connect() { + if (wsRef.current) return; + + useAppStore.getState().setConnectionStatus("connecting"); + const ws = new WebSocket(getWsUrl()); + ws.binaryType = "arraybuffer"; + + ws.onopen = () => { + reconnectDelayRef.current = WS_RECONNECT_BASE; + useAppStore.getState().setConnectionStatus("connected"); + }; + + ws.onmessage = (e: MessageEvent) => { + if (typeof e.data !== "string") return; + try { + const msg = JSON.parse(e.data); + const store = useAppStore.getState(); + switch (msg.type) { + case "partial": + store.setPreview(msg.text || "", false); + break; + case "final": + store.setPreview(msg.text || "", true); + if (msg.text) store.addHistory(msg.text); + break; + case "pasted": + store.showToast("\u2705 \u5df2\u7c98\u8d34"); + break; + case "error": + store.showToast(`\u274c ${msg.message || "\u9519\u8bef"}`); + break; + } + } catch { + // Ignore malformed messages + } + }; + + ws.onclose = () => { + wsRef.current = null; + const store = useAppStore.getState(); + store.setConnectionStatus("disconnected"); + if (store.recording) store.setRecording(false); + if (store.pendingStart) store.setPendingStart(false); + scheduleReconnect(); + }; + + ws.onerror = () => ws.close(); + wsRef.current = ws; + } + + function scheduleReconnect() { + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = setTimeout( + connect, + reconnectDelayRef.current, + ); + reconnectDelayRef.current = Math.min( + reconnectDelayRef.current * 2, + WS_RECONNECT_MAX, + ); + } + + connect(); + + return () => { + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + wsRef.current?.close(); + wsRef.current = null; + }; + }, []); + + return { sendJSON, sendBinary }; +} diff --git a/web/src/lib/resample.ts b/web/src/lib/resample.ts new file mode 100644 index 0000000..7f127b9 --- /dev/null +++ b/web/src/lib/resample.ts @@ -0,0 +1,23 @@ +/** + * Linear interpolation resampler: native sample rate -> 16kHz 16-bit mono PCM. + */ +const TARGET_SAMPLE_RATE = 16000; + +export function resampleTo16kInt16( + float32: Float32Array, + srcRate: number, +): Int16Array { + const ratio = srcRate / TARGET_SAMPLE_RATE; + const outLen = Math.floor(float32.length / ratio); + const out = new Int16Array(outLen); + for (let i = 0; i < outLen; i++) { + const srcIdx = i * ratio; + const lo = Math.floor(srcIdx); + const hi = Math.min(lo + 1, float32.length - 1); + const frac = srcIdx - lo; + const sample = float32[lo] + frac * (float32[hi] - float32[lo]); + // Clamp to [-1, 1] then scale to Int16 + out[i] = Math.max(-32768, Math.min(32767, Math.round(sample * 32767))); + } + return out; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..1624c16 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./app.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + , +); diff --git a/web/src/stores/app-store.ts b/web/src/stores/app-store.ts new file mode 100644 index 0000000..862fafd --- /dev/null +++ b/web/src/stores/app-store.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; + +export interface HistoryItem { + text: string; + ts: number; +} + +const HISTORY_KEY = "voicepaste_history"; +const HISTORY_MAX = 50; + +type ConnectionStatus = "connected" | "disconnected" | "connecting"; + +interface AppState { + // Connection + connectionStatus: ConnectionStatus; + // Recording + recording: boolean; + pendingStart: boolean; + // Preview + previewText: string; + previewActive: boolean; + // History + history: HistoryItem[]; + // Toast + toast: string | null; + + // Actions + setConnectionStatus: (status: ConnectionStatus) => void; + setRecording: (recording: boolean) => void; + setPendingStart: (pending: boolean) => void; + setPreview: (text: string, isFinal: boolean) => void; + clearPreview: () => void; + addHistory: (text: string) => void; + clearHistory: () => void; + showToast: (message: string) => void; + dismissToast: () => void; +} + +function loadHistoryFromStorage(): HistoryItem[] { + try { + return JSON.parse( + localStorage.getItem(HISTORY_KEY) || "[]", + ) as HistoryItem[]; + } catch { + return []; + } +} + +export const useAppStore = create((set, get) => ({ + connectionStatus: "connecting", + recording: false, + pendingStart: false, + previewText: "", + previewActive: false, + history: loadHistoryFromStorage(), + toast: null, + + setConnectionStatus: (connectionStatus) => set({ connectionStatus }), + setRecording: (recording) => set({ recording }), + setPendingStart: (pendingStart) => set({ pendingStart }), + + setPreview: (text, isFinal) => + set({ + previewText: text, + previewActive: !isFinal && text.length > 0, + }), + + clearPreview: () => set({ previewText: "", previewActive: false }), + + addHistory: (text) => { + const items = [{ text, ts: Date.now() }, ...get().history].slice( + 0, + HISTORY_MAX, + ); + localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); + set({ history: items }); + }, + + clearHistory: () => { + localStorage.removeItem(HISTORY_KEY); + set({ history: [] }); + }, + + showToast: (message) => set({ toast: message }), + dismissToast: () => set({ toast: null }), +})); diff --git a/web/audio-processor.ts b/web/src/workers/audio-processor.ts similarity index 100% rename from web/audio-processor.ts rename to web/src/workers/audio-processor.ts diff --git a/web/style.css b/web/style.css deleted file mode 100644 index 326ab80..0000000 --- a/web/style.css +++ /dev/null @@ -1,234 +0,0 @@ -@import "tailwindcss"; - -/* ─── Design Tokens ─── */ -@theme { - --color-bg: #08080d; - --color-surface: #111117; - --color-surface-hover: #17171e; - --color-surface-active: #1c1c25; - --color-edge: #1e1e2a; - --color-edge-active: #2c2c3e; - --color-fg: #eaeaef; - --color-fg-secondary: #9e9eb5; - --color-fg-dim: #5a5a6e; - --color-accent: #6366f1; - --color-accent-hover: #818cf8; - --color-danger: #f43f5e; - --color-success: #34d399; - --radius-card: 14px; -} - -/* ─── Base ─── */ -@layer base { - html, - body { - font-family: - "SF Pro Display", -apple-system, BlinkMacSystemFont, "PingFang SC", - "Hiragino Sans GB", "Microsoft YaHei", sans-serif; - -webkit-font-smoothing: antialiased; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; - } - - body::after { - content: ""; - @apply fixed pointer-events-none z-0; - top: -30%; - left: 50%; - transform: translateX(-50%); - width: 600px; - height: 600px; - background: radial-gradient( - circle, - rgba(99, 102, 241, 0.04) 0%, - transparent 70% - ); - } -} - -/* ─── App Container (safe-area padding) ─── */ -#app { - padding: calc(16px + env(safe-area-inset-top, 0px)) 20px - calc(16px + env(safe-area-inset-bottom, 0px)); -} - -/* ─── Connection Status States ─── */ -.status.connected { - @apply border-success/15; -} -.status.connected .dot { - @apply bg-success; - box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); -} -.status.disconnected .dot { - @apply bg-danger; - box-shadow: 0 0 6px rgba(244, 63, 94, 0.4); -} -.status.connecting .dot { - @apply bg-accent; - animation: pulse 1.4s ease-in-out infinite; -} - -/* ─── Preview Active State ─── */ -.preview-box.active { - border-color: rgba(99, 102, 241, 0.4); - box-shadow: - 0 0 0 1px rgba(99, 102, 241, 0.2), - 0 4px 24px -4px rgba(99, 102, 241, 0.15); - background: linear-gradient( - 180deg, - rgba(99, 102, 241, 0.03) 0%, - var(--color-surface) 100% - ); -} - -/* ─── Placeholder Text ─── */ -#preview-text.placeholder { - @apply text-fg-dim; -} - -/* ─── Mic Button ─── */ -#mic-btn { - background: linear-gradient( - 145deg, - var(--color-surface-hover), - var(--color-surface) - ); - box-shadow: - 0 2px 12px rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.04); - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); -} -#mic-btn:disabled { - @apply opacity-30 cursor-not-allowed; -} -#mic-btn:not(:disabled):active, -#mic-btn.recording { - @apply bg-accent border-accent-hover text-white; - transform: scale(1.06); - box-shadow: - 0 0 32px rgba(99, 102, 241, 0.35), - 0 0 80px rgba(99, 102, 241, 0.2); -} -#mic-btn.recording { - animation: mic-breathe 1.8s ease-in-out infinite; -} - -/* ─── Wave Rings ─── */ -#mic-btn.recording + .mic-rings .ring { - animation: ring-expand 2.4s cubic-bezier(0.2, 0, 0.2, 1) infinite; -} -#mic-btn.recording + .mic-rings .ring:nth-child(2) { - animation-delay: 0.8s; -} -#mic-btn.recording + .mic-rings .ring:nth-child(3) { - animation-delay: 1.6s; -} - -/* ─── History Items (dynamically created) ─── */ -#history-list li { - @apply bg-surface border border-edge rounded-card cursor-pointer flex items-start gap-3 transition-all duration-150; - padding: 14px 16px; - margin-bottom: 8px; - font-size: 14px; - line-height: 1.5; - animation: slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; - animation-delay: calc(var(--i, 0) * 40ms); -} -#history-list li:active { - @apply bg-surface-active border-edge-active; - transform: scale(0.985); -} -#history-list li .hist-text { - @apply flex-1 break-words; -} -#history-list li .hist-time { - @apply text-fg-dim whitespace-nowrap shrink-0; - font-size: 11px; - padding-top: 2px; - font-variant-numeric: tabular-nums; -} - -/* ─── Text Button Active State ─── */ -.text-btn:active { - @apply text-danger; - background: rgba(244, 63, 94, 0.08); -} - -/* ─── Scrollbar ─── */ -.preview-box::-webkit-scrollbar, -#history-list::-webkit-scrollbar { - width: 3px; -} -.preview-box::-webkit-scrollbar-track, -#history-list::-webkit-scrollbar-track { - background: transparent; -} -.preview-box::-webkit-scrollbar-thumb, -#history-list::-webkit-scrollbar-thumb { - @apply bg-edge; - border-radius: 3px; -} - -/* ─── Toast ─── */ -.toast { - bottom: calc(80px + env(safe-area-inset-bottom, 0px)); - transform: translateX(-50%) translateY(8px); - background: rgba(28, 28, 37, 0.85); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} -.toast.show { - @apply opacity-100; - transform: translateX(-50%) translateY(0); -} - -/* ─── Keyframes ─── */ -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } -} - -@keyframes mic-breathe { - 0%, - 100% { - box-shadow: - 0 0 32px rgba(99, 102, 241, 0.35), - 0 0 80px rgba(99, 102, 241, 0.2); - } - 50% { - box-shadow: - 0 0 48px rgba(99, 102, 241, 0.35), - 0 0 120px rgba(99, 102, 241, 0.2), - 0 0 200px rgba(99, 102, 241, 0.08); - } -} - -@keyframes ring-expand { - 0% { - transform: scale(1); - opacity: 0.35; - } - 100% { - transform: scale(2); - opacity: 0; - } -} - -@keyframes slide-up { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/web/tsconfig.json b/web/tsconfig.json index 9cf9f10..253618c 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, @@ -11,5 +12,5 @@ "skipLibCheck": true, "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["*.ts", "vite-env.d.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index fd0fe68..4a342e3 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,9 @@ import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [tailwindcss()], + plugins: [react(), tailwindcss()], root: ".", build: { outDir: "dist",