feat(web): tabbed On-the-Press view + kebab menu for press actions

The press page interleaved active downloads, queued items, and errors in one long
list — hard to see what's actually pressing. Split the non-imported rows into three
tabs (Active / In queue / Errors) with live counts; the default tab follows the work
(Active if anything's running, else Queue, else Errors) until the user picks one.
Move the press-level actions (Pause/Resume, Clear queue, Stop now) off the toolbar
into a vertical "⋮" kebab menu on the right of the header — Clear queue disables when
the queue is empty, Stop now only shows while downloading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-20 19:41:11 +02:00
parent 434cd2b22c
commit db8b43b0e8
3 changed files with 244 additions and 120 deletions
+171 -120
View File
@@ -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<Tab, string> = { active: "Active", queue: "In queue", errors: "Errors" };
const TAB_EMPTY: Record<Tab, string> = {
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<Tab | null>(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<Tab, Row[]> = { 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<string>();
@@ -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."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : j.state === "requested" ? (
<span className="item-controls">
{j.paused ? (
<>
<span className="chip">Paused</span>{" "}
<button className="btn sm ghost" onClick={() => pauseItem(r.id, false)}>
Resume
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => pauseItem(r.id, true)}>
Pause
</button>
)}{" "}
<button className="btn sm ghost" onClick={() => removeItem(r.id)}>
Remove
</button>
</span>
) : undefined;
let bar: React.ReactNode;
if (j.state === "matching" || j.state === "matched") {
bar = <ProgressBar kind="working" indeterminate caption="searching" />;
} 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 = <ProgressBar kind="working" indeterminate caption="preparing…" />;
} else {
const total = j.chosen?.trackCount ?? 0;
const done = total ? Math.round((pct / 100) * total) : 0;
bar = (
<ProgressBar
kind="working"
percent={pct}
caption={total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`}
/>
);
}
} else if (j.state === "tagging") {
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
} else {
bar = undefined;
}
return (
<JobRow
key={r.id}
artist={r.artist}
album={r.album}
kind={d.kind}
label={d.label}
meta={meta}
note={note}
bar={bar}
/>
);
}
return (
<div>
<StatTiles
@@ -185,27 +309,29 @@ export function Queue() {
]}
/>
<SectionHeader title="On the Press" note={`${active.length} running`} />
<div className="press-controls">
{paused ? (
<button className="btn sm accent" onClick={() => floorAction("resume", "Press resumed")}>
Resume the press
<div className="dept has-actions">
<h2>On the Press</h2>
<span className="fill" />
{paused ? <span className="count">paused</span> : null}
<KebabMenu label="Press actions">
{paused ? (
<button className="menu-item" onClick={() => floorAction("resume", "Press resumed")}>
Resume the press
</button>
) : (
<button className="menu-item" onClick={() => floorAction("pause", "Press paused")}>
Pause the press
</button>
)}
<button className="menu-item" onClick={clearPress} disabled={queuedCount === 0}>
Clear queue
</button>
) : (
<button className="btn sm ghost" onClick={() => floorAction("pause", "Press paused")}>
Pause the press
</button>
)}
<button className="btn sm ghost" onClick={clearPress}>
Clear queue
</button>
{downloading ? (
<button className="btn sm" onClick={stopPress}>
Stop now
</button>
) : null}
{paused ? <span className="chip">Press paused</span> : null}
{downloading ? (
<button className="menu-item danger" onClick={stopPress}>
Stop now
</button>
) : null}
</KebabMenu>
</div>
<form className="request-form" onSubmit={submit}>
@@ -225,102 +351,27 @@ export function Queue() {
{active.length === 0 ? (
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
) : (
<section className="floor">
{(() => {
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."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : j.state === "requested" ? (
<span className="item-controls">
{j.paused ? (
<>
<span className="chip">Paused</span>{" "}
<button className="btn sm ghost" onClick={() => pauseItem(r.id, false)}>
Resume
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => pauseItem(r.id, true)}>
Pause
</button>
)}{" "}
<button className="btn sm ghost" onClick={() => removeItem(r.id)}>
Remove
</button>
</span>
) : undefined;
let bar: React.ReactNode;
if (j.state === "matching" || j.state === "matched") {
bar = <ProgressBar kind="working" indeterminate caption="searching" />;
} 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 = <ProgressBar kind="working" indeterminate caption="preparing…" />;
} else {
const total = j.chosen?.trackCount ?? 0;
const done = total ? Math.round((pct / 100) * total) : 0;
bar = (
<ProgressBar
kind="working"
percent={pct}
caption={total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`}
/>
);
}
} else if (j.state === "tagging") {
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
} else {
bar = undefined;
}
return (
<JobRow
key={r.id}
artist={r.artist}
album={r.album}
kind={d.kind}
label={d.label}
meta={meta}
note={note}
bar={bar}
/>
);
});
})()}
</section>
<>
<div className="tabs" role="tablist">
{(["active", "queue", "errors"] as Tab[]).map((t) => (
<button
key={t}
role="tab"
aria-selected={activeTab === t}
className={`tab${activeTab === t ? " active" : ""}${t === "errors" && buckets.errors.length ? " has-errors" : ""}`}
onClick={() => setTab(t)}
>
{TAB_LABEL[t]}
<span className="n">{buckets[t].length}</span>
</button>
))}
</div>
{shown.length === 0 ? (
<p className="empty">{TAB_EMPTY[activeTab]}</p>
) : (
<section className="floor">{shown.map(renderRow)}</section>
)}
</>
)}
{pressed.length > 0 ? (