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:
38
AGENTS.md
38
AGENTS.md
@@ -7,7 +7,7 @@ VoicePaste: phone as microphone via browser → LAN WebSocket → Go server →
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Go 1.25+, Fiber v3, fasthttp/websocket, CGO required (robotgo + clipboard)
|
- **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)
|
- **Tooling**: Taskfile (not Make), mise (Go + bun + task)
|
||||||
- **ASR**: Doubao Seed-ASR-2.0 via custom binary WebSocket protocol
|
- **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
|
asr/client.go # WSS client to Doubao, audio streaming, result forwarding
|
||||||
paste/paste.go # clipboard.Write + robotgo key simulation (Ctrl+V / Cmd+V)
|
paste/paste.go # clipboard.Write + robotgo key simulation (Ctrl+V / Cmd+V)
|
||||||
web/
|
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
|
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
|
## Code Style — Go
|
||||||
@@ -137,13 +153,15 @@ Per-connection loggers via `slog.With("remote", addr)`.
|
|||||||
- Target: ES2022, module: ESNext, bundler resolution
|
- Target: ES2022, module: ESNext, bundler resolution
|
||||||
- DOM + DOM.Iterable libs
|
- DOM + DOM.Iterable libs
|
||||||
|
|
||||||
### Patterns
|
- React 19 with functional components and hooks
|
||||||
- No framework — vanilla TypeScript with direct DOM manipulation
|
- Zustand for global state management (connection, recording, preview, history, toast)
|
||||||
- State object pattern: single `AppState` interface with mutable fields
|
- 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)
|
- Pointer Events for touch/mouse (not touch + mouse separately)
|
||||||
- AudioWorklet for audio capture (not MediaRecorder)
|
- AudioWorklet for audio capture (not MediaRecorder)
|
||||||
- `?worker&url` Vite import for AudioWorklet files
|
- `?worker&url` Vite import for AudioWorklet files
|
||||||
- WebSocket: binary for audio frames, JSON text for control messages
|
- WebSocket: binary for audio frames, JSON text for control messages
|
||||||
|
- Tailwind CSS v4 with `@theme` design tokens; minimal custom CSS (keyframes only)
|
||||||
|
|
||||||
## Language & Locale
|
## 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": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"zustand": "^5.0.11",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@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",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -14,6 +22,44 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"@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-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=="],
|
"@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=="],
|
"@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/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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
@@ -240,8 +342,14 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"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=="],
|
"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/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=="],
|
"@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>
|
<title>VoicePaste</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full bg-bg text-fg overflow-hidden select-none">
|
<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">
|
<div id="root"></div>
|
||||||
<header class="flex items-center justify-between pt-2 pb-5 shrink-0">
|
<script type="module" src="src/main.tsx"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,8 +12,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@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",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"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",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
@@ -11,5 +12,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
"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 tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
root: ".",
|
root: ".",
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user