feat(web): series detail metadata header
This commit is contained in:
+29
-6
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user