Attention queues: no-PT-source and needs-decision (#89)
This commit was merged in pull request #89.
This commit is contained in:
+304
-6
@@ -6,6 +6,14 @@ import {
|
||||
seriesNeedsAttention,
|
||||
waiverLabel,
|
||||
} from "./library";
|
||||
import {
|
||||
type AttentionMovie,
|
||||
type AttentionQueues,
|
||||
allowEnglishAudio,
|
||||
attemptsLabel,
|
||||
attentionTotal,
|
||||
fetchAttention,
|
||||
} from "./queues";
|
||||
import {
|
||||
bucketOf,
|
||||
formatAudio,
|
||||
@@ -171,6 +179,8 @@ function main() {
|
||||
}
|
||||
|
||||
let inFlight = false;
|
||||
// assigned once the queues view exists; the poll keeps the badge honest
|
||||
let refreshQueuesBadge: (() => void) | null = null;
|
||||
|
||||
async function probe() {
|
||||
if (inFlight) {
|
||||
@@ -181,6 +191,7 @@ function main() {
|
||||
render(await probeHealth());
|
||||
probeButton.disabled = false;
|
||||
inFlight = false;
|
||||
refreshQueuesBadge?.();
|
||||
}
|
||||
|
||||
probeButton.addEventListener("click", () => {
|
||||
@@ -191,9 +202,21 @@ function main() {
|
||||
}, POLL_MS);
|
||||
void probe();
|
||||
|
||||
// views hide each other on open; the array is shared and filled once
|
||||
const views: HideableView[] = [];
|
||||
const releases = releasesMain();
|
||||
const library = libraryMain(board, releases);
|
||||
searchMain(board, releases, library);
|
||||
const library = libraryMain(board, releases, views);
|
||||
const queues = queuesMain(board, releases, views);
|
||||
views.push(library, queues);
|
||||
searchMain(board, releases, views);
|
||||
refreshQueuesBadge = () => {
|
||||
void queues.refreshBadge();
|
||||
};
|
||||
void queues.refreshBadge();
|
||||
}
|
||||
|
||||
interface HideableView {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
@@ -209,7 +232,7 @@ interface DeckRefs {
|
||||
manualIntake: HTMLElement;
|
||||
}
|
||||
|
||||
function searchMain(board: HTMLElement, releases: ReleasesView, library: LibraryView) {
|
||||
function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]) {
|
||||
const input = must<HTMLInputElement>("#search");
|
||||
const hint = must<HTMLElement>("#search-hint");
|
||||
const refs: DeckRefs = {
|
||||
@@ -251,14 +274,18 @@ function searchMain(board: HTMLElement, releases: ReleasesView, library: Library
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
releases.hide();
|
||||
library.hide();
|
||||
for (const view of views) {
|
||||
view.hide();
|
||||
}
|
||||
refs.deck.hidden = true;
|
||||
board.hidden = false;
|
||||
}
|
||||
|
||||
function showDeck() {
|
||||
releases.hide();
|
||||
library.hide();
|
||||
for (const view of views) {
|
||||
view.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
refs.deck.hidden = false;
|
||||
}
|
||||
@@ -1020,7 +1047,11 @@ interface LibraryGroup {
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
|
||||
function libraryMain(
|
||||
board: HTMLElement,
|
||||
releases: ReleasesView,
|
||||
views: HideableView[],
|
||||
): LibraryView {
|
||||
const view = must<HTMLElement>("#library");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-library");
|
||||
@@ -1131,6 +1162,9 @@ function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
|
||||
|
||||
function open() {
|
||||
releases.hide();
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
@@ -1211,6 +1245,270 @@ function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
|
||||
return item;
|
||||
}
|
||||
|
||||
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
|
||||
|
||||
interface QueuesView {
|
||||
hide: () => void;
|
||||
/** Refetches both queues and repaints the rail badge. */
|
||||
refreshBadge: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface QueueGroup {
|
||||
section: HTMLElement;
|
||||
count: HTMLElement;
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
|
||||
const view = must<HTMLElement>("#queues");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-queues");
|
||||
const badge = must<HTMLElement>("#queues-count");
|
||||
const summary = must<HTMLElement>("#queues-summary");
|
||||
const status = must<HTMLElement>("#queues-status");
|
||||
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
|
||||
noPt: {
|
||||
section: must<HTMLElement>("#queue-no-pt"),
|
||||
count: must<HTMLElement>("#count-no-pt"),
|
||||
rows: must<HTMLUListElement>("#rows-no-pt"),
|
||||
},
|
||||
decision: {
|
||||
section: must<HTMLElement>("#queue-decision"),
|
||||
count: must<HTMLElement>("#count-decision"),
|
||||
rows: must<HTMLUListElement>("#rows-decision"),
|
||||
},
|
||||
};
|
||||
|
||||
let roots: Root[] = [];
|
||||
// guards a stale fetch from painting over a newer view
|
||||
let sequence = 0;
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
status.hidden = text === null;
|
||||
status.textContent = text ?? "";
|
||||
if (tone) {
|
||||
status.dataset.tone = tone;
|
||||
} else {
|
||||
delete status.dataset.tone;
|
||||
}
|
||||
}
|
||||
|
||||
function clearGroups() {
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
group.section.hidden = true;
|
||||
group.rows.replaceChildren();
|
||||
}
|
||||
summary.textContent = "";
|
||||
}
|
||||
|
||||
function paintBadge(total: number) {
|
||||
badge.hidden = total === 0;
|
||||
badge.textContent = total === 0 ? "" : String(total);
|
||||
}
|
||||
|
||||
function emptyLine(group: QueueGroup) {
|
||||
if (group.rows.childElementCount === 0) {
|
||||
const none = document.createElement("li");
|
||||
none.className = "queue-empty readout dim";
|
||||
none.textContent = "queue empty";
|
||||
group.rows.append(none);
|
||||
}
|
||||
}
|
||||
|
||||
function entryCount(group: QueueGroup): number {
|
||||
return [...group.rows.children].filter((child) => !child.classList.contains("queue-empty"))
|
||||
.length;
|
||||
}
|
||||
|
||||
function paintCounts() {
|
||||
let total = 0;
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
const entries = entryCount(group);
|
||||
group.count.textContent = String(entries);
|
||||
total += entries;
|
||||
}
|
||||
paintBadge(total);
|
||||
summary.textContent = total === 0 ? "" : `${total} waiting`;
|
||||
}
|
||||
|
||||
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
|
||||
releases.open(movie, origin, view);
|
||||
};
|
||||
|
||||
/** Drops an emptied item without a refetch: the override IS written. */
|
||||
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
|
||||
item.remove();
|
||||
emptyLine(group);
|
||||
paintCounts();
|
||||
setStatus(text);
|
||||
}
|
||||
|
||||
function render(queues: AttentionQueues) {
|
||||
clearGroups();
|
||||
// both sections always render, even empty — this is the one surface
|
||||
// whose good news is an absence, and the operator visits to see it
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
group.section.hidden = false;
|
||||
}
|
||||
for (const movie of queues.no_pt_source) {
|
||||
groups.noPt.rows.append(
|
||||
noPtRow(movie, roots, openReleases, (item, text) => {
|
||||
settle(item, groups.noPt, text);
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const movie of queues.needs_decision) {
|
||||
groups.decision.rows.append(libraryRow(movie, roots, openReleases));
|
||||
}
|
||||
for (const group of [groups.noPt, groups.decision]) {
|
||||
emptyLine(group);
|
||||
}
|
||||
paintCounts();
|
||||
setStatus(attentionTotal(queues) === 0 ? "nothing needs attention" : null);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
sequence += 1;
|
||||
const ticket = sequence;
|
||||
setStatus("reading queues…");
|
||||
const [outcome, fetchedRoots] = await Promise.all([
|
||||
fetchAttention(),
|
||||
allRoots().catch(() => roots),
|
||||
]);
|
||||
if (ticket !== sequence) {
|
||||
return;
|
||||
}
|
||||
roots = fetchedRoots;
|
||||
if (outcome.kind === "error") {
|
||||
clearGroups();
|
||||
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
render(outcome.queues);
|
||||
}
|
||||
|
||||
function open() {
|
||||
releases.hide();
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
nav.setAttribute("aria-pressed", "true");
|
||||
void load();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
view.hidden = true;
|
||||
nav.setAttribute("aria-pressed", "false");
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
function close() {
|
||||
hide();
|
||||
board.hidden = false;
|
||||
nav.focus();
|
||||
}
|
||||
|
||||
nav.addEventListener("click", () => {
|
||||
if (view.hidden) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// capture, like the release deck: one Esc steps back one layer
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
close();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
async function refreshBadge() {
|
||||
const outcome = await fetchAttention();
|
||||
if (outcome.kind === "error") {
|
||||
return;
|
||||
}
|
||||
paintBadge(attentionTotal(outcome.queues));
|
||||
}
|
||||
|
||||
return { hide, refreshBadge };
|
||||
}
|
||||
|
||||
/**
|
||||
* One no-PT-source entry: the line opens the release deck as evidence; the
|
||||
* §5.2 one click sits beside it and empties the item.
|
||||
*/
|
||||
function noPtRow(
|
||||
movie: AttentionMovie,
|
||||
roots: Root[],
|
||||
open: (movie: LibraryMovie, origin: HTMLElement) => void,
|
||||
settled: (item: HTMLLIElement, text: string) => void,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
item.className = "queue-item";
|
||||
|
||||
const line = document.createElement("button");
|
||||
line.type = "button";
|
||||
line.className = "row row-tmdb queue-line";
|
||||
const chips = document.createElement("span");
|
||||
chips.className = "row-chips";
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
const attempts = attemptsLabel(movie.search_attempts);
|
||||
if (attempts !== null) {
|
||||
chips.append(
|
||||
chip(attempts, (span) => {
|
||||
span.setAttribute("aria-label", `${movie.search_attempts} searches found no pt source`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const affordance = document.createElement("span");
|
||||
affordance.className = "row-add readout";
|
||||
affordance.textContent = "releases";
|
||||
chips.append(affordance);
|
||||
line.append(rowTitle(movie.title, movie.year), chips);
|
||||
line.addEventListener("click", () => {
|
||||
open(movie, line);
|
||||
});
|
||||
|
||||
const allow = document.createElement("button");
|
||||
allow.type = "button";
|
||||
allow.className = "control queue-allow";
|
||||
allow.textContent = "allow english";
|
||||
|
||||
const note = document.createElement("span");
|
||||
note.className = "queue-feedback readout";
|
||||
note.setAttribute("role", "status");
|
||||
note.hidden = true;
|
||||
|
||||
allow.addEventListener("click", () => {
|
||||
allow.disabled = true;
|
||||
note.hidden = false;
|
||||
delete note.dataset.tone;
|
||||
note.textContent = "writing override…";
|
||||
void allowEnglishAudio(movie.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
allow.disabled = false;
|
||||
note.textContent = `${outcome.overrideWritten ? "override written · " : ""}${outcome.detail}`;
|
||||
note.dataset.tone = "fault";
|
||||
return;
|
||||
}
|
||||
settled(item, `${movie.title} — english allowed, search queued`);
|
||||
});
|
||||
});
|
||||
|
||||
item.append(line, allow, note);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
|
||||
const parsed = parseManualInput(kind, raw);
|
||||
intake.replaceChildren();
|
||||
|
||||
Reference in New Issue
Block a user