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">
|
<main class="deck releases" id="series" hidden aria-label="series detail">
|
||||||
<header class="releases-head">
|
<header class="releases-head">
|
||||||
<button type="button" class="control" id="series-back">back</button>
|
<button type="button" class="control" id="series-back">back</button>
|
||||||
<div class="releases-id">
|
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||||
<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>
|
|
||||||
</header>
|
</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>
|
<ul class="deck-rows seasons" id="rows-seasons"></ul>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+180
-59
@@ -18,6 +18,8 @@ import {
|
|||||||
tmdbImage,
|
tmdbImage,
|
||||||
tmdbMovieLink,
|
tmdbMovieLink,
|
||||||
tmdbPersonLink,
|
tmdbPersonLink,
|
||||||
|
tmdbSeriesLink,
|
||||||
|
tvdbLink,
|
||||||
updateMovie,
|
updateMovie,
|
||||||
youtubeLink,
|
youtubeLink,
|
||||||
} from "./movie";
|
} from "./movie";
|
||||||
@@ -87,8 +89,10 @@ import {
|
|||||||
fileAttributeTags,
|
fileAttributeTags,
|
||||||
formatAirDate,
|
formatAirDate,
|
||||||
isUnaired,
|
isUnaired,
|
||||||
|
type SeriesMetadata,
|
||||||
seasonCounts,
|
seasonCounts,
|
||||||
seasonTarget,
|
seasonTarget,
|
||||||
|
seriesMetadata,
|
||||||
setEpisodeWanted,
|
setEpisodeWanted,
|
||||||
setSeasonTracked,
|
setSeasonTracked,
|
||||||
type TvTarget,
|
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 {
|
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||||
const view = must<HTMLElement>("#movie");
|
const view = must<HTMLElement>("#movie");
|
||||||
const deckEl = must<HTMLElement>("#deck");
|
const deckEl = must<HTMLElement>("#deck");
|
||||||
@@ -1384,65 +1450,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
|||||||
castRows.replaceChildren();
|
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) {
|
async function loadMetadata(id: number, ticket: number) {
|
||||||
const outcome = await movieMetadata(id);
|
const outcome = await movieMetadata(id);
|
||||||
if (ticket !== sequence || movieId !== id) {
|
if (ticket !== sequence || movieId !== id) {
|
||||||
@@ -2682,9 +2689,18 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
|||||||
const view = must<HTMLElement>("#series");
|
const view = must<HTMLElement>("#series");
|
||||||
const deckEl = must<HTMLElement>("#deck");
|
const deckEl = must<HTMLElement>("#deck");
|
||||||
const back = must<HTMLButtonElement>("#series-back");
|
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 titleEl = must<HTMLElement>("#series-title");
|
||||||
const yearEl = must<HTMLElement>("#series-year");
|
const yearEl = must<HTMLElement>("#series-year");
|
||||||
const chipsEl = must<HTMLElement>("#series-chips");
|
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 statusEl = must<HTMLElement>("#series-status");
|
||||||
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
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 {
|
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
||||||
const counts = seasonCounts(season);
|
const counts = seasonCounts(season);
|
||||||
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
|
return chip(`${counts.available}/${counts.wanted} on disk`, (span) => {
|
||||||
@@ -3056,8 +3173,10 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
paintHeader();
|
paintHeader();
|
||||||
|
clearRichDetail();
|
||||||
renderSeasons();
|
renderSeasons();
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
void loadMetadata(id, ticket);
|
||||||
if (focusKey !== null) {
|
if (focusKey !== null) {
|
||||||
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
|
view.querySelector<HTMLElement>(`[data-focus-id="${focusKey}"]`)?.focus();
|
||||||
focusKey = null;
|
focusKey = null;
|
||||||
@@ -3078,6 +3197,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
|||||||
board.hidden = true;
|
board.hidden = true;
|
||||||
deckEl.hidden = true;
|
deckEl.hidden = true;
|
||||||
view.hidden = false;
|
view.hidden = false;
|
||||||
|
clearRichDetail();
|
||||||
back.focus();
|
back.focus();
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
@@ -3087,6 +3207,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
|||||||
seriesId = null;
|
seriesId = null;
|
||||||
seasons = null;
|
seasons = null;
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
|
clearRichDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ export function tmdbMovieLink(tmdbId: number): string {
|
|||||||
return `https://www.themoviedb.org/movie/${tmdbId}`;
|
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 {
|
export function tmdbPersonLink(tmdbId: number): string {
|
||||||
return `https://www.themoviedb.org/person/${tmdbId}`;
|
return `https://www.themoviedb.org/person/${tmdbId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// release schemas — same reasoning as search.ts: the generated client
|
// release schemas — same reasoning as search.ts: the generated client
|
||||||
// (src/api/) is uncommitted, so CI's tsc cannot see it.
|
// (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 type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
|
||||||
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
|
||||||
|
|
||||||
@@ -239,6 +240,46 @@ export async function waiveAndGrabTv(
|
|||||||
return { kind: "done", overrideWritten };
|
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 --------------------------------------------------- */
|
/* ---- display helpers --------------------------------------------------- */
|
||||||
|
|
||||||
/** Wanted / on-disk counts for one season, whatever its number. */
|
/** 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) --------------------------- */
|
/* ---- 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 */
|
/* one season: a collapsible group with its rule, counts and actions */
|
||||||
.season {
|
.season {
|
||||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||||
@@ -1284,12 +1279,13 @@ body {
|
|||||||
overflow-wrap: anywhere;
|
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
|
/* The hero is one machined module carrying what the title IS — shared by
|
||||||
hotlinks from image.tmdb.org (§9.6) and sinks into the console charcoal
|
the movie and series pages, which is why its classes read `movie-*`.
|
||||||
under a scrim, so the readouts keep their contrast — a photo never sits
|
The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the
|
||||||
behind text bare. */
|
console charcoal under a scrim, so the readouts keep their contrast — a
|
||||||
|
photo never sits behind text bare. */
|
||||||
|
|
||||||
.movie-hero {
|
.movie-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user