Library views with derived status (#83)
ci / web (push) Successful in 33s
ci / rust (push) Successful in 1m29s
e2e / e2e (push) Successful in 1m12s

This commit was merged in pull request #83.
This commit is contained in:
2026-08-22 23:33:05 +01:00
parent 0343783a57
commit f38fb1277b
18 changed files with 578 additions and 46 deletions
+241 -10
View File
@@ -1,4 +1,11 @@
import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
fetchLibrary,
type LibrarySeries,
movieNeedsAttention,
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
bucketOf,
formatAudio,
@@ -19,6 +26,7 @@ import {
} from "./releases";
import {
addMovie,
allRoots,
type LibraryMovie,
movieRoots,
parseManualInput,
@@ -183,7 +191,9 @@ function main() {
}, POLL_MS);
void probe();
searchMain(board, releasesMain());
const releases = releasesMain();
const library = libraryMain(board, releases);
searchMain(board, releases, library);
}
const DEBOUNCE_MS = 250;
@@ -199,7 +209,7 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement, releases: ReleasesView) {
function searchMain(board: HTMLElement, releases: ReleasesView, library: LibraryView) {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -241,12 +251,14 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
controller?.abort();
controller = null;
releases.hide();
library.hide();
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
library.hide();
board.hidden = true;
refs.deck.hidden = false;
}
@@ -310,7 +322,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of response.library) {
refs.groups.library.rows.append(libraryRow(movie, roots, releases.open));
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, refs.deck);
}),
);
}
}
if (response.tmdb.length > 0) {
@@ -441,6 +457,15 @@ function libraryRow(
span.dataset.movieState = movie.state;
}),
);
// §5.7 honesty: a waived import is never presented as a clean match
const waiver = waiverLabel(movie.waiver);
if (waiver !== null) {
chips.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (!movie.wanted) {
chips.append(chip("not wanted"));
}
@@ -623,7 +648,8 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
open: (movie: LibraryMovie, origin: HTMLElement) => void;
/** Opens over `returnTo`, which back and Esc restore. */
open: (movie: LibraryMovie, origin: HTMLElement, returnTo: HTMLElement) => void;
hide: () => void;
}
@@ -662,6 +688,7 @@ function releasesMain(): ReleasesView {
let current: LibraryMovie | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
@@ -770,12 +797,13 @@ function releasesMain(): ReleasesView {
render(outcome.releases);
}
function open(movie: LibraryMovie, from: HTMLElement) {
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
current = movie;
origin = from;
returnTo = container;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
deck.hidden = true;
container.hidden = true;
view.hidden = false;
clearBuckets();
sweep.disabled = false;
@@ -793,19 +821,19 @@ function releasesMain(): ReleasesView {
function close() {
const target = origin;
hide();
deck.hidden = false;
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// capture + stopPropagation: one Escape steps back one layer, instead
// of falling through to the search deck's own Escape handler
// capture + stopImmediatePropagation: one Escape steps back one layer
// the library and search decks also listen for Escape on this window
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopPropagation();
event.stopImmediatePropagation();
close();
}
},
@@ -980,6 +1008,209 @@ function releaseRow(
return item;
}
/* ---- library view (§4.2, issue #32) ----------------------------------- */
interface LibraryView {
hide: () => void;
}
interface LibraryGroup {
section: HTMLElement;
count: HTMLElement;
rows: HTMLUListElement;
}
function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
const view = must<HTMLElement>("#library");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-library");
const summary = must<HTMLElement>("#library-summary");
const toggle = must<HTMLButtonElement>("#library-toggle");
const status = must<HTMLElement>("#library-status");
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
series: {
section: must<HTMLElement>("#library-series"),
count: must<HTMLElement>("#count-lib-series"),
rows: must<HTMLUListElement>("#rows-lib-series"),
},
movies: {
section: must<HTMLElement>("#library-movies"),
count: must<HTMLElement>("#count-lib-movies"),
rows: must<HTMLUListElement>("#rows-lib-movies"),
},
};
// §4.2: ONE toggle for everything the default view leaves out
let showAll = false;
let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null;
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.series, groups.movies]) {
group.section.hidden = true;
group.rows.replaceChildren();
}
summary.textContent = "";
toggle.hidden = true;
}
function renderGroup<T>(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) {
group.rows.replaceChildren();
group.section.hidden = items.length === 0;
group.count.textContent = String(items.length);
for (const item of items) {
group.rows.append(build(item));
}
}
function render() {
const library = data;
if (!library) {
return;
}
const total = library.series.length + library.movies.length;
const satisfied =
library.series.filter((series) => !seriesNeedsAttention(series)).length +
library.movies.filter((movie) => !movieNeedsAttention(movie)).length;
summary.textContent = total === 0 ? "" : `${total} ${total === 1 ? "title" : "titles"}`;
toggle.hidden = satisfied === 0;
toggle.textContent = `${showAll ? "hide" : "show"} ${satisfied} satisfied`;
toggle.setAttribute("aria-expanded", String(showAll));
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots));
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, view);
}),
);
if (total === 0) {
setStatus("library is empty — the search box above adds titles");
} else if (seriesShown.length + moviesShown.length === 0) {
setStatus("nothing needs attention");
} else {
setStatus(null);
}
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading library…");
const [outcome, fetchedRoots] = await Promise.all([
fetchLibrary(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
if (outcome.kind === "error") {
clearGroups();
setStatus(`library unavailable — ${outcome.detail}`, "fault");
return;
}
data = { series: outcome.series, movies: outcome.movies };
render();
}
function open() {
releases.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();
}
});
toggle.addEventListener("click", () => {
showAll = !showAll;
render();
});
// 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,
);
return { hide };
}
/**
* One series with its §4.2 derived status: displayed, never editable.
* No release deck yet — per-season and per-episode actions are issue #39.
*/
function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
const item = document.createElement("li");
item.className = "row";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.wanted_episodes > 0) {
chips.append(
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
span.setAttribute(
"aria-label",
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
);
}),
);
}
if (series.blocked) {
chips.append(chip("blocked"));
}
chips.append(
chip(series.status, (span) => {
span.dataset.status = series.status;
}),
);
item.append(rowTitle(series.title, series.year), chips);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();