"use client"; import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { SHORTLIST_EVENT, type Shortlist, type ShortlistSummary, } from "@/lib/shortlist-types"; // The lists, and the active one's order. // // ORDER IS THE EDIT, so this is where it gets decided -- and every move sends a // WHOLE PERMUTATION rather than "move item 3 up". Atomic, idempotent, and a // disagreement about what the list holds comes back as a 400 instead of being // reconciled into an order nobody asked for. const mmss = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; export default function ShortlistBar({ lists, active, qs, }: { lists: ShortlistSummary[]; active: Shortlist | null; /** The page's current query string, so a list chip keeps the search. */ qs: string; }) { const router = useRouter(); const [all, setAll] = useState(lists); const [list, setList] = useState(active); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => setAll(lists), [lists]); useEffect(() => setList(active), [active]); // The rows star; this shows what was starred. One event, carrying the whole // list the server just wrote. useEffect(() => { const onChange = (e: Event) => { const l = (e as CustomEvent).detail; if (!l) return; setList((cur) => (cur && cur.slug === l.slug ? l : cur)); setAll((cur) => cur.map((s) => s.slug === l.slug ? { ...s, picks: l.picks.length, updated: l.updated } : s, ), ); }; window.addEventListener(SHORTLIST_EVENT, onChange); return () => window.removeEventListener(SHORTLIST_EVENT, onChange); }, []); const post = useCallback(async (body: unknown): Promise => { setBusy(true); setError(null); try { const res = await fetch("/api/shortlist", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); const j = (await res.json()) as { list?: Shortlist; error?: string }; if (!res.ok) { setError(j.error ?? `failed (${res.status})`); return null; } if (j.list) { setList(j.list); window.dispatchEvent(new CustomEvent(SHORTLIST_EVENT, { detail: j.list })); } return j.list ?? null; } catch { setError("could not reach the shortlist"); return null; } finally { setBusy(false); } }, []); const href = (slug: string | null) => { const p = new URLSearchParams(qs); if (slug) p.set("list", slug); else p.delete("list"); const s = p.toString(); return `/browse/find${s ? `?${s}` : ""}`; }; const move = async (i: number, d: number) => { if (!list) return; const ids = list.picks.map((p) => p.id); const j = i + d; if (j < 0 || j >= ids.length) return; [ids[i], ids[j]] = [ids[j], ids[i]]; await post({ op: "reorder", list: list.slug, ids }); }; return (
shortlist {all.length === 0 && none yet} {all.map((s) => ( {s.name} {s.picks} ))}
{ e.preventDefault(); if (!name.trim()) return; const made = await post({ op: "create", name }); if (made) { setName(""); router.push(href(made.slug)); } }} > setName(e.target.value)} onKeyDown={(e) => e.stopPropagation()} placeholder="new list" aria-label="new shortlist name" className="w-36 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] outline-none focus:border-[var(--color-sel)]" />
{list && ( export json markdown close )}
{error && (

{error}

)} {!list && all.length > 0 && (

Pick one to star into it — s on a row.

)} {list && (
    {list.picks.length === 0 && (
  1. Empty. s stars the focused row into it; the order you build here is the edit.
  2. )} {list.picks.map((p, i) => (
  3. {i + 1} {p.text} {p.video} {mmss(p.start)} {p.suspect && ( flagged source )} e.stopPropagation()} onBlur={(e) => { if ((e.target.value || "") === (p.note ?? "")) return; void post({ op: "note", list: list.slug, id: p.id, note: e.target.value }); }} className="min-w-[8rem] flex-1 rounded border border-transparent bg-transparent px-1 text-[11px] text-[var(--color-dim)] outline-none focus:border-[var(--color-line)] focus:text-[var(--color-text)]" />
  4. ))}
)}
); }