diff --git a/web/index.html b/web/index.html index 2624b4a..4523f83 100644 --- a/web/index.html +++ b/web/index.html @@ -129,6 +129,10 @@ library + + grid + list + ("#library-summary"); const toggle = must("#library-toggle"); const status = must("#library-status"); + const gridBtn = must("#library-view-grid"); + const listBtn = must("#library-view-list"); const groups: { series: LibraryGroup; movies: LibraryGroup } = { series: { section: must("#library-series"), @@ -2407,11 +2428,30 @@ function libraryMain( // §4.2: ONE toggle for everything the default view leaves out let showAll = false; + // §9.6 library view: the poster grid is default, per-person, localStorage + let shape = loadLibraryShape(); 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 setShape(next: LibraryShape) { + shape = next; + try { + window.localStorage.setItem(LIBRARY_SHAPE_KEY, next); + } catch { + // private mode or full storage: the choice just does not persist + } + gridBtn.setAttribute("aria-pressed", String(shape === "grid")); + listBtn.setAttribute("aria-pressed", String(shape === "list")); + render(); + } + + gridBtn.addEventListener("click", () => setShape("grid")); + listBtn.addEventListener("click", () => setShape("list")); + gridBtn.setAttribute("aria-pressed", String(shape === "grid")); + listBtn.setAttribute("aria-pressed", String(shape === "list")); + function setStatus(text: string | null, tone?: "fault") { status.hidden = text === null; status.textContent = text ?? ""; @@ -2432,6 +2472,7 @@ function libraryMain( } function renderGroup(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) { + group.rows.className = shape === "grid" ? "deck-rows grid-rows" : "deck-rows"; group.rows.replaceChildren(); group.section.hidden = items.length === 0; group.count.textContent = String(items.length); @@ -2457,18 +2498,23 @@ function libraryMain( 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, (id, origin) => { - navigate({ kind: "series", seriesId: id }); - void seriesDetail.open(id, origin, view, { kind: "library" }); - }), - ); - renderGroup(groups.movies, moviesShown, (movie) => - libraryRow(movie, roots, (target, origin) => { - navigate({ kind: "movie", movieId: target.id }); - void movieDetail.open(target.id, origin, view, { kind: "library" }); - }), - ); + + const openSeries = (id: number, origin: HTMLElement) => { + navigate({ kind: "series", seriesId: id }); + void seriesDetail.open(id, origin, view, { kind: "library" }); + }; + const openMovie = (movie: LibraryMovie, origin: HTMLElement) => { + navigate({ kind: "movie", movieId: movie.id }); + void movieDetail.open(movie.id, origin, view, { kind: "library" }); + }; + + if (shape === "grid") { + renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries)); + renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie)); + } else { + renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries)); + renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie)); + } if (total === 0) { setStatus("library is empty — the search box above adds titles"); @@ -2601,6 +2647,151 @@ function seriesRow( return item; } +/** + * One §9.6 grid tile (#151): poster at w342, title and year beneath, then + * the chips the row view carries — a card is legible before it is pretty. + * A missing poster becomes a same-ratio placeholder carrying its title, + * so the grid never reflows and never has a hole in it. + */ +function libraryCard( + posterPath: string | null, + title: string, + year: number | null, + chips: HTMLSpanElement[], + open: (origin: HTMLElement) => void, +): HTMLLIElement { + const item = document.createElement("li"); + const card = document.createElement("button"); + card.type = "button"; + card.className = "card"; + + const art = document.createElement("span"); + art.className = "card-art"; + if (posterPath === null) { + const blank = document.createElement("span"); + blank.className = "card-blank"; + const blankTitle = document.createElement("span"); + blankTitle.className = "card-blank-title"; + blankTitle.textContent = title; + blank.append(blankTitle); + art.append(blank); + } else { + const img = document.createElement("img"); + img.className = "card-poster"; + img.loading = "lazy"; + img.src = tmdbImage(posterPath, "w342") ?? ""; + img.alt = `${title} poster`; + // hotlinked art can 404; fall back to the titled blank rather than a hole + img.addEventListener("error", () => { + const blankTitle = document.createElement("span"); + blankTitle.className = "card-blank-title"; + blankTitle.textContent = title; + const blank = document.createElement("span"); + blank.className = "card-blank"; + blank.append(blankTitle); + img.replaceWith(blank); + }); + art.append(img); + } + + const id = document.createElement("span"); + id.className = "card-id"; + const name = document.createElement("span"); + name.className = "card-name"; + name.textContent = title; + const when = document.createElement("span"); + when.className = "card-year readout dim"; + when.textContent = year === null ? "—" : String(year); + id.append(name, when); + + const chipLine = document.createElement("span"); + chipLine.className = "card-chips"; + for (const chipEl of chips) { + chipLine.append(chipEl); + } + + const body = document.createElement("span"); + body.className = "card-body"; + body.append(id, chipLine); + + card.append(art, body); + card.addEventListener("click", () => { + open(card); + }); + item.append(card); + return item; +} + +function seriesCard( + series: LibrarySeries, + roots: Root[], + open: (id: number, origin: HTMLElement) => void, +): HTMLLIElement { + const root = roots.find((candidate) => candidate.id === series.root_id); + const chips = [chip(root ? root.audience : `root ${series.root_id}`)]; + if (series.wanted_episodes > 0) { + chips.push( + 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.push(chip("blocked")); + } + chips.push( + chip(series.status, (span) => { + span.dataset.status = series.status; + }), + ); + const rating = ratingChip(series.vote_average); + if (rating !== null) { + chips.push(rating); + } + return libraryCard(series.poster_path, series.title, series.year, chips, (origin) => + open(series.id, origin), + ); +} + +function movieCard( + movie: LibraryMovie, + roots: Root[], + open: (movie: LibraryMovie, origin: HTMLElement) => void, +): HTMLLIElement { + const root = roots.find((candidate) => candidate.id === movie.root_id); + const chips = [chip(root ? root.audience : `root ${movie.root_id}`)]; + chips.push( + chip(movie.state, (span) => { + 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.push( + chip(waiver, (span) => { + span.dataset.verdict = "waived"; + }), + ); + } + if (!movie.wanted) { + chips.push(chip("not wanted")); + } + if (movie.blocked) { + chips.push(chip("blocked")); + } + const rating = ratingChip(movie.vote_average); + if (rating !== null) { + chips.push(rating); + } + return libraryCard(movie.poster_path, movie.title, movie.year, chips, (origin) => + open(movie, origin), + ); +} + /* ---- tv release deck (§9.3 for season packs and single episodes) ------- */ interface TvDeckRequest { diff --git a/web/src/style.css b/web/src/style.css index a387e2b..fd954c3 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1159,9 +1159,157 @@ body { } /* the toggle's own padding would inset its text past the chip column's - right rag; drop it so SATISFIED sits flush with the chips below */ + right rag; with the shape toggle right of it, SATISFIED sits flush + against its neighbour instead of the deck edge */ .library-bar .bucket-toggle { padding: 0; + margin-left: var(--space-2); +} + +/* ---- library poster grid (§9.6, issue 151) ------------------------------ */ + +/* the shape toggle: a joined pair, engaged side lit like any view control */ +.view-toggle { + margin-left: auto; + display: inline-flex; +} + +.view-btn { + min-height: 1.75rem; + padding: 0 var(--space-2); + font-family: var(--font-readout); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-faint); + background: none; + border: 1px solid var(--line); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out); +} + +.view-btn + .view-btn { + border-left-width: 0; +} + +.view-btn:hover { + color: var(--accent-bright); +} + +.view-btn[aria-pressed="true"] { + color: var(--accent-bright); + border-color: var(--accent); +} + +/* auto-fill keeps every tile the same width down to a phone, where three + narrow-but-legible columns beat two oversized ones */ +.deck-rows.grid-rows { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: var(--space-4); +} + +.card { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + height: 100%; + padding: 0; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + text-align: left; + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + transform 150ms var(--ease-out); +} + +.card:hover { + border-color: var(--accent); +} + +.card:active { + transform: translateY(1px); +} + +/* the poster is framed evidence, like every other image on a panel */ +.card-art { + display: block; + aspect-ratio: 2 / 3; +} + +.card-poster { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + background: var(--panel-raised); +} + +/* same ratio as a poster, carrying the title: the grid never has a hole */ +.card-blank { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: var(--space-3); + background: var(--panel-raised); + box-shadow: inset 0 1px var(--highlight); +} + +.card-blank-title { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 600; + letter-spacing: 0.02em; + text-align: center; + color: var(--ink-faint); +} + +.card-body { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); +} + +.card-id { + display: flex; + align-items: baseline; + gap: var(--space-1); + min-width: 0; +} + +.card-name { + flex: 1; + /* two clamped lines: a long title stays readable where a single + ellipsised line at tile width would not be */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink); +} + +.card-year { + flex: none; + font-size: var(--text-2xs); +} + +.card-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); } /* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */ @@ -1625,6 +1773,19 @@ body { gap: var(--space-3) var(--space-4); } + /* the library bar wraps to two lines at phone widths: identity and count + on the first, shape and satisfied toggles on the second, each label + whole instead of squeezed into a mid-word break */ + .library-bar { + flex-wrap: wrap; + row-gap: var(--space-2); + } + + .library-bar .bucket-toggle, + .view-btn { + white-space: nowrap; + } + .rail-verdict { flex-basis: 100%; order: 3;