techfreakworm commited on
Commit
b2d4d0e
·
unverified ·
1 Parent(s): 96f2542

feat(web): UI components — ParamsPanel, TagBar, ModelPicker, DeviceBadge, VoiceLibrary, HistoryList, VoiceComposer

Browse files
web/src/components/DeviceBadge.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ export default function DeviceBadge() {
4
+ const [device, setDevice] = useState<string>("?");
5
+ useEffect(() => {
6
+ fetch("/api/health")
7
+ .then((r) => r.json())
8
+ .then((d) => setDevice(d.device))
9
+ .catch(() => setDevice("offline"));
10
+ }, []);
11
+ return (
12
+ <span className="text-xs px-2 py-0.5 rounded-md border border-border text-muted-foreground">
13
+ {device}
14
+ </span>
15
+ );
16
+ }
web/src/components/HistoryList.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { listHistory, type HistoryRecord } from "@/lib/idb";
3
+
4
+ type Props = {
5
+ refreshKey?: number;
6
+ onRegenerate: (h: HistoryRecord) => void;
7
+ };
8
+
9
+ export default function HistoryList({ refreshKey, onRegenerate }: Props) {
10
+ const [items, setItems] = useState<HistoryRecord[]>([]);
11
+ useEffect(() => {
12
+ listHistory().then(setItems);
13
+ }, [refreshKey]);
14
+
15
+ if (items.length === 0) {
16
+ return <p className="text-sm text-muted-foreground">No generations yet.</p>;
17
+ }
18
+
19
+ return (
20
+ <ul className="space-y-2">
21
+ {items.map((h) => {
22
+ const url = URL.createObjectURL(h.audioBlob);
23
+ return (
24
+ <li key={h.id} className="rounded-md border border-border p-2 space-y-2">
25
+ <div className="text-sm line-clamp-2">{h.text}</div>
26
+ <div className="text-xs text-muted-foreground">
27
+ {h.modelId} · {h.language ?? "—"} · {new Date(h.createdAt).toLocaleTimeString()}
28
+ </div>
29
+ <audio controls src={url} className="w-full" />
30
+ <div className="flex justify-end gap-2">
31
+ <a href={url} download={`${h.id}.wav`} className="text-xs underline">download</a>
32
+ <button type="button" className="text-xs underline" onClick={() => onRegenerate(h)}>
33
+ regenerate
34
+ </button>
35
+ </div>
36
+ </li>
37
+ );
38
+ })}
39
+ </ul>
40
+ );
41
+ }
web/src/components/ModelPicker.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ModelInfo } from "@/lib/api";
2
+
3
+ type Props = {
4
+ models: ModelInfo[];
5
+ activeId: string | null;
6
+ loading: boolean;
7
+ onPick: (id: string) => void;
8
+ };
9
+
10
+ export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
11
+ return (
12
+ <select
13
+ aria-label="Model"
14
+ disabled={loading || models.length === 0}
15
+ value={activeId ?? ""}
16
+ onChange={(e) => onPick(e.target.value)}
17
+ className="rounded-md border border-border bg-background px-2 py-1 text-sm"
18
+ >
19
+ <option value="" disabled>
20
+ Choose model…
21
+ </option>
22
+ {models.map((m) => (
23
+ <option key={m.id} value={m.id}>
24
+ {m.label}
25
+ </option>
26
+ ))}
27
+ </select>
28
+ );
29
+ }
web/src/components/ParamsPanel.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ParamSpec } from "@/lib/api";
2
+
3
+ type Props = {
4
+ specs: ParamSpec[];
5
+ values: Record<string, unknown>;
6
+ onChange: (next: Record<string, unknown>) => void;
7
+ };
8
+
9
+ export default function ParamsPanel({ specs, values, onChange }: Props) {
10
+ function set(name: string, v: unknown) {
11
+ onChange({ ...values, [name]: v });
12
+ }
13
+ return (
14
+ <div className="space-y-4">
15
+ {specs.map((s) => {
16
+ const id = `param-${s.name}`;
17
+ const current = (values[s.name] ?? s.default) as never;
18
+ if (s.type === "float" || s.type === "int") {
19
+ return (
20
+ <label key={s.name} htmlFor={id} className="block space-y-1">
21
+ <span className="text-sm">{s.label}</span>
22
+ <input
23
+ id={id}
24
+ aria-label={s.label}
25
+ type="range"
26
+ min={s.min}
27
+ max={s.max}
28
+ step={s.step ?? 0.01}
29
+ value={current as number}
30
+ onChange={(e) => set(s.name, Number(e.target.value))}
31
+ className="w-full"
32
+ />
33
+ <span className="text-xs text-muted-foreground">{String(current)}</span>
34
+ </label>
35
+ );
36
+ }
37
+ if (s.type === "bool") {
38
+ return (
39
+ <label key={s.name} htmlFor={id} className="flex items-center justify-between text-sm">
40
+ <span>{s.label}</span>
41
+ <input
42
+ id={id}
43
+ aria-label={s.label}
44
+ type="checkbox"
45
+ checked={!!current}
46
+ onChange={(e) => set(s.name, e.target.checked)}
47
+ />
48
+ </label>
49
+ );
50
+ }
51
+ return (
52
+ <label key={s.name} htmlFor={id} className="block space-y-1">
53
+ <span className="text-sm">{s.label}</span>
54
+ <select
55
+ id={id}
56
+ aria-label={s.label}
57
+ value={current as string}
58
+ onChange={(e) => set(s.name, e.target.value)}
59
+ className="w-full rounded-md border border-border bg-background px-2 py-1"
60
+ >
61
+ {(s.choices ?? []).map((c) => (
62
+ <option key={c} value={c}>{c}</option>
63
+ ))}
64
+ </select>
65
+ </label>
66
+ );
67
+ })}
68
+ </div>
69
+ );
70
+ }
web/src/components/TagBar.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RefObject } from "react";
2
+
3
+ type Props = {
4
+ tags: string[];
5
+ targetRef: RefObject<HTMLTextAreaElement>;
6
+ };
7
+
8
+ export default function TagBar({ tags, targetRef }: Props) {
9
+ if (tags.length === 0) return null;
10
+ function insert(tag: string) {
11
+ const el = targetRef.current;
12
+ if (!el) return;
13
+ const start = el.selectionStart ?? el.value.length;
14
+ const end = el.selectionEnd ?? start;
15
+ const before = el.value.slice(0, start);
16
+ const after = el.value.slice(end);
17
+ const native = Object.getOwnPropertyDescriptor(
18
+ window.HTMLTextAreaElement.prototype,
19
+ "value",
20
+ )?.set;
21
+ native?.call(el, before + tag + after);
22
+ el.dispatchEvent(new Event("input", { bubbles: true }));
23
+ const cursor = start + tag.length;
24
+ el.setSelectionRange(cursor, cursor);
25
+ el.focus();
26
+ }
27
+ return (
28
+ <div className="flex flex-wrap gap-1.5">
29
+ {tags.map((t) => (
30
+ <button
31
+ key={t}
32
+ type="button"
33
+ onClick={() => insert(t)}
34
+ className="text-xs px-2 py-0.5 rounded-md border border-border hover:bg-muted"
35
+ >
36
+ {t}
37
+ </button>
38
+ ))}
39
+ </div>
40
+ );
41
+ }
web/src/components/VoiceComposer.tsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState } from "react";
2
+ import { Recorder } from "@/lib/audio";
3
+ import { addVoice } from "@/lib/idb";
4
+
5
+ type Props = {
6
+ onSaved: () => void;
7
+ };
8
+
9
+ export default function VoiceComposer({ onSaved }: Props) {
10
+ const fileRef = useRef<HTMLInputElement>(null);
11
+ const recorderRef = useRef<Recorder | null>(null);
12
+ const [recState, setRecState] = useState<"idle" | "recording" | "stopping" | "error">("idle");
13
+ const [name, setName] = useState("");
14
+
15
+ async function importBlob(blob: Blob, defaultName: string) {
16
+ const arr = new Uint8Array(await blob.arrayBuffer());
17
+ const ctx = new AudioContext();
18
+ const buf = await ctx.decodeAudioData(arr.buffer.slice(0));
19
+ await addVoice({
20
+ name: name || defaultName || `voice-${Date.now()}`,
21
+ blob,
22
+ sampleRate: buf.sampleRate,
23
+ durationMs: Math.round(buf.duration * 1000),
24
+ });
25
+ setName("");
26
+ onSaved();
27
+ }
28
+
29
+ async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
30
+ const f = e.target.files?.[0];
31
+ if (!f) return;
32
+ await importBlob(f, f.name.replace(/\.[^.]+$/, ""));
33
+ e.target.value = "";
34
+ }
35
+
36
+ async function startRec() {
37
+ const r = new Recorder();
38
+ recorderRef.current = r;
39
+ try {
40
+ await r.start();
41
+ setRecState("recording");
42
+ } catch {
43
+ setRecState("error");
44
+ }
45
+ }
46
+
47
+ async function stopRec() {
48
+ setRecState("stopping");
49
+ const blob = await recorderRef.current?.stop();
50
+ setRecState("idle");
51
+ if (blob) await importBlob(blob, "recorded");
52
+ }
53
+
54
+ return (
55
+ <div className="space-y-2">
56
+ <input
57
+ type="text"
58
+ placeholder="Voice name (optional)"
59
+ value={name}
60
+ onChange={(e) => setName(e.target.value)}
61
+ className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm"
62
+ />
63
+ <div className="flex gap-2">
64
+ <button
65
+ type="button"
66
+ onClick={() => fileRef.current?.click()}
67
+ className="rounded-md border border-border px-3 py-1.5 text-sm"
68
+ >
69
+ Upload .wav/.mp3
70
+ </button>
71
+ <input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
72
+ {recState === "recording" ? (
73
+ <button
74
+ type="button"
75
+ onClick={stopRec}
76
+ className="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm"
77
+ >
78
+ Stop &amp; save
79
+ </button>
80
+ ) : (
81
+ <button
82
+ type="button"
83
+ onClick={startRec}
84
+ className="rounded-md border border-border px-3 py-1.5 text-sm"
85
+ >
86
+ Record
87
+ </button>
88
+ )}
89
+ </div>
90
+ {recState === "error" && (
91
+ <p className="text-xs text-red-500">Microphone permission denied.</p>
92
+ )}
93
+ </div>
94
+ );
95
+ }
web/src/components/VoiceLibrary.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { deleteVoice, listVoices, setFavorite, type VoiceRecord } from "@/lib/idb";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ type Props = {
6
+ selectedId?: number;
7
+ onSelect: (v: VoiceRecord) => void;
8
+ refreshKey?: number;
9
+ };
10
+
11
+ export default function VoiceLibrary({ selectedId, onSelect, refreshKey }: Props) {
12
+ const [voices, setVoices] = useState<VoiceRecord[]>([]);
13
+ useEffect(() => {
14
+ listVoices().then(setVoices);
15
+ }, [refreshKey]);
16
+
17
+ if (voices.length === 0) {
18
+ return <p className="text-sm text-muted-foreground">No saved voices yet.</p>;
19
+ }
20
+
21
+ return (
22
+ <ul className="space-y-2">
23
+ {voices.map((v) => (
24
+ <li
25
+ key={v.id}
26
+ className={cn(
27
+ "flex items-center justify-between rounded-md border border-border p-2",
28
+ selectedId === v.id && "ring-1 ring-primary",
29
+ )}
30
+ >
31
+ <button
32
+ className="flex-1 text-left text-sm"
33
+ onClick={() => onSelect(v)}
34
+ type="button"
35
+ >
36
+ <div className="font-medium">{v.name}</div>
37
+ <div className="text-xs text-muted-foreground">
38
+ {(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
39
+ </div>
40
+ </button>
41
+ <div className="flex items-center gap-1">
42
+ <button
43
+ type="button"
44
+ aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
45
+ onClick={() => setFavorite(v.id!, !v.isFavorite).then(() => listVoices().then(setVoices))}
46
+ className="text-xs px-1"
47
+ >
48
+ {v.isFavorite ? "★" : "☆"}
49
+ </button>
50
+ <button
51
+ type="button"
52
+ aria-label="Delete"
53
+ onClick={() => deleteVoice(v.id!).then(() => listVoices().then(setVoices))}
54
+ className="text-xs px-1 text-muted-foreground"
55
+ >
56
+
57
+ </button>
58
+ </div>
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ );
63
+ }
web/src/test/ParamsPanel.test.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import ParamsPanel from "@/components/ParamsPanel";
4
+ import type { ParamSpec } from "@/lib/api";
5
+
6
+ const specs: ParamSpec[] = [
7
+ { name: "exaggeration", label: "Exaggeration", type: "float", default: 0.5, min: 0, max: 2, step: 0.05 },
8
+ { name: "is_fast", label: "Fast mode", type: "bool", default: false },
9
+ { name: "lang", label: "Lang", type: "enum", default: "en", choices: ["en", "fr"] },
10
+ ];
11
+
12
+ describe("ParamsPanel", () => {
13
+ it("renders one control per spec", () => {
14
+ render(<ParamsPanel specs={specs} values={{}} onChange={() => {}} />);
15
+ expect(screen.getByLabelText(/exaggeration/i)).toBeInTheDocument();
16
+ expect(screen.getByLabelText(/fast mode/i)).toBeInTheDocument();
17
+ expect(screen.getByLabelText(/^lang$/i)).toBeInTheDocument();
18
+ });
19
+
20
+ it("emits onChange with merged values", () => {
21
+ const onChange = vi.fn();
22
+ render(<ParamsPanel specs={specs} values={{}} onChange={onChange} />);
23
+ fireEvent.change(screen.getByLabelText(/exaggeration/i), { target: { value: "1.2" } });
24
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ exaggeration: 1.2 }));
25
+ });
26
+ });
web/src/test/TagBar.test.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { useRef } from "react";
4
+ import TagBar from "@/components/TagBar";
5
+
6
+ function Host({ tags }: { tags: string[] }) {
7
+ const ref = useRef<HTMLTextAreaElement>(null);
8
+ return (
9
+ <>
10
+ <textarea ref={ref} aria-label="text" defaultValue="hello world" />
11
+ <TagBar tags={tags} targetRef={ref} />
12
+ </>
13
+ );
14
+ }
15
+
16
+ describe("TagBar", () => {
17
+ it("inserts tag at cursor position", () => {
18
+ render(<Host tags={["[laugh]"]} />);
19
+ const ta = screen.getByLabelText("text") as HTMLTextAreaElement;
20
+ ta.focus();
21
+ ta.setSelectionRange(5, 5);
22
+ fireEvent.click(screen.getByRole("button", { name: /\[laugh\]/i }));
23
+ expect(ta.value).toBe("hello[laugh] world");
24
+ });
25
+
26
+ it("renders nothing when tags is empty", () => {
27
+ const { container } = render(<Host tags={[]} />);
28
+ expect(container.querySelectorAll("button").length).toBe(0);
29
+ });
30
+ });