refactor: 迁移前端到 React 19 + Zustand + Tailwind CSS v4

- 将 vanilla TS 单文件 (app.ts 395行) 拆分为 React 组件化架构
- 引入 Zustand 管理全局状态 (连接/录音/预览/历史/toast)
- 自定义 hooks 封装 WebSocket 连接和音频录制管线
- CSS 全面 Tailwind 化,style.css 从 234 行精简到 114 行 (仅保留 tokens + keyframes)
- 新增依赖: react, react-dom, zustand, @vitejs/plugin-react
- Go 后端 embed 路径 web/dist 不变,无需改动
This commit is contained in:
2026-03-02 06:36:02 +08:00
parent ea46ad71bf
commit 70344bcd98
21 changed files with 914 additions and 687 deletions

View File

@@ -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
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
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
```
## 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

View File

@@ -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<typeof setTimeout> | 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<HTMLElement>(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<HTMLElement>("#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<string, unknown>): 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<typeof setTimeout> }
)._timer;
if (timer) clearTimeout(timer);
(toast as HTMLElement & { _timer?: ReturnType<typeof setTimeout> })._timer =
setTimeout(() => {
toast.classList.remove("show");
}, 2000);
}
// ── Audio pipeline ──
async function initAudio(): Promise<void> {
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<void> {
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 =
`<span class="hist-text">${escapeHtml(item.text)}</span>` +
`<span class="hist-time">${formatTime(item.ts)}</span>`;
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();
}

View File

@@ -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=="],

View File

@@ -9,50 +9,7 @@
<title>VoicePaste</title>
</head>
<body class="h-full bg-bg text-fg overflow-hidden select-none">
<div id="app" class="relative z-1 flex flex-col h-full max-w-[480px] mx-auto">
<header class="flex items-center justify-between pt-2 pb-5 shrink-0">
<h1 class="text-[22px] font-bold tracking-[-0.03em]">VoicePaste</h1>
<div id="status" class="status disconnected flex items-center gap-[7px] text-xs font-medium text-fg-dim px-3 py-[5px] rounded-full bg-surface border border-edge transition-all">
<span class="dot size-[7px] rounded-full bg-fg-dim shrink-0 transition-all duration-300"></span>
<span id="status-text">连接中…</span>
</div>
</header>
<section id="preview-section" class="shrink-0 pb-3">
<div id="preview" class="preview-box bg-surface border border-edge rounded-card px-[18px] py-4 min-h-20 max-h-40 overflow-y-auto transition-all duration-300">
<p id="preview-text" class="placeholder text-base leading-relaxed break-words">按住说话…</p>
</div>
</section>
<section id="mic-section" class="flex flex-col items-center pt-5 pb-4 shrink-0 gap-3.5">
<div class="mic-wrapper relative flex items-center justify-center touch-none">
<button id="mic-btn" class="relative z-1 size-24 rounded-full border-2 border-edge text-fg-secondary flex items-center justify-center cursor-pointer select-none touch-none" type="button" disabled>
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor" aria-label="麦克风" role="img">
<title>麦克风</title>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
</button>
<div class="mic-rings absolute inset-0 pointer-events-none" aria-hidden="true">
<span class="ring absolute inset-0 rounded-full border-[1.5px] border-accent opacity-0"></span>
<span class="ring absolute inset-0 rounded-full border-[1.5px] border-accent opacity-0"></span>
<span class="ring absolute inset-0 rounded-full border-[1.5px] border-accent opacity-0"></span>
</div>
</div>
<p class="text-fg-dim text-sm font-medium">按住说话</p>
</section>
<section id="history-section" class="flex-1 min-h-0 flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-2.5 shrink-0">
<h2 class="text-[13px] font-semibold text-fg-dim uppercase tracking-[0.06em]">历史记录</h2>
<button id="clear-history" type="button" class="text-btn bg-transparent border-none text-fg-dim text-xs font-medium cursor-pointer px-2.5 py-1 rounded-lg transition-all duration-150">清空</button>
</div>
<ul id="history-list" class="list-none flex-1 overflow-y-auto"></ul>
<p id="history-empty" class="text-fg-dim text-sm text-center py-10">暂无记录</p>
</section>
</div>
<div id="toast" class="toast fixed left-1/2 z-[999] rounded-3xl text-[13px] font-medium text-fg border border-edge pointer-events-none opacity-0 px-[22px] py-2.5"></div>
<script type="module" src="app.ts"></script>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>

View File

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

33
web/src/App.tsx Normal file
View File

@@ -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 (
<>
<div className="relative z-1 mx-auto flex h-full max-w-[480px] flex-col px-5 pt-[calc(16px+env(safe-area-inset-top,0px))] pb-[calc(16px+env(safe-area-inset-bottom,0px))]">
<header className="flex shrink-0 items-center justify-between pt-2 pb-5">
<h1 className="font-bold text-[22px] tracking-[-0.03em]">
VoicePaste
</h1>
<StatusBadge />
</header>
<PreviewBox />
<MicButton onStart={startRecording} onStop={stopRecording} />
<HistoryList sendJSON={sendJSON} />
</div>
<Toast />
</>
);
}

112
web/src/app.css Normal file
View File

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

View File

@@ -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<string, unknown>) => 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 (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between pb-2.5">
<h2 className="font-semibold text-[13px] text-fg-dim uppercase tracking-[0.06em]">
{"\u5386\u53f2\u8bb0\u5f55"}
</h2>
<button
type="button"
onClick={clearHistory}
className="cursor-pointer rounded-lg border-none bg-transparent px-2.5 py-1 font-medium text-fg-dim text-xs transition-all duration-150 active:bg-danger/[0.08] active:text-danger"
>
{"\u6e05\u7a7a"}
</button>
</div>
{history.length === 0 ? (
<p className="py-10 text-center text-fg-dim text-sm">
{"\u6682\u65e0\u8bb0\u5f55"}
</p>
) : (
<div className="scrollbar-thin flex-1 overflow-y-auto">
{history.map((item, i) => (
<button
type="button"
key={`${item.ts}-${i}`}
onClick={() => handleItemClick(item.text)}
className="mb-2 flex w-full animate-slide-up cursor-pointer items-start gap-3 rounded-card border border-edge bg-surface px-4 py-3.5 text-left text-sm leading-relaxed transition-all duration-150 active:scale-[0.985] active:border-edge-active active:bg-surface-active"
style={{
animationDelay: `${Math.min(i, 10) * 40}ms`,
}}
>
<span className="flex-1 break-words">{item.text}</span>
<span className="shrink-0 whitespace-nowrap pt-0.5 text-[11px] text-fg-dim tabular-nums">
{formatTime(item.ts)}
</span>
</button>
))}
</div>
)}
</section>
);
}

View File

@@ -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<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
onStart();
},
[onStart],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
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 (
<section className="flex shrink-0 flex-col items-center gap-3.5 pt-5 pb-4">
<div className="relative flex touch-none items-center justify-center">
<button
type="button"
disabled={!connected}
className={`relative z-1 flex size-24 cursor-pointer touch-none select-none items-center justify-center rounded-full border-2 transition-all duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] ${
!connected
? disabledClasses
: isActive
? activeClasses
: idleClasses
}`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
>
<svg
viewBox="0 0 24 24"
width={48}
height={48}
fill="currentColor"
aria-label="\u9ea6\u514b\u98ce"
role="img"
>
<title>{"\u9ea6\u514b\u98ce"}</title>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</button>
{/* Wave rings */}
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{[0, 0.8, 1.6].map((delay) => (
<span
key={delay}
className={`absolute inset-0 rounded-full border-[1.5px] border-accent ${
isActive ? "animate-ring-expand" : "opacity-0"
}`}
style={isActive ? { animationDelay: `${delay}s` } : undefined}
/>
))}
</div>
</div>
<p className="font-medium text-fg-dim text-sm">
{"\u6309\u4f4f\u8bf4\u8bdd"}
</p>
</section>
);
}

View File

@@ -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 (
<section className="shrink-0 pb-3">
<div
className={`scrollbar-thin max-h-40 min-h-20 overflow-y-auto rounded-card border px-[18px] py-4 transition-all duration-300 ${
active
? "border-accent/40 bg-linear-to-b from-accent/[0.03] to-surface shadow-[0_0_0_1px_rgba(99,102,241,0.2),0_4px_24px_-4px_rgba(99,102,241,0.15)]"
: "border-edge bg-surface"
}`}
>
<p
className={`break-words text-base leading-relaxed ${hasText ? "" : "text-fg-dim"}`}
>
{hasText ? text : "\u6309\u4f4f\u8bf4\u8bdd\u2026"}
</p>
</div>
</section>
);
}

View File

@@ -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 (
<div
className={`flex items-center gap-[7px] rounded-full border bg-surface px-3 py-[5px] font-medium text-fg-dim text-xs transition-all ${borderClass}`}
>
<span
className={`size-[7px] shrink-0 rounded-full transition-all duration-300 ${dotClass}`}
/>
<span>{text}</span>
</div>
);
}

View File

@@ -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<ReturnType<typeof setTimeout> | 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 (
<div
className={`pointer-events-none fixed bottom-[calc(80px+env(safe-area-inset-bottom,0px))] left-1/2 z-[999] rounded-3xl border border-edge bg-[rgba(28,28,37,0.85)] px-[22px] py-2.5 font-medium text-[13px] text-fg shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-[16px] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
toast
? "-translate-x-1/2 translate-y-0 opacity-100"
: "-translate-x-1/2 translate-y-2 opacity-0"
}`}
>
{toast}
</div>
);
}

View File

@@ -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<string, unknown>) => void;
sendBinary: (data: Int16Array) => void;
}
export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) {
const audioCtxRef = useRef<AudioContext | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const abortRef = useRef<AbortController | null>(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 };
}

View File

@@ -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<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef(WS_RECONNECT_BASE);
const sendJSON = useCallback((obj: Record<string, unknown>) => {
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 };
}

23
web/src/lib/resample.ts Normal file
View File

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

13
web/src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -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<AppState>((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 }),
}));

View File

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

View File

@@ -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"]
}

View File

@@ -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",