"use client"; import { useEffect, useState } from "react"; import type { Verdict, VerdictEntry } from "@/lib/browse"; // One file's judgement, small enough to sit in a variant row. // // This is the one place outside the deck where the verdict hues appear, and it // is the reason they are reserved: a row of these down a song page is the // judging loop that currently lives in scrollback. `undecided` is deliberately // NEUTRAL rather than a third hue -- it is the absence of a judgement. // // Buttons stay disabled until mount. A click that lands before React hydrates // fires nothing at all -- no request, no error -- and a verdict lost that way // is indistinguishable from one that was never given. const TONE: Record = { keep: { on: "border-[var(--color-good)] bg-[color-mix(in_srgb,var(--color-good)_18%,transparent)] text-[var(--color-good)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-good)] hover:text-[var(--color-good)]", label: "keep", }, undecided: { on: "border-[var(--color-line)] bg-[var(--color-panel-2)] text-[var(--color-text)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-text)]", label: "undecided", }, reject: { on: "border-[var(--color-bad)] bg-[color-mix(in_srgb,var(--color-bad)_18%,transparent)] text-[var(--color-bad)]", off: "border-[var(--color-line)] text-[var(--color-dim)] hover:border-[var(--color-bad)] hover:text-[var(--color-bad)]", label: "reject", }, }; const ORDER: Verdict[] = ["reject", "undecided", "keep"]; export default function VerdictChip({ song, rel, initial, }: { song: string; rel: string; initial: VerdictEntry | null; }) { const [entry, setEntry] = useState(initial); const [note, setNote] = useState(initial?.note ?? ""); const [editing, setEditing] = useState(false); const [ready, setReady] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => setReady(true), []); // A navigation back to this page re-renders the server value; adopt it rather // than keeping stale optimistic state from the previous visit. useEffect(() => { setEntry(initial); setNote(initial?.note ?? ""); }, [initial]); const current = entry?.verdict ?? "undecided"; const save = async (verdict: Verdict, nextNote: string) => { setBusy(true); setError(null); const optimistic: VerdictEntry = { verdict, note: nextNote || undefined, at: new Date().toISOString(), }; const before = entry; setEntry(optimistic); try { const res = await fetch("/api/browse/verdict", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, rel, verdict, note: nextNote }), cache: "no-store", }); if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`); const j = (await res.json()) as { entry: VerdictEntry }; setEntry(j.entry); } catch (e) { // Put the old value back. A chip that keeps showing a verdict the disk // never took is worse than one that flickers. setEntry(before); setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; return (
{ORDER.map((v) => { const on = current === v; return ( ); })}
{editing ? (
{ setEditing(false); void save(current, note); }} > setNote(e.target.value)} placeholder="why" aria-label={`note for ${rel}`} className="w-44 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" />
) : ( )} {error && {error}}
); }