diff --git a/web/index.html b/web/index.html index 1046c98..2624b4a 100644 --- a/web/index.html +++ b/web/index.html @@ -401,13 +401,36 @@
-
-

—

- - -
+
- + +
+
+ +
+
+

—

+ +
+ + + + + + +
+
+
+
+ + +
diff --git a/web/src/main.ts b/web/src/main.ts index 45c9100..a5bdc5f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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("#movie"); const deckEl = must("#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("#series"); const deckEl = must("#deck"); const back = must("#series-back"); + const hero = must("#series-hero"); + const poster = must("#series-poster"); const titleEl = must("#series-title"); const yearEl = must("#series-year"); const chipsEl = must("#series-chips"); + const ratingEl = must("#series-rating"); + const metaEl = must("#series-meta"); + const taglineEl = must("#series-tagline"); + const overviewEl = must("#series-overview"); + const actionsEl = must("#series-actions"); + const castSection = must("#series-cast"); + const castRows = must("#rows-series-cast"); const statusEl = must("#series-status"); const seasonsList = must("#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(`[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() { diff --git a/web/src/movie.ts b/web/src/movie.ts index 50f40f3..acdf984 100644 --- a/web/src/movie.ts +++ b/web/src/movie.ts @@ -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}`; } diff --git a/web/src/series.ts b/web/src/series.ts index a0230aa..eb48246 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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 { + 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. */ diff --git a/web/src/style.css b/web/src/style.css index 179b692..4d49d81 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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;