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:
+171
-34
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user