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:
40
AGENTS.md
40
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
|
||||
|
||||
|
||||
395
web/app.ts
395
web/app.ts
@@ -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();
|
||||
}
|
||||
108
web/bun.lock
108
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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
33
web/src/App.tsx
Normal 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
112
web/src/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
67
web/src/components/HistoryList.tsx
Normal file
67
web/src/components/HistoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
web/src/components/MicButton.tsx
Normal file
106
web/src/components/MicButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
web/src/components/PreviewBox.tsx
Normal file
25
web/src/components/PreviewBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
web/src/components/StatusBadge.tsx
Normal file
35
web/src/components/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
web/src/components/Toast.tsx
Normal file
29
web/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
web/src/hooks/useRecorder.ts
Normal file
127
web/src/hooks/useRecorder.ts
Normal 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 };
|
||||
}
|
||||
107
web/src/hooks/useWebSocket.ts
Normal file
107
web/src/hooks/useWebSocket.ts
Normal 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
23
web/src/lib/resample.ts
Normal 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
13
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
86
web/src/stores/app-store.ts
Normal file
86
web/src/stores/app-store.ts
Normal 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 }),
|
||||
}));
|
||||
234
web/style.css
234
web/style.css
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user