Files
vibepod/web/components/Header.tsx
T
LyAhn 13085166fb feat(phase-1): persistent generation library
- Save every completed generation to SQLite (generation_store.py) with
  WAV and waveform peaks written to data/generations/<id>/
- Deferred DB write until success — cancelled/errored generations never
  touch the DB and never appear in the library
- Fixed cancel+regenerate IndexError: _reset_scheduler_caches() now
  directly zeros scheduler._step_index and running state in addition to
  clearing VibePod cache dicts; same explicit resets added in the fresh
  path of prepare_noise_scheduler as belt-and-suspenders
- Added /library page with GenerationCard, WaveformPreview, waveform
  fetch, play/pause, download, delete, pagination, empty + error states
- Added generation API routes (list, single, audio stream, waveform,
  delete) proxying to Python server
- Added Library nav link to Header with active state
- Persist script/speaker/CFG to localStorage so generate page state
  survives navigation
- Updated build plan: Phase 0+1 ticked off, better-sqlite3 moved to
  Phase 2, architectural note on Python owning all persistence
2026-05-02 23:05:11 +01:00

206 lines
6.5 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
type ServerStatus = "checking" | "downloading" | "loading" | "online" | "error" | "offline";
type Device = "cpu" | "cuda" | null;
// Polling intervals: poll quickly until the server is online, then slow down.
const FAST_INTERVAL_MS = 3000; // while checking / loading
const SLOW_INTERVAL_MS = 30000; // once online
const NAV_LINKS = [
{ href: "/", label: "Generate" },
{ href: "/library", label: "Library" },
];
export default function Header() {
const pathname = usePathname();
const [status, setStatus] = useState<ServerStatus>("checking");
const [device, setDevice] = useState<Device>(null);
const [message, setMessage] = useState<string | undefined>();
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const checkHealth = async () => {
try {
const res = await fetch("/api/health", { cache: "no-store" });
const data = await res.json();
const newStatus: ServerStatus = (data.status as ServerStatus) ?? "offline";
setStatus(newStatus);
setDevice((data.device as Device) ?? null);
setMessage(data.message);
// Switch to slow polling once we know the server is online
if (newStatus === "online" && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
}
// Switch to fast polling if we detect the server went offline/loading
if (
(newStatus === "offline" || newStatus === "downloading" || newStatus === "loading") &&
intervalRef.current
) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
}
} catch {
setStatus("offline");
setDevice(null);
setMessage(undefined);
}
};
// Start with a fast poll — the server may still be loading the model
checkHealth();
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
const statusConfig: Record<
ServerStatus,
{ color: string; label: string; pulse: boolean; ring: string }
> = {
checking: {
color: "bg-yellow-500",
label: "Checking…",
pulse: true,
ring: "border-yellow-500/30",
},
loading: {
color: "bg-blue-400",
label: "Loading model…",
pulse: true,
ring: "border-blue-400/30",
},
downloading: {
color: "bg-sky-400",
label: "Downloading model…",
pulse: true,
ring: "border-sky-400/30",
},
online: {
color: "bg-green-500",
label: "Server Online",
pulse: false,
ring: "border-green-500/30",
},
error: {
color: "bg-orange-500",
label: "Model Error",
pulse: false,
ring: "border-orange-500/30",
},
offline: {
color: "bg-red-500",
label: "Server Offline",
pulse: false,
ring: "border-red-500/30",
},
};
const cfg = statusConfig[status];
// Device badge — only shown once the server is online and device is known
const deviceBadge =
status === "online" && device ? (
<span
className="px-2 py-0.5 rounded-full text-xs font-semibold tracking-wide uppercase"
style={{
background: device === "cuda" ? "var(--accent-violet-dim)" : "var(--accent-teal-dim)",
color: device === "cuda" ? "var(--accent-violet)" : "var(--accent-teal)",
border: `1px solid ${device === "cuda" ? "var(--accent-violet-dim)" : "var(--accent-teal-dim)"}`,
}}
title={device === "cuda" ? "Running on NVIDIA GPU" : "Running on CPU"}
>
{device.toUpperCase()}
</span>
) : null;
return (
<header
className="border-b px-6 py-4 flex items-center justify-between"
style={{
background: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold"
style={{
background:
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
}}
>
🎙
</div>
<div>
<h1
className="text-xl font-bold tracking-tight"
style={{
background: "linear-gradient(135deg, var(--accent-teal), var(--accent-violet))",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
VibePod
</h1>
<p className="text-xs" style={{ color: "var(--muted)" }}>
Powered by VibeVoice 0.5B
</p>
</div>
</div>
{/* Nav links */}
<nav className="flex items-center gap-1 ml-2">
{NAV_LINKS.map(({ href, label }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: active ? "var(--accent-teal-dim)" : "transparent",
color: active ? "var(--accent-teal)" : "var(--muted)",
}}
>
{label}
</Link>
);
})}
</nav>
</div>
<div className="flex items-center gap-2">
{deviceBadge}
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border ${cfg.ring}`}
style={{
background: "var(--background)",
borderColor: "var(--border)",
}}
title={message}
>
<span className="relative flex h-2 w-2">
{cfg.pulse && (
<span
className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${cfg.color}`}
/>
)}
<span className={`relative inline-flex rounded-full h-2 w-2 ${cfg.color}`} />
</span>
<span style={{ color: "var(--foreground)" }}>{cfg.label}</span>
</div>
</div>
</header>
);
}