diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index 1082c9f..e54011a 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -17,8 +17,11 @@ reads the library as derived status, not flags. anywhere. - Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below, engraved micro-label headings, machined rows, chips on the right. -- Add flow: inline panel under a TMDB row — root radio cards (audience name, - policy name, path), main pre-selected, one confirm. Never a separate page. +- Add flow: inline panel under a TMDB row of either kind — root radio cards + (audience name, policy name, path), main pre-selected, one confirm. Never a + separate page. A series adds the one §4.1 add-time question: an auto-track + pressed control (default on) with the plain line "future seasons are tracked + automatically; past seasons are not", echoed in the added confirmation. - Release deck (§9.3, issue #31): a library row opens the buckets view. ELIGIBLE leads with fixed-width attribute chips in aligned mono columns (rem-width `.cw-*` classes shared by chips and the column header, so the @@ -59,7 +62,13 @@ reads the library as derived status, not flags. - Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS, cyan for interaction only, dead-air rule (no idle animation), no horizontal scroll at any viewport (§9.3 acceptance criterion). +- Unified search covers both kinds (issue 130): in-library hits read as + whatever they are — a movie row, a series hit (display-only until the + detail view of issue 129 exists) and an episode hit rendered as its series, + an `SxxEyy` chip and the episode title in sans beside it. TMDB rows show TV + alongside film; the "in library" dedup spans movies and series. - Unresolved: releases land in the persisted store only once the daemon - consumes search commands (#68-adjacent); TMDB search is movie-only until - series land (#39); per-season and per-episode library actions are #39; - attention queues surface is #33. + consumes search commands (#68-adjacent); episode hits have no link target + until issue 129's series detail view exists — they render display-only for + now; per-season and per-episode library actions are #39; attention queues + surface is #33. diff --git a/web/src/main.ts b/web/src/main.ts index b79e336..344d843 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -42,15 +42,18 @@ import { import { currentRoute, navigate, type Route } from "./router"; import { addMovie, + addSeries, allRoots, fetchMovie, + type LibraryEpisodeHit, type LibraryMovie, - movieRoots, + type LibrarySeriesHit, parseManualInput, type Root, type SearchResponse, searchTitles, type TmdbMovie, + type TmdbSeries, } from "./search"; import { settingsMain } from "./settings"; import "./style.css"; @@ -322,11 +325,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV manualIntake: must("#manual-intake"), }; - // The audience chip on a library row needs id → root. movieRoots() caches + // The audience chip on a library row needs id → root. allRoots() caches // success, so a failed boot fetch is retried whenever an add panel opens. let roots: Root[] = []; const fetchRoots = () => - movieRoots().then( + allRoots().then( (fetched) => { roots = fetched; return fetched; @@ -406,11 +409,18 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV return; } - // Series and episode hits render in issue 130; movies keep the current row. + // §9.2: in-library hits read as whatever they are — a movie, a series, + // or an episode with its series and SxxEyy for context const libraryMovies = response.library.filter( (hit): hit is LibraryMovie => hit.kind === "movie", ); - const inLibrary = new Set(libraryMovies.map((movie) => movie.tmdb_id)); + const librarySeries = response.library.filter( + (hit): hit is LibrarySeriesHit => hit.kind === "series", + ); + const libraryEpisodes = response.library.filter( + (hit): hit is LibraryEpisodeHit => hit.kind === "episode", + ); + const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id)); if (response.library.length === 0 && response.tmdb.length === 0) { setStatus("no matches in library or on tmdb"); return; @@ -428,14 +438,17 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV }), ); } + for (const series of librarySeries) { + refs.groups.library.rows.append(librarySeriesRow(series, roots)); + } + for (const episode of libraryEpisodes) { + refs.groups.library.rows.append(episodeRow(episode)); + } } if (response.tmdb.length > 0) { refs.groups.tmdb.section.hidden = false; refs.groups.tmdb.count.textContent = String(response.tmdb.length); for (const hit of response.tmdb) { - if (hit.kind !== "movie") { - continue; - } refs.groups.tmdb.rows.append(tmdbRow(hit, inLibrary.has(hit.tmdb_id), fetchRoots)); } } @@ -606,8 +619,51 @@ function libraryRow( return item; } +/** + * An in-library series hit. Display-only, like its library-view sibling — + * the series detail view it would open into is issue 129. + */ +function librarySeriesRow(series: LibrarySeriesHit, roots: Root[]): HTMLLIElement { + const item = document.createElement("li"); + const row = document.createElement("div"); + row.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.blocked) { + chips.append(chip("blocked")); + } + row.append(rowTitle(series.title, series.year), chips); + item.append(row); + return item; +} + +/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */ +function episodeRow(episode: LibraryEpisodeHit): HTMLLIElement { + const item = document.createElement("li"); + const row = document.createElement("div"); + row.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + chips.append( + chip(episode.tag, (span) => { + span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`); + }), + ); + // the episode title is a name, not a readout: sans, never mono + const title = document.createElement("span"); + title.className = "row-sub"; + title.textContent = episode.title; + chips.append(title); + row.append(rowTitle(episode.series_title, null), chips); + item.append(row); + return item; +} + +/** A TMDB hit of either kind — the row opens the add flow (§9.2). */ function tmdbRow( - movie: TmdbMovie, + hit: TmdbMovie | TmdbSeries, inLibrary: boolean, fetchRoots: () => Promise, ): HTMLLIElement { @@ -618,7 +674,7 @@ function tmdbRow( row.className = "row row-tmdb"; const chips = document.createElement("span"); chips.className = "row-chips"; - chips.append(chip(movie.original_language)); + chips.append(chip(hit.original_language)); if (inLibrary) { chips.append( chip("in library", (span) => { @@ -631,11 +687,11 @@ function tmdbRow( add.textContent = "add"; chips.append(add); } - row.append(rowTitle(movie.title, movie.year), chips); - if (movie.overview) { + row.append(rowTitle(hit.title, hit.year), chips); + if (hit.overview) { const overview = document.createElement("span"); overview.className = "row-overview"; - overview.textContent = movie.overview; + overview.textContent = hit.overview; row.append(overview); } item.append(row); @@ -658,7 +714,7 @@ function tmdbRow( if (item.querySelector(".add-panel")) { return; } - const panel = addPanel(movie, available, row); + const panel = addPanel(hit, available, row); item.append(panel); row.setAttribute("aria-expanded", "true"); panel.querySelector(".control")?.focus(); @@ -667,8 +723,19 @@ function tmdbRow( return item; } -/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */ -function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement { +/** + * The §9.2 add flow: inline, root and policy pre-filled, one confirm. A + * series asks the one series-level question at add time — `auto_track` + * (§4.1) — and states what it does in one plain line. + */ +function addPanel( + title: TmdbMovie | TmdbSeries, + available: Root[], + row: HTMLButtonElement, +): HTMLElement { + const isSeries = title.kind === "series"; + const kind = isSeries ? "tv" : "movie"; + const roots = available.filter((root) => root.kind === kind); const panel = document.createElement("div"); panel.className = "add-panel"; @@ -707,6 +774,34 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML options.append(option); } + // §4.1: auto_track is a rule about seasons metadata reveals, not intent. + // Future seasons are picked up by themselves; past seasons never are. + let autoTrack = true; + let autotrack: HTMLElement | null = null; + if (isSeries) { + const track = document.createElement("button"); + track.type = "button"; + track.className = "control control-quiet"; + track.setAttribute("aria-pressed", "true"); + const trackState = () => { + track.textContent = `auto-track future seasons: ${autoTrack ? "on" : "off"}`; + }; + trackState(); + track.addEventListener("click", () => { + autoTrack = !autoTrack; + track.setAttribute("aria-pressed", String(autoTrack)); + trackState(); + }); + + const trackNote = document.createElement("p"); + trackNote.className = "add-note readout dim"; + trackNote.textContent = "future seasons are tracked automatically; past seasons are not"; + + autotrack = document.createElement("div"); + autotrack.className = "add-autotrack"; + autotrack.append(track, trackNote); + } + const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "control"; @@ -720,7 +815,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML if (roots.length === 0) { confirm.disabled = true; note.hidden = false; - note.textContent = "roots unavailable — daemon down or database missing"; + // a failed fetch and an absent kind are different problems + note.textContent = + available.length === 0 + ? "roots unavailable — daemon down or database missing" + : `no ${isSeries ? "tv" : "movie"} roots configured — create one under settings`; note.dataset.tone = "fault"; } @@ -733,7 +832,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML note.hidden = false; delete note.dataset.tone; note.textContent = "adding…"; - void addMovie(movie, root.id).then((outcome) => { + const adding = + title.kind === "movie" ? addMovie(title, root.id) : addSeries(title, root.id, autoTrack); + void adding.then((outcome) => { if (outcome.kind === "error") { confirm.disabled = false; note.textContent = `add failed — ${outcome.detail}`; @@ -747,7 +848,9 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML done.tabIndex = -1; done.textContent = already ? "already in library" - : `added to ${root.audience} — wanted, search follows`; + : title.kind === "movie" + ? `added to ${root.audience} — wanted, search follows` + : `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`; panel.replaceChildren(done); done.focus(); row.disabled = true; @@ -763,7 +866,11 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML const actions = document.createElement("div"); actions.className = "add-actions"; actions.append(confirm, note); - panel.append(options, actions); + if (autotrack) { + panel.append(options, autotrack, actions); + } else { + panel.append(options, actions); + } return panel; } diff --git a/web/src/search.ts b/web/src/search.ts index d45909e..5bba581 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -129,16 +129,6 @@ export async function allRoots(): Promise { return rootsCache; } -/** The movie roots. The add flow pre-fills from these. */ -export async function movieRoots(): Promise { - return (await allRoots()).filter((root) => root.kind === "movie"); -} - -/** The TV roots. The series add flow pre-fills from these. */ -export async function tvRoots(): Promise { - return (await allRoots()).filter((root) => root.kind === "tv"); -} - /** The stored series a successful POST /api/series returns. */ export interface ApiSeries { id: number; diff --git a/web/src/style.css b/web/src/style.css index 858c344..dfe4dd6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -724,6 +724,12 @@ body { text-overflow: ellipsis; } +/* an episode hit's title sits beside its SxxEyy chip: a name, not a readout */ +.row-sub { + font-size: var(--text-sm); + color: var(--ink-muted); +} + /* ---- add flow (inline, root and policy pre-filled) -------------------- */ .add-panel { @@ -786,6 +792,14 @@ body { margin: var(--space-3) 0 0; } +/* the one series-level add-time question (§4.1): the choice and what it does */ +.add-autotrack { + display: grid; + justify-content: start; + gap: var(--space-1); + margin: var(--space-3) 0 0; +} + .add-note { margin: 0; font-size: var(--text-xs);