feat(web): series detail metadata header

This commit is contained in:
Miguel Palhas
2026-08-24 00:37:53 +01:00
parent b413718fd1
commit ed210b29d3
5 changed files with 265 additions and 75 deletions
+29 -6
View File
@@ -401,13 +401,36 @@
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="series-title"></h2>
<span class="releases-year readout dim" id="series-year"></span>
<span class="row-chips series-chips" id="series-chips"></span>
</div>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="series-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="series-title"></h2>
<span class="movie-year readout dim" id="series-year"></span>
</div>
<!-- audience, episode counts and §4.2 status: operational state,
outranking the artwork, folded into this one header (issue 150) -->
<span class="row-chips movie-chips" id="series-chips"></span>
<p class="movie-rating readout" id="series-rating" hidden></p>
<p class="movie-meta readout dim" id="series-meta" hidden></p>
<p class="movie-tagline" id="series-tagline" hidden></p>
<p class="movie-overview" id="series-overview" hidden></p>
<div class="movie-actions" id="series-actions"></div>
</div>
</div>
</section>
<section class="deck-group" id="series-cast" hidden aria-labelledby="label-series-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-series-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-series-cast"></ul>
</section>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
+180 -59
View File
@@ -18,6 +18,8 @@ import {
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
tmdbSeriesLink,
tvdbLink,
updateMovie,
youtubeLink,
} from "./movie";
@@ -87,8 +89,10 @@ import {
fileAttributeTags,
formatAirDate,
isUnaired,
type SeriesMetadata,
seasonCounts,
seasonTarget,
seriesMetadata,
setEpisodeWanted,
setSeasonTracked,
type TvTarget,
@@ -1119,6 +1123,68 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
function starIcon(): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("class", "star");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
);
svg.append(path);
return svg;
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
/** One top-billed cast member, linking out to their TMDB page (§9.6). */
function castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name}${member.character}`;
item.append(link);
return item;
}
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
@@ -1384,65 +1450,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
castRows.replaceChildren();
}
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
function starIcon(): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("class", "star");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
);
svg.append(path);
return svg;
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
function castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name}${member.character}`;
item.append(link);
return item;
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await movieMetadata(id);
if (ticket !== sequence || movieId !== id) {
@@ -2682,9 +2689,18 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
const yearEl = must<HTMLElement>("#series-year");
const chipsEl = must<HTMLElement>("#series-chips");
const ratingEl = must<HTMLElement>("#series-rating");
const metaEl = must<HTMLElement>("#series-meta");
const taglineEl = must<HTMLElement>("#series-tagline");
const overviewEl = must<HTMLElement>("#series-overview");
const actionsEl = must<HTMLElement>("#series-actions");
const castSection = must<HTMLElement>("#series-cast");
const castRows = must<HTMLUListElement>("#rows-series-cast");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
@@ -2741,6 +2757,107 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
}
}
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await seriesMetadata(id);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (outcome.kind === "error") {
// the operational header stands; the actions row says why the rest is absent
const note = document.createElement("p");
note.className = "add-note readout";
note.dataset.tone = "fault";
note.textContent = `metadata unavailable — ${outcome.detail}`;
actionsEl.append(note);
return;
}
paintMetadata(outcome.metadata);
}
/**
* What the show IS (#150): art, rating, overview above the season tree.
* The operational chips — audience, counts, derived status — stay from
* [`paintHeader`] and outrank all of it.
*/
function paintMetadata(detail: SeriesMetadata) {
const posterUrl = tmdbImage(detail.poster_path, "w342");
if (posterUrl !== null && series !== null) {
poster.src = posterUrl;
poster.alt = `${series.title} poster`;
poster.hidden = false;
}
const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
if (backdropUrl !== null) {
hero.classList.add("has-backdrop");
hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
}
if (detail.vote_average !== null) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
);
}
if (detail.cast.length > 0) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
@@ -3056,8 +3173,10 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
: [],
);
paintHeader();
clearRichDetail();
renderSeasons();
setStatus(null);
void loadMetadata(id, ticket);
if (focusKey !== null) {
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
focusKey = null;
@@ -3078,6 +3197,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearRichDetail();
back.focus();
await load();
}
@@ -3087,6 +3207,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
seriesId = null;
seasons = null;
sequence += 1;
clearRichDetail();
}
function close() {
+9
View File
@@ -90,6 +90,15 @@ export function tmdbMovieLink(tmdbId: number): string {
return `https://www.themoviedb.org/movie/${tmdbId}`;
}
export function tmdbSeriesLink(tmdbId: number): string {
return `https://www.themoviedb.org/tv/${tmdbId}`;
}
/** A series has no imdb_id in this app; TVDB is its second id (§9.6). */
export function tvdbLink(tvdbId: number): string {
return `https://www.thetvdb.com/dereferrer/series/${tvdbId}`;
}
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
+41
View File
@@ -2,6 +2,7 @@
// release schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { MetadataCastMember, MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
@@ -239,6 +240,46 @@ export async function waiveAndGrabTv(
return { kind: "done", overrideWritten };
}
/* ---- §9.6 rich detail --------------------------------------------------- */
/** Rich detail for one library series' §9.6 page (#146). */
export interface SeriesMetadata {
tmdb_id: number;
overview: string | null;
tagline: string | null;
genres: string[];
/** Minutes per episode. */
runtime: number | null;
status: string;
poster_path: string | null;
backdrop_path: string | null;
/** `null` when TMDB has no votes for the title (#156). */
vote_average: number | null;
vote_count: number;
homepage: string | null;
/** §9.6 links out to TVDB for series; absent when the id is unknown. */
tvdb_id: number | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
export type SeriesMetadataOutcome =
| { kind: "metadata"; metadata: SeriesMetadata }
| { kind: "error"; detail: string };
/** One request for everything above the season tree (§9.6). */
export async function seriesMetadata(seriesId: number): Promise<SeriesMetadataOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/metadata`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "metadata", metadata: (await response.json()) as SeriesMetadata };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- display helpers --------------------------------------------------- */
/** Wanted / on-disk counts for one season, whatever its number. */
+6 -10
View File
@@ -1103,11 +1103,6 @@ body {
/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */
/* the title line carries its readouts beside the name, not pushed right */
.series-chips {
margin-left: var(--space-3);
}
/* one season: a collapsible group with its rule, counts and actions */
.season {
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
@@ -1284,12 +1279,13 @@ body {
overflow-wrap: anywhere;
}
/* ---- movie page (§9.6, issue #149) ------------------------------------- */
/* ---- title detail pages (§9.6, issues #149 and #150) ------------------- */
/* The hero is one machined module carrying what the film IS. The backdrop
hotlinks from image.tmdb.org (§9.6) and sinks into the console charcoal
under a scrim, so the readouts keep their contrast — a photo never sits
behind text bare. */
/* The hero is one machined module carrying what the title IS — shared by
the movie and series pages, which is why its classes read `movie-*`.
The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the
console charcoal under a scrim, so the readouts keep their contrast — a
photo never sits behind text bare. */
.movie-hero {
position: relative;