feat(web): rich search rows (#148)

Every search row — library and TMDB alike — gains a w92 poster
thumbnail with a same-size blank fallback, a TMDB rating chip
(hidden when there are no votes), and a trailer chip that resolves
GET /api/trailer on click only, opening the tab inside the handler.
Episode hits carry the series' TMDB id so their chip resolves too.
This commit is contained in:
Miguel Palhas
2026-08-24 01:55:00 +01:00
parent 82b294ab1b
commit 07741c80fe
6 changed files with 288 additions and 41 deletions
+171 -34
View File
@@ -14,6 +14,7 @@ import {
imdbLink,
type MetadataCastMember,
movieMetadata,
resolveTrailer,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
@@ -628,7 +629,9 @@ function searchMain(
void run(query);
}
} else if (event.key === "ArrowDown") {
const first = refs.deck.querySelector<HTMLElement>(".row-tmdb:not(:disabled)");
const first = refs.deck.querySelector<HTMLElement>(
'.row-tmdb:not(:disabled):not([aria-disabled="true"])',
);
if (first) {
event.preventDefault();
first.focus();
@@ -678,7 +681,11 @@ function searchMain(
return;
}
event.preventDefault();
const rows = [...refs.deck.querySelectorAll<HTMLElement>(".row-tmdb:not(:disabled)")];
const rows = [
...refs.deck.querySelectorAll<HTMLElement>(
'.row-tmdb:not(:disabled):not([aria-disabled="true"])',
),
];
const index = rows.indexOf(target);
if (event.key === "ArrowUp" && index === 0) {
input.focus();
@@ -727,6 +734,131 @@ function seasonTag(seasonNumber: number): string {
return `S${two(seasonNumber)}`;
}
/* ---- §9.2 rich-row parts (#148) ---------------------------------------- */
/** A search row's poster at w92, or a same-size blank so the edge never jitters. */
function rowPoster(posterPath: string | null, title: string): HTMLElement {
if (posterPath === null) {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
return blank;
}
const img = document.createElement("img");
img.className = "row-poster";
img.loading = "lazy";
img.src = tmdbImage(posterPath, "w92") ?? "";
img.alt = `${title} poster`;
// hotlinked art can 404; fall back to the blank rather than alt-text spill
img.addEventListener("error", () => {
const blank = document.createElement("span");
blank.className = "row-poster-blank";
blank.setAttribute("aria-hidden", "true");
img.replaceWith(blank);
});
return img;
}
/**
* TMDB's rating as a chip (#148). No votes means no chip — never a 0.0.
* `vote_count` rides along as the title when the response carries one.
*/
function ratingChip(voteAverage: number | null, voteCount?: number): HTMLSpanElement | null {
if (voteAverage === null) {
return null;
}
const span = document.createElement("span");
span.className = "chip";
span.append(starIcon(), document.createTextNode(formatRating(voteAverage)));
if (voteCount !== undefined) {
span.title = `${formatVoteCount(voteCount)} votes`;
}
return span;
}
/**
* The §9.6 trailer chip: one call, fired from the click and never before,
* so a page of results costs zero trailer requests. The tab opens inside
* the handler — an async `window.open` is what popup blockers eat — and is
* navigated when the key arrives; no video closes it again quietly.
*/
function trailerChip(kind: "movie" | "tv", tmdbId: number): HTMLSpanElement {
const chipEl = document.createElement("span");
chipEl.className = "chip row-trailer";
chipEl.textContent = "trailer";
chipEl.setAttribute("role", "button");
chipEl.tabIndex = 0;
let busy = false;
const resolve = () => {
if (busy || chipEl.dataset.state !== undefined) {
return;
}
busy = true;
const tab = window.open("", "_blank");
chipEl.dataset.state = "pending";
chipEl.textContent = "trailer…";
void resolveTrailer(kind, tmdbId).then((outcome) => {
busy = false;
if (outcome.kind === "trailer") {
if (tab) {
tab.location.href = youtubeLink(outcome.youtubeKey);
delete chipEl.dataset.state;
chipEl.textContent = "trailer";
} else {
// visibly blocked beats silently swallowed
chipEl.dataset.state = "blocked";
chipEl.textContent = "popups blocked";
}
return;
}
tab?.close();
if (outcome.kind === "none") {
// TMDB having no video is an ordinary outcome, not a failure
chipEl.dataset.state = "none";
chipEl.textContent = "no trailer";
chipEl.removeAttribute("role");
chipEl.removeAttribute("tabindex");
} else {
delete chipEl.dataset.state;
chipEl.title = outcome.detail;
chipEl.textContent = "trailer";
}
});
};
chipEl.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
resolve();
});
chipEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.stopPropagation();
event.preventDefault();
resolve();
}
});
return chipEl;
}
/** Poster left, then title / chips / overview in a wrapping column (#148). */
function richRow(): {
item: HTMLLIElement;
row: HTMLButtonElement;
body: HTMLElement;
chips: HTMLElement;
} {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb row-rich";
const body = document.createElement("span");
body.className = "row-main";
const chips = document.createElement("span");
chips.className = "row-chips";
return { item, row, body, chips };
}
function episodeTag(seasonNumber: number, episodeNumber: number): string {
return `${seasonTag(seasonNumber)}E${two(episodeNumber)}`;
}
@@ -740,12 +872,7 @@ function libraryRow(
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
chips.append(
@@ -768,11 +895,17 @@ function libraryRow(
if (movie.blocked) {
chips.append(chip("blocked"));
}
const rating = ratingChip(movie.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("movie", movie.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
body.append(rowTitle(movie.title, movie.year), chips);
row.append(rowPoster(movie.poster_path, movie.title), body);
row.addEventListener("click", () => {
open(movie, row);
});
@@ -789,22 +922,23 @@ function librarySeriesRow(
roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
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"));
}
const rating = ratingChip(series.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("tv", series.tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(series.title, series.year), chips);
body.append(rowTitle(series.title, series.year), chips);
row.append(rowPoster(series.poster_path, series.title), body);
row.addEventListener("click", () => {
open(series.id, row);
});
@@ -817,12 +951,7 @@ function episodeRow(
episode: LibraryEpisodeHit,
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
chips.append(
chip(episode.tag, (span) => {
span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`);
@@ -833,11 +962,17 @@ function episodeRow(
title.className = "row-sub";
title.textContent = episode.title;
chips.append(title);
const rating = ratingChip(episode.vote_average);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip("tv", episode.series_tmdb_id));
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "episodes";
chips.append(affordance);
row.append(rowTitle(episode.series_title, null), chips);
body.append(rowTitle(episode.series_title, null), chips);
row.append(rowPoster(episode.poster_path, episode.series_title), body);
row.addEventListener("click", () => {
open(episode.series_id, row);
});
@@ -851,13 +986,7 @@ function tmdbRow(
inLibrary: boolean,
fetchRoots: () => Promise<Root[]>,
): HTMLLIElement {
const item = document.createElement("li");
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const { item, row, body, chips } = richRow();
chips.append(chip(hit.original_language));
if (inLibrary) {
chips.append(
@@ -871,17 +1000,25 @@ function tmdbRow(
add.textContent = "add";
chips.append(add);
}
row.append(rowTitle(hit.title, hit.year), chips);
const rating = ratingChip(hit.vote_average, hit.vote_count);
if (rating !== null) {
chips.append(rating);
}
chips.append(trailerChip(hit.kind === "series" ? "tv" : "movie", hit.tmdb_id));
body.append(rowTitle(hit.title, hit.year), chips);
if (hit.overview) {
const overview = document.createElement("span");
overview.className = "row-overview";
overview.textContent = hit.overview;
row.append(overview);
body.append(overview);
}
row.append(rowPoster(hit.poster_path, hit.title), body);
item.append(row);
if (inLibrary) {
row.disabled = true;
// aria-disabled, not disabled: a dead button would also kill the
// trailer chip's clicks, and that chip stays live on every row
row.setAttribute("aria-disabled", "true");
return item;
}
@@ -1037,7 +1174,7 @@ function addPanel(
: `added to ${root.audience} — future seasons ${autoTrack ? "" : "not "}tracked automatically`;
panel.replaceChildren(done);
done.focus();
row.disabled = true;
row.setAttribute("aria-disabled", "true");
row.setAttribute("aria-expanded", "true");
row.querySelector(".row-add")?.replaceWith(
chip("in library", (span) => {
+30
View File
@@ -43,6 +43,36 @@ export type MetadataOutcome =
| { kind: "metadata"; metadata: MovieMetadata }
| { kind: "error"; detail: string };
/** The click-resolved trailer behind a search row's chip (#148). */
export type TrailerOutcome =
| { kind: "trailer"; youtubeKey: string }
| { kind: "none" }
| { kind: "error"; detail: string };
/**
* `GET /api/trailer` (#144), fired from a chip click only (§9.6) — a page
* of results costs zero extra calls. A 404 is TMDB simply having no video:
* an ordinary outcome, never an error.
*/
export async function resolveTrailer(
kind: "movie" | "tv",
tmdbId: number,
): Promise<TrailerOutcome> {
try {
const response = await fetch(`/api/trailer?kind=${kind}&tmdb_id=${tmdbId}`);
if (response.status === 404) {
return { kind: "none" };
}
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
const body = (await response.json()) as MetadataTrailer;
return { kind: "trailer", youtubeKey: body.youtube_key };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One request for everything above the release deck (§9.6). */
export async function movieMetadata(movieId: number): Promise<MetadataOutcome> {
try {
+2
View File
@@ -48,6 +48,8 @@ export interface LibraryEpisodeHit {
/** The series' poster — an episode has no artwork of its own worth showing at row size. */
poster_path: string | null;
vote_average: number | null;
/** The series' TMDB id — what the row's trailer chip resolves through. */
series_tmdb_id: number;
}
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
+72 -3
View File
@@ -699,12 +699,13 @@ body {
transition: background 150ms var(--ease-out);
}
.row-tmdb:hover:not(:disabled),
.row-tmdb:focus-visible {
.row-tmdb:hover:not(:disabled):not([aria-disabled="true"]),
.row-tmdb:focus-visible:not([aria-disabled="true"]) {
background: var(--panel);
}
.row-tmdb:disabled {
.row-tmdb:disabled,
.row-tmdb[aria-disabled="true"] {
cursor: default;
}
@@ -730,6 +731,68 @@ body {
color: var(--ink-muted);
}
/* ---- rich search rows (#148): poster, rating chip, trailer chip -------- */
.row-rich {
align-items: flex-start;
}
.row-main {
display: flex;
flex: 1;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
min-width: 0;
}
/* w92 is the right order of magnitude for a row; the blank is the same
box, so the column edge holds while results stream in */
.row-poster,
.row-poster-blank {
width: 2.75rem;
aspect-ratio: 2 / 3;
flex-shrink: 0;
align-self: center;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.row-poster {
display: block;
background: var(--panel-raised);
object-fit: cover;
}
.chip[role="button"] {
cursor: pointer;
transition:
color 150ms var(--ease-out),
border-color 150ms var(--ease-out);
}
.chip[role="button"]:hover,
.chip[role="button"]:focus-visible {
color: var(--accent);
border-color: oklch(from var(--accent) l c h / 45%);
}
.row-trailer[data-state="pending"] {
color: var(--ink-faint);
}
/* no video on TMDB is an ordinary outcome: quiet and dashed, not fault red */
.row-trailer[data-state="none"] {
border-style: dashed;
color: var(--ink-faint);
cursor: default;
}
.row-trailer[data-state="blocked"] {
color: var(--signal-warn);
cursor: default;
}
/* ---- add flow (inline, root and policy pre-filled) -------------------- */
.add-panel {
@@ -1390,6 +1453,12 @@ body {
fill: var(--ink-muted);
}
/* a row chip is smaller than the detail page's rating readout */
.row-rating .star {
width: 0.625rem;
height: 0.625rem;
}
.movie-meta {
margin: 0;
}