Library views with derived status (#83)
This commit was merged in pull request #83.
This commit is contained in:
+241
-10
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user