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:
@@ -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 <button className="menu-item">…</button>. */
|
||||||
|
export function KebabMenu({ label = "Actions", children }: { label?: string; children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="menu" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu-trigger"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="menu-pop" role="menu" onClick={() => setOpen(false)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -386,6 +386,32 @@ nav.contents .sep { flex: 1; }
|
|||||||
.tab:hover { color: var(--ink); }
|
.tab:hover { color: var(--ink); }
|
||||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||||
.tab .n { color: var(--rule-2); margin-left: 6px; }
|
.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) ───── */
|
/* ── Discography row (cover thumb + click-to-modal) ───── */
|
||||||
.disco-open {
|
.disco-open {
|
||||||
|
|||||||
+171
-120
@@ -7,8 +7,28 @@ import { SectionHeader } from "./_ui/section-header";
|
|||||||
import { JobRow } from "./_ui/job-row";
|
import { JobRow } from "./_ui/job-row";
|
||||||
import { ProgressBar } from "./_ui/progress-bar";
|
import { ProgressBar } from "./_ui/progress-bar";
|
||||||
import { PressedList } from "./_ui/pressed-list";
|
import { PressedList } from "./_ui/pressed-list";
|
||||||
|
import { KebabMenu } from "./_ui/kebab-menu";
|
||||||
import { toast } from "./_ui/toast";
|
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 = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
@@ -60,6 +80,9 @@ export function Queue() {
|
|||||||
const [artist, setArtist] = useState("");
|
const [artist, setArtist] = useState("");
|
||||||
const [album, setAlbum] = useState("");
|
const [album, setAlbum] = useState("");
|
||||||
const [paused, setPaused] = useState(false);
|
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() {
|
async function refresh() {
|
||||||
const [reqRes, wantRes, artRes, floorRes] = await Promise.all([
|
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 active = rows.filter((r) => jobOf(r).state !== "imported");
|
||||||
const downloading = rows.some((r) => {
|
const buckets: Record<Tab, Row[]> = { active: [], queue: [], errors: [] };
|
||||||
const s = jobOf(r).state;
|
for (const r of active) {
|
||||||
return s === "matching" || s === "matched" || s === "downloading" || s === "tagging";
|
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
|
// 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).
|
// completed Requests; show each album once (rows arrive newest-first, so keep the first).
|
||||||
const seenPressed = new Set<string>();
|
const seenPressed = new Set<string>();
|
||||||
@@ -174,6 +205,99 @@ export function Queue() {
|
|||||||
return true;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StatTiles
|
<StatTiles
|
||||||
@@ -185,27 +309,29 @@ export function Queue() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader title="On the Press" note={`${active.length} running`} />
|
<div className="dept has-actions">
|
||||||
|
<h2>On the Press</h2>
|
||||||
<div className="press-controls">
|
<span className="fill" />
|
||||||
{paused ? (
|
{paused ? <span className="count">paused</span> : null}
|
||||||
<button className="btn sm accent" onClick={() => floorAction("resume", "Press resumed")}>
|
<KebabMenu label="Press actions">
|
||||||
Resume the press
|
{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>
|
||||||
) : (
|
{downloading ? (
|
||||||
<button className="btn sm ghost" onClick={() => floorAction("pause", "Press paused")}>
|
<button className="menu-item danger" onClick={stopPress}>
|
||||||
Pause the press
|
Stop now
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
<button className="btn sm ghost" onClick={clearPress}>
|
</KebabMenu>
|
||||||
Clear queue
|
|
||||||
</button>
|
|
||||||
{downloading ? (
|
|
||||||
<button className="btn sm" onClick={stopPress}>
|
|
||||||
Stop now
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{paused ? <span className="chip">Press paused</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="request-form" onSubmit={submit}>
|
<form className="request-form" onSubmit={submit}>
|
||||||
@@ -225,102 +351,27 @@ export function Queue() {
|
|||||||
{active.length === 0 ? (
|
{active.length === 0 ? (
|
||||||
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
|
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
|
||||||
) : (
|
) : (
|
||||||
<section className="floor">
|
<>
|
||||||
{(() => {
|
<div className="tabs" role="tablist">
|
||||||
const now = Date.now();
|
{(["active", "queue", "errors"] as Tab[]).map((t) => (
|
||||||
return active.map((r) => {
|
<button
|
||||||
const j = jobOf(r);
|
key={t}
|
||||||
const d = describeJob(j.state, j.currentStage);
|
role="tab"
|
||||||
const attention = d.kind === "attention";
|
aria-selected={activeTab === t}
|
||||||
|
className={`tab${activeTab === t ? " active" : ""}${t === "errors" && buckets.errors.length ? " has-errors" : ""}`}
|
||||||
let meta: string | undefined;
|
onClick={() => setTab(t)}
|
||||||
if (j.state === "downloading" || j.state === "tagging") {
|
>
|
||||||
const parts: string[] = [];
|
{TAB_LABEL[t]}
|
||||||
if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`);
|
<span className="n">{buckets[t].length}</span>
|
||||||
const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now);
|
</button>
|
||||||
if (elapsed) parts.push(elapsed);
|
))}
|
||||||
meta = parts.join(" · ") || undefined;
|
</div>
|
||||||
} else if (j.state === "matching" || j.state === "matched") {
|
{shown.length === 0 ? (
|
||||||
meta =
|
<p className="empty">{TAB_EMPTY[activeTab]}</p>
|
||||||
j.candidateCount > 0
|
) : (
|
||||||
? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}`
|
<section className="floor">{shown.map(renderRow)}</section>
|
||||||
: "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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pressed.length > 0 ? (
|
{pressed.length > 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user