feat(web): library poster grid with list toggle

This commit is contained in:
Miguel Palhas
2026-08-24 03:04:06 +01:00
parent 365c2a7587
commit a6cdd0ab0b
3 changed files with 369 additions and 13 deletions
+4
View File
@@ -129,6 +129,10 @@
<header class="deck-head library-bar">
<h2 class="deck-label">library</h2>
<span class="deck-count readout" id="library-summary"></span>
<div class="view-toggle">
<button type="button" class="view-btn" id="library-view-grid" aria-pressed="true">grid</button>
<button type="button" class="view-btn" id="library-view-list" aria-pressed="false">list</button>
</div>
<button
type="button"
class="bucket-toggle readout"
+203 -12
View File
@@ -2380,6 +2380,25 @@ interface LibraryGroup {
rows: HTMLUListElement;
}
/* ---- §9.6 poster grid (#151) -------------------------------------------- */
type LibraryShape = "grid" | "list";
const LIBRARY_SHAPE_KEY = "arr.libraryView";
/**
* The grid is default and the choice is per-person — no server state. A
* corrupt or foreign value falls back to the grid rather than breaking open.
*/
function loadLibraryShape(): LibraryShape {
try {
const stored = window.localStorage.getItem(LIBRARY_SHAPE_KEY);
return stored === "list" ? "list" : "grid";
} catch {
return "grid";
}
}
function libraryMain(
board: HTMLElement,
movieDetail: MovieView,
@@ -2392,6 +2411,8 @@ function libraryMain(
const summary = must<HTMLElement>("#library-summary");
const toggle = must<HTMLButtonElement>("#library-toggle");
const status = must<HTMLElement>("#library-status");
const gridBtn = must<HTMLButtonElement>("#library-view-grid");
const listBtn = must<HTMLButtonElement>("#library-view-list");
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
series: {
section: must<HTMLElement>("#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<T>(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 {
+162 -1
View File
@@ -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;