diff --git a/web/src/app/_ui/kebab-menu.tsx b/web/src/app/_ui/kebab-menu.tsx new file mode 100644 index 0000000..658aaab --- /dev/null +++ b/web/src/app/_ui/kebab-menu.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; + +/** A vertical "⋮" trigger that opens a small popup menu. Closes on outside click, Escape, or any + * click inside the popup (so menu items don't each need to manage it). Children are the items — + * use …. */ +export function KebabMenu({ label = "Actions", children }: { label?: string; children: ReactNode }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function onDoc(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( + + setOpen((o) => !o)} + > + ⋮ + + {open ? ( + setOpen(false)}> + {children} + + ) : null} + + ); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index 4582afe..0bc14a7 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -386,6 +386,32 @@ nav.contents .sep { flex: 1; } .tab:hover { color: var(--ink); } .tab.active { color: var(--ink); border-bottom-color: var(--accent); } .tab .n { color: var(--rule-2); margin-left: 6px; } +.tab.has-errors .n { color: var(--alert); } + +/* ── Kebab menu (⋮ popup for press actions) ───────────── */ +.dept.has-actions { align-items: center; } +.dept.has-actions .fill { transform: none; } +.menu { position: relative; display: inline-flex; } +.menu-trigger { + font-size: 1.15rem; line-height: 1; color: var(--graphite); background: transparent; + border: 1px solid var(--rule); border-radius: 6px; width: 32px; height: 30px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; +} +.menu-trigger:hover { color: var(--ink); border-color: var(--rule-2); } +.menu-pop { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 190px; + background: var(--paper-2); border: 1px solid var(--rule-2); border-radius: 9px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); padding: 5px; + display: flex; flex-direction: column; gap: 1px; +} +.menu-item { + font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; + text-align: left; background: transparent; border: 0; color: var(--ink); + padding: 10px 11px; border-radius: 6px; cursor: pointer; white-space: nowrap; +} +.menu-item:hover { background: var(--rule); } +.menu-item:disabled { color: var(--rule-2); cursor: default; background: transparent; } +.menu-item.danger { color: var(--alert); } /* ── Discography row (cover thumb + click-to-modal) ───── */ .disco-open { diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index d50c4fe..d4de511 100644 --- a/web/src/app/queue.tsx +++ b/web/src/app/queue.tsx @@ -7,8 +7,28 @@ import { SectionHeader } from "./_ui/section-header"; import { JobRow } from "./_ui/job-row"; import { ProgressBar } from "./_ui/progress-bar"; import { PressedList } from "./_ui/pressed-list"; +import { KebabMenu } from "./_ui/kebab-menu"; import { toast } from "./_ui/toast"; +type Tab = "active" | "queue" | "errors"; + +// Split the on-the-press rows into three honest buckets so active presses aren't buried between +// queued items and errors. Active = being worked (searching/downloading/finishing), Queue = +// waiting to be claimed, Errors = needs attention. +function categoryOf(state: string, stage: string): Tab { + const kind = describeJob(state, stage).kind; + if (kind === "attention") return "errors"; + if (state === "requested") return "queue"; + return "active"; +} + +const TAB_LABEL: Record = { active: "Active", queue: "In queue", errors: "Errors" }; +const TAB_EMPTY: Record = { + active: "Nothing pressing right now.", + queue: "The queue is empty.", + errors: "No errors — everything's flowing.", +}; + type Row = { id: string; artist: string; @@ -60,6 +80,9 @@ export function Queue() { const [artist, setArtist] = useState(""); const [album, setAlbum] = useState(""); const [paused, setPaused] = useState(false); + // null = follow the smart default (Active if anything's active, else Queue, else Errors); once + // the user picks a tab, their choice sticks. + const [tab, setTab] = useState(null); async function refresh() { const [reqRes, wantRes, artRes, floorRes] = await Promise.all([ @@ -159,10 +182,18 @@ export function Queue() { } const active = rows.filter((r) => jobOf(r).state !== "imported"); - const downloading = rows.some((r) => { - const s = jobOf(r).state; - return s === "matching" || s === "matched" || s === "downloading" || s === "tagging"; - }); + const buckets: Record = { active: [], queue: [], errors: [] }; + for (const r of active) { + const j = jobOf(r); + buckets[categoryOf(j.state, j.currentStage)].push(r); + } + const queuedCount = buckets.queue.length; + const downloading = buckets.active.length > 0; + // Smart default: land on the tab that has something worth looking at. + const defaultTab: Tab = buckets.active.length ? "active" : queuedCount ? "queue" : "errors"; + const activeTab: Tab = tab ?? defaultTab; + const shown = buckets[activeTab]; + // Dedupe by artist+album — re-requesting/re-downloading the same album leaves several // completed Requests; show each album once (rows arrive newest-first, so keep the first). const seenPressed = new Set(); @@ -174,6 +205,99 @@ export function Queue() { return true; }); + const now = Date.now(); + function renderRow(r: Row) { + const j = jobOf(r); + const d = describeJob(j.state, j.currentStage); + const attention = d.kind === "attention"; + + let meta: string | undefined; + if (j.state === "downloading" || j.state === "tagging") { + const parts: string[] = []; + if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`); + const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now); + if (elapsed) parts.push(elapsed); + meta = parts.join(" · ") || undefined; + } else if (j.state === "matching" || j.state === "matched") { + meta = + j.candidateCount > 0 + ? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}` + : "Searching sources…"; + } else if (j.state === "requested") { + const elapsed = timeAgo(r.createdAt, now); + meta = elapsed ? `In the queue · ${elapsed}` : "In the queue"; + } else { + meta = undefined; + } + + const note = attention ? ( + <> + {j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "} + retry(r.id)}> + Retry + + > + ) : j.state === "requested" ? ( + + {j.paused ? ( + <> + Paused{" "} + pauseItem(r.id, false)}> + Resume + + > + ) : ( + pauseItem(r.id, true)}> + Pause + + )}{" "} + removeItem(r.id)}> + Remove + + + ) : undefined; + + let bar: React.ReactNode; + if (j.state === "matching" || j.state === "matched") { + bar = ; + } else if (j.state === "downloading") { + const pct = Math.round((j.downloadProgress || 0) * 100); + if (pct <= 0) { + // Bytes haven't started flowing yet — Qobuz login/metadata/artwork, a Soulseek peer + // queue, or a YouTube info-fetch. Show an indeterminate bar instead of a stuck-looking + // 0% (it flips to the % bar on the first byte). + bar = ; + } else { + const total = j.chosen?.trackCount ?? 0; + const done = total ? Math.round((pct / 100) * total) : 0; + bar = ( + + ); + } + } else if (j.state === "tagging") { + bar = ; + } else { + bar = undefined; + } + + return ( + + ); + } + return ( - - - - {paused ? ( - floorAction("resume", "Press resumed")}> - Resume the press + + On the Press + + {paused ? paused : null} + + {paused ? ( + floorAction("resume", "Press resumed")}> + Resume the press + + ) : ( + floorAction("pause", "Press paused")}> + Pause the press + + )} + + Clear queue - ) : ( - floorAction("pause", "Press paused")}> - Pause the press - - )} - - Clear queue - - {downloading ? ( - - Stop now - - ) : null} - {paused ? Press paused : null} + {downloading ? ( + + Stop now + + ) : null} + @@ -225,102 +351,27 @@ export function Queue() { {active.length === 0 ? ( The press is quiet. Queue a release above, or send one over from Wanted or Discover. ) : ( - - {(() => { - const now = Date.now(); - return active.map((r) => { - const j = jobOf(r); - const d = describeJob(j.state, j.currentStage); - const attention = d.kind === "attention"; - - let meta: string | undefined; - if (j.state === "downloading" || j.state === "tagging") { - const parts: string[] = []; - if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`); - const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now); - if (elapsed) parts.push(elapsed); - meta = parts.join(" · ") || undefined; - } else if (j.state === "matching" || j.state === "matched") { - meta = - j.candidateCount > 0 - ? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}` - : "Searching sources…"; - } else if (j.state === "requested") { - const elapsed = timeAgo(r.createdAt, now); - meta = elapsed ? `In the queue · ${elapsed}` : "In the queue"; - } else { - meta = undefined; - } - - const note = attention ? ( - <> - {j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "} - retry(r.id)}> - Retry - - > - ) : j.state === "requested" ? ( - - {j.paused ? ( - <> - Paused{" "} - pauseItem(r.id, false)}> - Resume - - > - ) : ( - pauseItem(r.id, true)}> - Pause - - )}{" "} - removeItem(r.id)}> - Remove - - - ) : undefined; - - let bar: React.ReactNode; - if (j.state === "matching" || j.state === "matched") { - bar = ; - } else if (j.state === "downloading") { - const pct = Math.round((j.downloadProgress || 0) * 100); - if (pct <= 0) { - // Bytes haven't started flowing yet — Qobuz login/metadata/artwork, a - // Soulseek peer queue, or a YouTube info-fetch. Show an indeterminate bar - // instead of a stuck-looking 0% (it flips to the % bar on the first byte). - bar = ; - } else { - const total = j.chosen?.trackCount ?? 0; - const done = total ? Math.round((pct / 100) * total) : 0; - bar = ( - - ); - } - } else if (j.state === "tagging") { - bar = ; - } else { - bar = undefined; - } - - return ( - - ); - }); - })()} - + <> + + {(["active", "queue", "errors"] as Tab[]).map((t) => ( + setTab(t)} + > + {TAB_LABEL[t]} + {buckets[t].length} + + ))} + + {shown.length === 0 ? ( + {TAB_EMPTY[activeTab]} + ) : ( + {shown.map(renderRow)} + )} + > )} {pressed.length > 0 ? (
The press is quiet. Queue a release above, or send one over from Wanted or Discover.
{TAB_EMPTY[activeTab]}