fix(web): tighten library poster grid
ci / web (push) Successful in 1m17s
ci / rust (push) Successful in 2m2s
e2e / e2e (push) Failing after 3m20s

This commit is contained in:
Miguel Palhas
2026-08-26 22:38:53 +01:00
parent 9d2b818c52
commit 3f4c1ade3b
3 changed files with 73 additions and 50 deletions
+66 -32
View File
@@ -3627,6 +3627,11 @@ interface LibraryGroup {
rows: HTMLUListElement;
}
interface LibraryGridItem {
root_id: number;
item: HTMLLIElement;
}
/* ---- §9.6 poster grid (#151) -------------------------------------------- */
type LibraryShape = "grid" | "list";
@@ -3658,6 +3663,7 @@ function libraryMain(
const status = must<HTMLElement>("#library-status");
const gridBtn = must<HTMLButtonElement>("#library-view-grid");
const listBtn = must<HTMLButtonElement>("#library-view-list");
const libraryRows = must<HTMLElement>("#library-libraries");
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
series: {
section: must<HTMLElement>("#library-series"),
@@ -3708,6 +3714,8 @@ function libraryMain(
}
function clearGroups() {
libraryRows.replaceChildren();
libraryRows.hidden = true;
for (const group of [groups.series, groups.movies]) {
group.section.hidden = true;
group.rows.replaceChildren();
@@ -3726,6 +3734,43 @@ function libraryMain(
}
}
function renderLibraryRows(items: LibraryGridItem[]) {
libraryRows.replaceChildren();
libraryRows.hidden = items.length === 0;
const rows = [
...roots.map((root) => ({ id: root.id, label: root.audience })),
...[
...new Set(
items
.filter((item) => !roots.some((root) => root.id === item.root_id))
.map((item) => item.root_id),
),
].map((id) => ({ id, label: `root ${id}` })),
];
for (const root of rows) {
const titles = items.filter((item) => item.root_id === root.id);
if (titles.length === 0) {
continue;
}
const section = document.createElement("section");
section.className = "deck-group library-grid-group";
const heading = document.createElement("h3");
heading.className = "deck-label";
heading.textContent = root.label;
const count = document.createElement("span");
count.className = "deck-count readout";
count.textContent = String(titles.length);
const header = document.createElement("header");
header.className = "deck-head";
header.append(heading, count);
const rows = document.createElement("ul");
rows.className = "deck-rows grid-rows";
rows.append(...titles.map((item) => item.item));
section.append(header, rows);
libraryRows.append(section);
}
}
function render() {
const library = data;
if (!library) {
@@ -3754,9 +3799,21 @@ function libraryMain(
};
if (shape === "grid") {
renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries));
renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie));
groups.series.section.hidden = true;
groups.movies.section.hidden = true;
renderLibraryRows([
...seriesShown.map((series) => ({
root_id: series.root_id,
item: seriesCard(series, openSeries),
})),
...moviesShown.map((movie) => ({
root_id: movie.root_id,
item: movieCard(movie, openMovie),
})),
]);
} else {
libraryRows.replaceChildren();
libraryRows.hidden = true;
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries));
renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie));
}
@@ -3865,15 +3922,14 @@ function seriesRow(
}
/**
* 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.
* One §9.6 grid tile (#151): poster, title and operational chips. The grid
* supplies the library context, so cards do not repeat it.
* 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 {
@@ -3911,15 +3967,9 @@ function libraryCard(
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";
@@ -3929,7 +3979,7 @@ function libraryCard(
const body = document.createElement("span");
body.className = "card-body";
body.append(id, chipLine);
body.append(name, chipLine);
card.append(art, body);
card.addEventListener("click", () => {
@@ -3941,11 +3991,9 @@ function libraryCard(
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}`)];
const chips: HTMLSpanElement[] = [];
if (series.wanted_episodes > 0) {
chips.push(
countsChip(
@@ -3966,22 +4014,14 @@ function seriesCard(
}),
);
}
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),
);
return libraryCard(series.poster_path, series.title, 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}`)];
const chips: HTMLSpanElement[] = [];
chips.push(mediaStateChip(movie.state, movie.wanted));
const download = downloadChip("movie", movie.id);
if (download !== null) {
@@ -3997,13 +4037,7 @@ function movieCard(
if (movie.blocked) {
chips.push(chip(NO_TARGETED_SEARCH));
}
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),
);
return libraryCard(movie.poster_path, movie.title, chips, (origin) => open(movie, origin));
}
/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */
+4 -17
View File
@@ -1480,11 +1480,11 @@ body {
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 */
/* Wider tiles give artwork its due without forcing a desktop-style grid onto
a phone; the slightly wider crop avoids a wall of overly tall posters. */
.deck-rows.grid-rows {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: var(--space-4);
}
@@ -1517,7 +1517,7 @@ body {
/* the poster is framed evidence, like every other image on a panel */
.card-art {
display: block;
aspect-ratio: 2 / 3;
aspect-ratio: 18 / 25;
}
.card-poster {
@@ -1556,15 +1556,7 @@ body {
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;
@@ -1578,11 +1570,6 @@ body {
color: var(--ink);
}
.card-year {
flex: none;
font-size: var(--text-2xs);
}
.card-chips {
display: flex;
flex-wrap: wrap;