From 072ee898365c0673e18039b99be8525cd0c7938b Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 12 Oct 2026 19:01:01 +0200 Subject: [PATCH 01/26] docs(spec): design consistent card size and richer title pages (spec 0022) Co-Authored-By: Claude Opus 5.5 --- docs/scope/scope.md | 17 ++ .../specs/0022-card-size-title-pages/index.md | 153 ++++++++++++++++++ .../0022-card-size-title-pages/rationale.md | 61 +++++++ 3 files changed, 231 insertions(+) create mode 100644 docs/specs/0022-card-size-title-pages/index.md create mode 100644 docs/specs/0022-card-size-title-pages/rationale.md diff --git a/docs/scope/scope.md b/docs/scope/scope.md index 39e16f4..428702f 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -39,6 +39,7 @@ _2026-10-06: the `design/` artboards were removed. The UI built from them carrie | 22 | Media tabs on every page | Slice 9 | done | | 23 | Progress based library pages | Slice 10 | done | | 24 | Home carousels | Slice 11 | done | +| 25 | Consistent card size and richer title pages | Slice 12 | in-progress | ## Foundations @@ -414,10 +415,26 @@ spec [0020](../specs/0020-progress-based-library-pages/index.md) · code in `lib - [x] Test it: `/test home carousels` spec [0021](../specs/0021-home-carousels/index.md) · code in `lib/catalog/lists.ts`, `lib/catalog-list-read.ts`, `lib/tmdb/lists.ts`, `components/catalog/`, `app/shows/{slug}/`, `app/movies/{slug}/`, `proxy.ts`, `next.config.ts` +## Slice 12: Title pages + +### 25. Consistent card size and richer title pages · in-progress +Two strands, one feature (merged on 2026-10-12). **Cards:** grid cards match the home row card (about 180px) everywhere: `PosterGrid` steps 2 / 3 (`sm`) / 4 (`md`) / 5 (`lg`) / 6 (`xl`) columns and its `sizes` follows, on the list pages, `/search` and the three library pages; the season grid keeps its 2 and 4 columns. A 20 title page leaves a short last row, accepted. **Title pages:** movie and show pages gain a Trailer (hero button, modal with a click to load `youtube-nocookie` embed; YouTube joins `PROCESSORS` and the privacy text), Where to watch (TMDB watch providers grouped Stream, Free, Rent, Buy, region from `x-vercel-ip-country` with a `US` fallback, named in the heading, JustWatch attribution, an honest "Not streaming in {country}" line), More like this (TMDB recommendations in the home row card, hidden when empty), key crew (Director and Writers, or Created by), an age rating chip for the same region, and a Facts panel (status, original title and language, budget and revenue when known, companies, networks, type, air dates). Order: hero, overview, crew, the show's rating and seasons, where to watch, cast, more like this, facts. Settled in a grilling session on 2026-10-12. +**Done when:** every changed grid renders cards close to the home row width with matching image `sizes` and the season grid unchanged; both title pages show each new section from cached TMDB data with missing values omitted, never invented; YouTube loads only after a click; the region is named wherever it applies; the shells stay prerendered; and `/privacy` lists YouTube with `LEGAL_LAST_UPDATED` bumped. +- [x] Design it (spec): `/architect consistent card size and richer title pages` +- [ ] Build it: `/develop consistent card size and richer title pages` + - [ ] Card strand: the `columns` preset with `catalog` and `wide`, the sizes and eager maps, the season grid on `wide`, the library skeleton and `/showcase`, checked at five widths — AC-1 to AC-7 + - [ ] Tracer bullet: the movie extras read, `lib/region.ts` and Where to watch on the movie page end to end against live TMDB — AC-8 to AC-10, AC-12, AC-18 + - [ ] Movie page thickened: Trailer dialog, age chip, the legal change, More like this, crew and facts in order — AC-11, AC-13 to AC-17, AC-19 + - [ ] Show page parity and proof: the TV extras read, the new show fields, every section, tests, 375px and keyboard passes, checks and spec amendments — AC-7, AC-8, AC-10 to AC-17, AC-20 +- [ ] Verify it: `/check verify consistent card size and richer title pages` +- [ ] Test it: `/test consistent card size and richer title pages` +spec [0022](../specs/0022-card-size-title-pages/index.md) + ## Deferred Out of scope for the current build pass, kept so the plan stays honest. - **Public profiles and social features**: ruled out of the MVP by `AGENTS.md` - **Per visitor region and timezone on the home rows** (from spec 0021): Now playing, Upcoming, Airing today and On the air use the fixed `PL` region and `Europe/Warsaw`. Following the visitor needs its own decision (a request header or an account setting). +- **More title page sections** (from the 2026-10-12 grilling): external links (IMDb, homepage, socials) and the movie collection row are a later batch; keywords, image gallery, TMDB reviews and the aggregate show cast were skipped - **Error monitoring and product analytics**: not selected for this pass - **Self serve account deletion and data export** (from specs 0005 and 0017): GDPR requests are handled by email for now. Undesigned; a `/account` flow needs the Supabase admin API or a security definer function, an elevated call nothing else in the app uses, so it deserves its own decision, including what happens to the cascading tracking data - **Rate limit on sign in and sign up** (from spec 0018): Supabase counts Vercel's addresses, so its auth limit is shared by every visitor ([docs/deploy.md](../deploy.md) section 6). If it bites, add a second Vercel Firewall rule on sign in and sign up posts per IP. (The `/api/search` limit from spec 0010 shipped as a firewall rule in feature 20.) diff --git a/docs/specs/0022-card-size-title-pages/index.md b/docs/specs/0022-card-size-title-pages/index.md new file mode 100644 index 0000000..3faceb4 --- /dev/null +++ b/docs/specs/0022-card-size-title-pages/index.md @@ -0,0 +1,153 @@ +# 0022. Consistent card size and richer title pages + +**Date**: 2026-10-12 +**Status**: In Progress + +## Summary + +This spec covers two related changes. First, poster cards on every grid page (See all lists, `/search`, Watchlist, Upcoming and Watched) shrink to about the home row card's 180px by stepping the grid from 2 up to 6 columns; the season grid keeps its 2 and 4 columns. Second, movie and show pages gain a trailer, where to watch, more like this, key crew, an age rating and a facts panel. The new title data comes from one extra TMDB request per title, cached for an hour and shared by every visitor, so the library pages, which read up to 500 shows, stay as cheap as today. YouTube is added to the privacy policy, and the visitor's country (from Vercel's `x-vercel-ip-country` request header, never stored) picks the streaming and age rating region. + +## Requirements + +**User stories**: +- As a visitor, I want a poster to be the same size in a home row and on its See all page, so the catalog feels like one product. +- As a signed in user, I want my library cards at that same size with their controls still easy to use. +- As a visitor on a title page, I want to watch the trailer, see where I can stream, rent or buy it in my country, find similar titles, and read who made it and its basic facts, without leaving BeStats to look them up. + +**Acceptance criteria**: + +*Card size strand* + +- **AC-1**: The default (`catalog`) `PosterGrid` has 2 columns below `sm` (640px), 3 from `sm`, 4 from `md` (768px), 5 from `lg` (1024px) and 6 from `xl` (1280px). The 1200px cap, centring and gaps (`gap-x-4 gap-y-6`) are unchanged. +- **AC-2**: The `catalog` grid applies to the See all list pages (`/shows/{slug}`, `/movies/{slug}`), `/search`, Watchlist, Upcoming and Watched (both types), every one of their skeletons, and `/showcase`. At 1280px and wider a card is between 180px and 190px wide. From 1024px to 1279px cards are deliberately larger (about 186px to 228px). +- **AC-3**: The season grid on `/shows/{id}` and its skeleton use the `wide` preset, which is exactly today's layout: 2 columns below `md`, 4 from it, with today's `sizes`. +- **AC-4**: Each preset's `sizes` string has one `floor(100 / columns)vw` entry per fluid column step, and one fixed px entry, `ceil((1200 - (columns - 1) × 16) / columns)`, for every column count the grid shows while capped at 1200px. For `catalog` that is `(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw`. For `wide` it is today's `(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw`. `poster-grid.test.tsx` checks every key of `POSTER_GRID_SIZES` with this rule, so a preset with no sizes fails the type check, and a column change that misses its sizes fails the suite. +- **AC-5**: Eager posters are one full row at the preset's widest count: 6 for `catalog` and 4 for `wide`, read from one exported `POSTER_GRID_EAGER` map. The four local `EAGER_POSTERS` copies are removed. +- **AC-6**: `LibrarySkeleton` shows one row at the current column count at every width (6 cards, visibility `["", "", "hidden sm:block", "hidden md:block", "hidden lg:block", "hidden xl:block"]`), so a one row library does not shift when it arrives. The list page and search skeletons keep their 20 cards. +- **AC-7**: At 375px, 640px, 768px, 1024px and 1280px no grid page scrolls sideways, the missing poster tile keeps a real poster's footprint, and every card control keeps its 44px tap target. `/showcase` shows 6 grid cards. + +*Title page strand: data* + +- **AC-8**: Each title page makes at most one new TMDB request per title per cache lifetime: `/movie/{id}` or `/tv/{id}` with `append_to_response=videos,recommendations,watch/providers,release_dates` (movie) or `…,content_ratings` (show) and `include_video_language=en,null`, cached with `cacheLife("hours")`. `getTvShow`, `getMovie` and every library read make no extra request and keep their lifetimes. +- **AC-9**: The region is the `x-vercel-ip-country` request header when it is two capital letters that `Intl.DisplayNames` can name and is not `XX` or `T1`, otherwise `US`. It is read outside every cached scope, inside the Suspense boundary of the section that needs it, once per request (`getVisitorRegion` is wrapped in React `cache()`), and is never stored, logged or sent to TMDB. The extras read returns every country; the page picks the region's entry after the cached read returns. Every region dependent section names the country in English (`Intl.DisplayNames`), for example "Where to watch in Poland". +- **AC-10**: Every route keeps its prerendered shell (`pnpm build` route table unchanged for `/movies/[id]` and `/shows/[id]`), and nothing new reads cookies or headers outside a Suspense boundary. No SEO surface (metadata, JSON-LD, share image) receives the region, the age rating or any extras data, and `proxy.ts`, the sitemap and `generateMetadata` are unchanged. + +*Title page strand: sections* + +- **AC-11** (Trailer): When the extras include a YouTube video of type `Trailer` (else `Teaser`), preferring `official` and then the newest `published_at` (compared with `Date.parse`), the hero's new `actions` slot shows a Trailer button. It is the base-ui `Dialog.Trigger`, so focus returns to it on close. The dialog (full screen below `md`) renders, only while open, an iframe from `https://www.youtube-nocookie.com/embed/{key}?autoplay=1&rel=0&playsinline=1` with a descriptive `title`, `allow="autoplay; encrypted-media; picture-in-picture; fullscreen"` and `referrerPolicy="strict-origin-when-cross-origin"`. Closing the dialog unmounts the iframe. A key that is not 6 to 20 characters of `[A-Za-z0-9_-]` is treated as no trailer. No trailer means no button. +- **AC-12** (Where to watch): A section titled "Where to watch in {country}" lists providers from the region's entry in three groups, in this order and each only when non empty: Stream (`flatrate`), Free (`free` then `ads`, merged by `provider_id`), and Rent or buy (`rent` then `buy`, merged by `provider_id`). A provider may appear in more than one group. Each group is ordered by `display_priority`, then by name, and each provider shows its `w92` logo (name text when the logo is missing). The section links once to the `link` TMDB returns for the region and carries the JustWatch credit line, `JUSTWATCH_ATTRIBUTION` ("Availability data from JustWatch"), a new constant in `lib/legal/operator.ts` beside `TMDB_ATTRIBUTION` that the legal boundary test keeps in one place. With no entry or no providers for the region it shows "Not streaming in {country} right now." and the JustWatch line. +- **AC-13** (Age rating): The hero meta shows a chip with the region's rating: for a movie, the non empty certification of release type 3 (Theatrical) for the region, else the first non empty certification for the region in TMDB's array order (trimmed, an empty string counts as empty); for a show, the region's `content_ratings` rating. No rating for the region means no chip, never another country's. +- **AC-14** (More like this): A row titled "More like this" shows up to 12 of page 1 of TMDB recommendations in the home row card (same width, badges and your tracked, planned or watched status, through `CatalogListCards`), with the arrow scroller and no See all link. Recommendations are validated with the existing list item schemas that carry `adult` (`movieListItemSchema`, `tvListItemSchema`) and adult titles are dropped, as on the spec 0021 lists. No recommendations means no row. +- **AC-15** (Key crew): Under the overview, a movie shows "Directed by" (crew job `Director`) and "Written by" (jobs `Screenplay` and `Writer`, not `Story`; names deduplicated in credit order, then capped at 3), and a show shows "Created by" (`created_by`, at most 3). Each line appears only when it has a name. +- **AC-16** (Facts): A Facts panel at the end of the page shows, each only when TMDB has a value: for a movie, status, original title (only when it differs from the title), original language, budget and revenue (only when above 0, `formatUsdCompact`, US dollars in compact form with at most one decimal, such as "$165M" or "$1.5B"), and production companies; for a show, status, type, original language, networks (`w154` logo, else name), production companies, and the first and last air dates (`formatReleaseDate`). Status and type show TMDB's English strings as is ("Released", "Returning Series", "Miniseries"), languages go through `languageName`. Nothing is shown as "Unknown" or 0. Facts and the hero read only `getMovie` and `getTvShow`, never the copy of the details inside the extras response. +- **AC-17** (Page order): The movie page reads hero (with Trailer and age chip), overview, crew, where to watch, cast, more like this, facts. The show page reads hero, overview, crew, your rating and seasons, where to watch, cast, more like this, facts. +- **AC-18** (States): While the extras load, Where to watch and More like this show skeletons at their usual height, and the Trailer button and age chip render nothing (the hero grows slightly when they arrive, and an empty section collapses on arrival; both accepted, as with the tracking slot). If the extras read fails, including a 404 for a title the detail read found, the Trailer button and age chip are absent, and Where to watch and More like this each show a one line error with a Retry link to the page (`RetryLink`). The rest of the page, including tracking, is unaffected. Crew and Facts come from the existing detail reads and share their states. +- **AC-19** (Legal): `PROCESSORS` gains YouTube (trailer playback, only after you press Trailer), `/privacy` adds one sentence saying the approximate country Vercel derives from your connection picks the streaming and age rating region and is not stored, and `LEGAL_LAST_UPDATED` is bumped to the ship date. +- **AC-20** (Mobile and keyboard): At 375px every new section fits without sideways page scroll (the recommendations row scrolls inside itself), the trailer dialog fills the screen, every new control is reachable by keyboard with visible focus, and tap targets stay 44px. + +## Decision + +**Chosen options**: a `columns` preset on `PosterGrid` (`catalog` default, `wide` for seasons) that owns its classes, `sizes` and eager count; and one separate cached "extras" read per title for the new TMDB data, with the region picked per request outside the cache. + +**Implementation skills**: `next-dev-loop` (`vercel/next.js`, `.agents/skills/next-dev-loop/`) for checking each section in the running app. `next-cache-components-optimizer` (`vercel/next.js`, `.agents/skills/next-cache-components-optimizer/`) if a title page's shell regresses. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Data model sketch**: no database change. New TMDB types in `lib/tmdb/types.ts`: + +- `MovieExtras` and `TvExtras`, two types with the same fields: `{ trailer: Trailer | null; recommendations: TaggedCatalogPage; watchProviders: Record; ageRatings: Record }`, where `recommendations` is already the `{ mediaType, paged }` shape `CatalogListCards` takes (`mediaType` fixed per type, `paged` built from page 1). Region maps are keyed by ISO 3166 1 code; `ageRatings` already holds the picked certification per country (the AC-13 rule runs in the normalizer for every country), and the page reads `[region]` after the cache returns. +- Appended TMDB shapes the extras schema reads (a separate non strict schema that picks only these keys; every list is `.nullish()` and defaults to empty): `videos.results[]` `{ site, type, key, name, official?, published_at? }`; `recommendations` page 1, results through the list item schemas; `watch/providers.results` `{ [CC]: { link?, flatrate?, free?, ads?, rent?, buy? } }`, each provider `{ provider_id, provider_name, logo_path?, display_priority? }`; `release_dates.results[]` `{ iso_3166_1, release_dates: [{ certification, type }] }` (type 3 is Theatrical); `content_ratings.results[]` `{ iso_3166_1, rating }`. The request keeps `buildUrl`'s `language=en-US`; `include_video_language=en,null` is what stops that language filter from dropping videos with no language. +- `Trailer` = `{ key: string; name: string }`. +- `RegionProviders` = `{ link: string | null; stream: Provider[]; free: Provider[]; rentOrBuy: Provider[] }`, already grouped and deduplicated by the normalizer. +- `Provider` = `{ id: number; name: string; logoUrl: string | null }`. +- `Movie` adds `status: string | null`, `budget: number | null`, `revenue: number | null`, `productionCompanies: Company[]`, `directors: string[]`, `writers: string[]` (parsed from the existing `credits.crew`, no new request). +- `TvShow` adds `type: string | null`, `createdBy: string[]`, `networks: Company[]`, `productionCompanies: Company[]` (from the existing details response, no new request). +- `Company` = `{ id: number; name: string; logoUrl: string | null }`. + +**Interface surface** (no HTTP endpoints, no Server Actions): + +| Unit | Shape | Notes | +|---|---|---| +| `fetchMovieExtras(id)`, `fetchTvExtras(id)` | uncached fetch + Zod + normalize | `lib/tmdb/extras.ts`, the inner half per spec 0002 AC-23; `fetchMovie` extends its schema to keep `credits.crew` | +| `getMovieExtras(id)`, `getTvExtras(id)` | `use cache`, `cacheLife("hours")`, returns `TmdbResult` | in `reads.ts`, exported through the barrel | +| `getVisitorRegion()` | `Promise<{ code: string; name: string }>` | new `lib/region.ts` (outside `lib/tmdb`, which may not read request state), `server-only`, React `cache()`d, reads `headers()`; the pure `parseRegion(header)` lives in `lib/catalog/region.ts` and is tested alone | +| `DetailHero` | new `actions?: ReactNode` slot, rendered beside `tracking` | holds the Trailer slot; the age chip goes inside each page's `meta` | +| Each extras slot | an async Server Component that calls `getMovieExtras(id)` / `getTvExtras(id)` itself | repeated calls in one render share one cached read; the trailer slot passes `{ key, name }` as props to the client `TrailerButton` | +| `PosterGrid` | `columns?: "catalog" \| "wide"` | plus exported `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER`, both `Record`; class strings are full literals so Tailwind emits them | +| New components | `TrailerButton` (client, uses `ui/dialog`), `WhereToWatch`, `AgeRatingChip`, `MoreLikeThis`, `CrewLines`, `FactsPanel` | in `components/catalog/` (shared by both pages); each async slot sits in its own Suspense boundary | + +**Value sourcing**: + +| Action | Value | Source | +|---|---|---| +| Grid | columns, `sizes`, eager count | the preset in `poster-grid.tsx` (AC-1 to AC-5) | +| Trailer | video key and name | extras `videos.results`, filtered by AC-11 | +| Where to watch | providers, groups, link | extras `watch/providers.results[region]` | +| Where to watch, age chip | region code and country name | `x-vercel-ip-country` → `parseRegion` → `Intl.DisplayNames(["en"], { type: "region" })`, fallback `US` (AC-9) | +| Age chip | certification | movie: extras `release_dates.results[region]`; show: extras `content_ratings.results[region]` (AC-13) | +| More like this | titles | extras `recommendations.results` (page 1), normalized with the existing summary normalizers | +| More like this | your status per card | the existing grid state reads behind `CatalogListCards` (`getMovieGridStates`, `lib/tracking/show-grid-status.ts`) | +| Crew | names | movie: existing `credits.crew`; show: existing `created_by` | +| Facts | every field | existing detail responses (`getMovie`, `getTvShow`), newly parsed | +| Facts | money format | new `formatUsdCompact` in `lib/format.ts` (`Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact" })`) | +| Facts | language name | existing language name helper in `lib/format.ts` | +| Provider and company logos | image URL | `imageUrl` in `lib/tmdb/images.ts` from `logo_path` (host `image.tmdb.org`, already allowed) | + +**Key invariants**: +- Nothing user or request specific enters a cached scope (lib/tmdb rule): the extras read takes only the id, and the region is applied after it returns. +- The library and catalog grid reads never trigger the extras read. +- Every preset's classes, `sizes` and eager count change together, and the test covers every preset. +- Missing stays missing: no section, line or chip is shown with placeholder text, a zero, or another region's data. +- A failure crosses the cache boundary as a `TmdbResult`, never a thrown `TmdbError`. + +**Security model**: everything new is public catalog data. The region header is read per request and is not stored or logged. The YouTube iframe does not exist in the page until the visitor presses Trailer. No new environment variable, secret or database access. The TMDB token stays in `lib/tmdb/env.ts`. + +**Configuration required**: none. + +**Critical test scenarios**: +- `poster-grid.test.tsx` loops over `POSTER_GRID_SIZES` and checks each against its columns with the AC-4 rule, and checks that `wide` keeps today's classes, verifies **AC-1**, **AC-3**, **AC-4**. +- Fixture tests for the extras normalizer: trailer choice (official trailer over teaser, newest wins, bad key dropped), provider grouping and deduplication, certification choice (type 3 first, empty skipped), adult recommendations dropped, missing logos, verifies **AC-11** to **AC-14**. +- `parseRegion`: `PL` → Poland, missing, lowercase, three letters, `XX`, `T1` or an unnameable code → `US`, verifies **AC-9**. Another real country is checked on a preview with `curl -H "x-vercel-ip-country: PL"`, since local runs have no header and always show `US`. +- Crew and facts normalizers: writer deduplication and cap, zero budget becomes null, verifies **AC-15**, **AC-16**. +- A request scope test: rendering a library tab or `getTvShow` makes no extras call, and the extras read takes no request state, verifies **AC-8**, **AC-9**. +- In the running app: a movie and a show with every section, a title with none (no trailer, no providers, no recommendations), a forced extras failure, the trailer dialog by keyboard, and every grid page at the five widths, verifies **AC-2**, **AC-7**, **AC-11** to **AC-18**, **AC-20**. +- `pnpm build` route table and the prerendered shells, verifies **AC-10**. +- `/privacy` shows YouTube and the country sentence; the legal boundary test passes with `JUSTWATCH_ATTRIBUTION`, verifies **AC-12**, **AC-19**. +- Existing tests to update: `movie-hero.test.tsx` and the show hero tests (new slot), `lib/legal/boundary.test.ts`, the `/privacy` page test and any `PROCESSORS` assertion, `app/showcase/page.test.tsx`, `skeleton.test.tsx` and the library skeleton tests. `json-ld.test.tsx` and `security-boundary.test.ts` should need no change; if they do, something leaked. + +## Build plan + +1. Card preset: add `columns` with `catalog` and `wide`, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER`; point `POSTER_SIZES` at the catalog value; switch `SeasonGrid` and `ShowDetailSkeleton` to `wide` with its sizes; rewrite `poster-grid.test.tsx` to loop over presets and update its `covers` header. Satisfies **AC-1**, **AC-3**, **AC-4**. +2. Card rollout: replace the four `EAGER_POSTERS` with `POSTER_GRID_EAGER.catalog`, give `LibrarySkeleton` the 6 card visibility list, show 6 cards in `/showcase`, then check every grid page at 375, 640, 768, 1024 and 1280px. Satisfies **AC-2**, **AC-5**, **AC-6**, **AC-7**. +3. Tracer bullet for title pages: the extras schemas, normalizer and cached reads for movies, `lib/region.ts`, and the Where to watch section on the movie page with its skeleton, empty and error states, verified against live TMDB in the running app and in the build. Satisfies **AC-8**, **AC-9**, **AC-10**, **AC-12**, **AC-18**. +4. Trailer and age rating on the movie page: `TrailerButton` with the dialog and the click to load iframe, `AgeRatingChip` in the hero meta, and the legal change (`PROCESSORS`, the country sentence, `LEGAL_LAST_UPDATED`). Satisfies **AC-11**, **AC-13**, **AC-19**. +5. More like this, crew and facts on the movie page: the recommendations row through `CatalogListCards`, the new `Movie` fields and `formatUsdCompact`, `CrewLines` and `FactsPanel`, in the AC-17 order. Satisfies **AC-14**, **AC-15**, **AC-16**, **AC-17**. +6. Show page parity: the TV extras read (with `content_ratings`), the new `TvShow` fields, and every section on `/shows/{id}` in its order. Satisfies **AC-8**, **AC-11** to **AC-17**. +7. Proof: the fixture, region and request scope tests, 375px and keyboard passes, `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`; amendment notes in spec 0004 (AC-9), spec 0006 and spec 0009 (new sections), and `prompts/grid-tabs-footer-legend.md`. Satisfies **AC-7**, **AC-10**, **AC-20**. + +## Consequences + +**Positive**: +- One card size everywhere, and 30 titles per desktop screen instead of 20. +- Title pages answer the questions visitors leave for: what it looks like, where to watch it, what else is like it, who made it. +- The library pages pay nothing for the new data. + +**Negative / tradeoffs**: +- A 20 title page ends with a short last row at 3, 5 and 6 columns, the problem the 2 and 4 column grid was built to remove. +- Smaller desktop posters make captions and controls tighter, and six eager posters means a phone preloads three rows instead of two. +- Each title page view can cost one more TMDB request per hour per title, and a cold page waits on it for two of its sections. +- Embedding YouTube adds a processor and a legal update. Region from an IP header is approximate (VPNs and travel show another country), and local development always shows the `US` fallback. +- The title pages get longer, and more sections means more states to keep honest. + +**Neutral**: +- Spec 0004 AC-9 returns to its original column counts. The 2 and 4 column decision in `prompts/grid-tabs-footer-legend.md` now applies only to the season grid. +- The deferred "per visitor region on the home rows" item now has a precedent (`lib/region.ts`) it can reuse. + +## Follow-up + +- [ ] `components/AGENTS.md` still describes the grid as 2 and 4 columns with 5 full rows, and `lib/tmdb/AGENTS.md` should list the extras read and its `hours` lifetime; `/sync` updates both after the build. +- [ ] Later batch (from the grilling, kept on the scope's Deferred list): external links and the movie collection row. diff --git a/docs/specs/0022-card-size-title-pages/rationale.md b/docs/specs/0022-card-size-title-pages/rationale.md new file mode 100644 index 0000000..8100790 --- /dev/null +++ b/docs/specs/0022-card-size-title-pages/rationale.md @@ -0,0 +1,61 @@ +# 0022. Consistent card size and richer title pages: rationale + +## Context + +> ⚠️ Premise note: the card strand partly reverses [prompts/grid-tabs-footer-legend.md](../../../prompts/grid-tabs-footer-legend.md), which moved every grid from 2/3/4/5/6 columns to 2/4 so a 20 title page fills five complete rows. Six columns bring the ragged last row back, and TMDB pages are fixed at 20, so a page size change cannot fix it on the catalog pages. The owner accepted the ragged row in the 2026-10-12 grilling session in exchange for one card size everywhere. Spec 0004 AC-9 originally asked for exactly these column counts. + +**Card size.** Spec 0021 gave the home rows fixed width cards, 140px below `md` and 180px from it (`ROW_ITEM_CLASS`). Every other grid goes through `PosterGrid`, which is fluid at 2 and 4 columns and capped at 1200px, so at 1280px a grid card is about 288px, 1.6 times the row card. The column counts live in `PosterGrid` and the image `sizes` in `POSTER_SIZES`; if one changes without the other, every screen silently downloads the wrong image width, which is why `poster-grid.test.tsx` exists. Eager poster counts (`EAGER_POSTERS = 4`, copied in four files) and `LibrarySkeleton`'s one row are also tied to the column count. The season grid uses `PosterGrid` too, but holds a few seasons in a narrower detail layout. + +**Title pages.** The movie page shows a hero, tracking, overview and cast; the show page adds meta, progress, your rating and seasons. Movies request `credits,translations` and shows `translations`. TMDB also offers videos, watch providers (supplied by JustWatch, whose attribution TMDB requires), recommendations, release dates with certifications, content ratings, and more detail fields the app currently drops. The constraint that shapes everything: `getTvShow` is the read the library pages run for every tracked show, up to 500 per tab, so anything appended to it is paid hundreds of times per library view. Watch providers and certifications are per country, the app stores no locale, and `lib/tmdb` forbids request state inside a cached scope. Embedding a third party player is a new processor under spec 0017, which requires `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` to change in the same PR. + +## Options considered + +### Grid: a `columns` preset on `PosterGrid` (chosen) + +`PosterGrid` takes `columns: "catalog" | "wide"`; each preset owns its class string, `sizes` and eager count in exported maps. + +**Pros**: the column, sizes and eager invariant lives in one file and one test that loops over every preset; the season grid opts out by name. +**Cons**: callers of `wide` must pass its sizes to their cards; a small new API. + +### Grid: a class override from the season grid + +Change the default and let `SeasonGrid` pass overriding classes, merged by `cn`. + +**Pros**: smallest change. +**Cons**: the invariant splits across files and the merge must undo three breakpoints, which the test cannot see. + +### Grid: the season grid owns its own list + +**Pros**: plain to read. +**Cons**: duplicates the cap, gap and list semantics, and the season grid escapes the sizes test. + +### Data: one separate extras read per title (chosen) + +A new cached read per title appends videos, recommendations, watch providers and release dates or content ratings in one TMDB request, `hours` lifetime. Crew and facts come from parsing more of the existing detail responses. + +**Pros**: one request per title per hour shared by every visitor; the library reads are untouched; the shorter lifetime fits provider availability without shortening the movie read's `days`. +**Cons**: the details payload comes back a second time inside the extras response; two sections share one failure. + +### Data: append to the existing detail reads + +**Pros**: nothing new to wire. +**Cons**: the 500 show library read gets much heavier and the movie read inherits a shorter lifetime. + +### Data: one read per section + +**Pros**: each section fails alone. +**Cons**: four TMDB requests per cold title instead of one, against a rate limited API. + +### Region: request header (chosen), fixed region, or account setting + +The header follows the visitor with no storage, and the country is named on screen so a wrong guess is visible. A fixed region is wrong for most visitors; an account setting needs a migration and still needs a default for visitors who are signed out. + +### Trailer: dialog with click to load embed (chosen) or a link out + +A link out avoids a processor, but the owner asked for trailers on the page. Loading `youtube-nocookie.com` only on click keeps YouTube off every page view that does not ask for it. + +## Rationale + +The grid failure mode is silent (wrong image widths, nothing visibly broken), so the design keeps every number that depends on the column count in one exported map per preset, and the test iterates the map. The cross check showed the conditional `library` preset could not fail at a width that matters (library cards are 172px at 768px today and fit, and 6 columns give 186px), so it was dropped for one grid everywhere. The fixed 228px entry for the capped five column range removes an avoidable overshoot. + +For the data, the deciding force is the library read: appending to `getTvShow` would multiply the cost of the new data by every tracked show, while a separate read costs one request per title per hour shared across visitors. Watch providers and certifications arrive for every country in one response, so the cache stays free of request state and the region is a cheap pick afterward, which honours the `lib/tmdb` rule. Hiding a missing age rating rather than falling back to another country, and naming the country on Where to watch, follow the project rule never to present data that may be false. Failure states follow spec 0021's row pattern (one line with Retry), so a slow or failing extras read never takes the page or its tracking down. From 880b97f8de485600079b71711943151030fe7209 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 12 Oct 2026 19:01:02 +0200 Subject: [PATCH 02/26] feat(ui): step the catalog grid to six columns, keep seasons wide Co-Authored-By: Claude Opus 5.5 --- app/showcase/page.tsx | 16 ++ components/catalog/catalog-list-page.test.tsx | 6 +- components/catalog/catalog-list-page.tsx | 11 +- components/library/library-grid.tsx | 10 +- components/library/library-panels.tsx | 15 +- components/library/show-library-tab.tsx | 7 +- components/poster-card.tsx | 15 +- components/poster-grid.test.tsx | 142 ++++++++++++++---- components/poster-grid.tsx | 71 +++++++-- components/search/search-results.tsx | 7 +- components/show/episode-list.tsx | 2 +- components/show/season-grid.tsx | 8 +- components/show/show-detail-skeleton.tsx | 5 +- 13 files changed, 225 insertions(+), 90 deletions(-) diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index e1f964d..e68ba8b 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -388,6 +388,22 @@ export default function ShowcasePage() {
  • +
  • + } + /> +
  • +
  • + } + /> +
  • diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx index 07c62b2..58a2bed 100644 --- a/components/catalog/catalog-list-page.test.tsx +++ b/components/catalog/catalog-list-page.test.tsx @@ -200,13 +200,13 @@ describe("the list page grid (AC-4, AC-9)", () => { ); }); - it("loads the first 4 posters eagerly and the rest lazily (AC-7)", async () => { + it("loads the first 6 posters eagerly and the rest lazily (AC-7; spec 0022, AC-5)", async () => { read.mockResolvedValue(tvPage(8, 30)); await renderGrid(); - expect(screen.getByText("Show 4")).toHaveAttribute("data-priority", "true"); - expect(screen.getByText("Show 5")).toHaveAttribute( + expect(screen.getByText("Show 6")).toHaveAttribute("data-priority", "true"); + expect(screen.getByText("Show 7")).toHaveAttribute( "data-priority", "false", ); diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx index 8c862f1..e15992e 100644 --- a/components/catalog/catalog-list-page.tsx +++ b/components/catalog/catalog-list-page.tsx @@ -4,7 +4,7 @@ import { Suspense } from "react"; import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; import { PaginationLinks } from "@/components/pagination-links"; -import { PosterGrid } from "@/components/poster-grid"; +import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; import { RetryLink } from "@/components/retry-link"; import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; @@ -21,13 +21,6 @@ import { TmdbError } from "@/lib/tmdb"; /** TMDB's list page size, which the skeleton mirrors. */ const PAGE_SIZE = 20; -/** - * Cards whose posters load eagerly: one full row at the widest grid (4 - * columns from `md`), so the first row is never lazy at any width (spec 0021, - * AC-7, keeping spec 0006, AC-1). - */ -const EAGER_POSTERS = 4; - const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const; type SearchParams = Promise>; @@ -124,7 +117,7 @@ async function ListGrid({ diff --git a/components/library/library-grid.tsx b/components/library/library-grid.tsx index c0aad35..fad14a9 100644 --- a/components/library/library-grid.tsx +++ b/components/library/library-grid.tsx @@ -10,7 +10,7 @@ import { setMovieWatched, setMovieWatchlist, } from "@/app/movies/actions"; -import { PosterGrid } from "@/components/poster-grid"; +import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; import { runUndoInToast, settleResultCall, @@ -38,12 +38,6 @@ import { libraryItemKey, } from "./types"; -/** - * Cards whose posters load eagerly: one full row at the widest grid, as on - * `/movies`. - */ -const EAGER_POSTERS = 4; - /** * The movie grid on `/watchlist`, `/upcoming` and `/watched`, and everything * a removal does (spec 0008, AC-5 to AC-7, AC-16, AC-18; spec 0020, AC-13). @@ -221,7 +215,7 @@ function LibraryGrid({ list={list} item={item} onRemove={() => remove(item)} - priority={index < EAGER_POSTERS} + priority={index < POSTER_GRID_EAGER.catalog} /> ); diff --git a/components/library/library-panels.tsx b/components/library/library-panels.tsx index 8888aa6..7477378 100644 --- a/components/library/library-panels.tsx +++ b/components/library/library-panels.tsx @@ -232,8 +232,8 @@ function LoadFailed({ } /** - * The static shell's stand in for the list: one row of cards, two below `md` - * and four from it. The real count is unknown until the read, and a full page + * The static shell's stand in for the list: one row of cards at the current + * column count, 2 up to 6 (spec 0022, AC-6). The real count is unknown until the read, and a full page * of placeholders made a short library collapse by several rows when it * arrived (prompts/grid-tabs-footer-legend.md); one row is the smallest * honest guess and never wraps. @@ -251,5 +251,12 @@ export function LibrarySkeleton() { ); } -/** Each card's visibility in that row: the last two show only from `md`. */ -const ROW_CARD_VISIBILITY = ["", "", "hidden md:block", "hidden md:block"]; +/** Each card's visibility in that row: one more card at each column step. */ +const ROW_CARD_VISIBILITY = [ + "", + "", + "hidden sm:block", + "hidden md:block", + "hidden lg:block", + "hidden xl:block", +]; diff --git a/components/library/show-library-tab.tsx b/components/library/show-library-tab.tsx index 831a891..1c73dba 100644 --- a/components/library/show-library-tab.tsx +++ b/components/library/show-library-tab.tsx @@ -1,5 +1,5 @@ import { PaginationLinks } from "@/components/pagination-links"; -import { PosterGrid } from "@/components/poster-grid"; +import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; import { getShowLibraryTab, type LibraryTabQuery, @@ -21,9 +21,6 @@ import { UpcomingShowCard, WatchedShowCard } from "./show-cards"; import type { LibraryList } from "./types"; import { WatchlistShowCard } from "./watchlist-show-card"; -/** Posters that load eagerly: one full row at the widest grid. */ -const EAGER_POSTERS = 4; - /** * A show tab (AC-9, AC-11, AC-12, AC-15 to AC-18): Watchlist with Mark * watched cards; Upcoming with dated and Date TBA cards; Watched with the @@ -73,7 +70,7 @@ export async function showLibraryTab(query: LibraryTabQuery) { itemId={itemId} today={today} rating={ratings.get(card.showId) ?? null} - priority={index < EAGER_POSTERS} + priority={index < POSTER_GRID_EAGER.catalog} /> ); diff --git a/components/poster-card.tsx b/components/poster-card.tsx index a568d84..5cc9f1d 100644 --- a/components/poster-card.tsx +++ b/components/poster-card.tsx @@ -4,16 +4,17 @@ import Image from "next/image"; import Link from "next/link"; import type * as React from "react"; +import { POSTER_GRID_SIZES } from "@/components/poster-grid"; + /** - * The `sizes` string that matches the grid in `PosterGrid`. + * The `sizes` string that matches the default (`catalog`) `PosterGrid`. * - * Kept next to the card rather than in the grid because `next/image` needs it - * on the image, and kept as one constant because a grid change that misses it - * silently ships the wrong image width to every screen (spec 0004, AC-8). - * Mirrors the column counts: 2 at base, 4 from md, and a fixed width once the - * grid reaches its 1200px cap. + * Kept as the card's default because nearly every card sits in that grid, and + * owned by the grid's preset map because a column change that misses it + * silently ships the wrong image width to every screen (spec 0004, AC-8; + * spec 0022, AC-4). A card in another preset passes that preset's sizes. */ -const POSTER_SIZES = "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw"; +const POSTER_SIZES = POSTER_GRID_SIZES.catalog; /** * The single most repeated element in the UI: a 2:3 poster inside a rounded diff --git a/components/poster-grid.test.tsx b/components/poster-grid.test.tsx index 4267c4a..5da42a5 100644 --- a/components/poster-grid.test.tsx +++ b/components/poster-grid.test.tsx @@ -2,31 +2,52 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { POSTER_SIZES } from "@/components/poster-card"; -import { PosterGrid } from "@/components/poster-grid"; +import { + POSTER_GRID_EAGER, + POSTER_GRID_SIZES, + PosterGrid, + type PosterGridColumns, +} from "@/components/poster-grid"; /** - * covers: AC-9, AC-8 + * covers: spec 0004 AC-9, AC-8; spec 0022 AC-1, AC-3, AC-4, AC-5 * * The grid's own comment names the failure this file is here to prevent: the - * column counts live in `poster-grid.tsx` and are mirrored by `POSTER_SIZES` - * in `poster-card.tsx`, and changing one without the other silently ships the + * column counts and their image `sizes` live side by side in + * `poster-grid.tsx`, and changing one without the other silently ships the * wrong image width to every screen. Nothing breaks visibly, nothing fails to * compile, the app just gets slower and blurrier. * * So the interesting test here is not "does it render a grid" but the - * cross file invariant: at every breakpoint, the `sizes` viewport width has to - * be roughly one column's worth of the viewport. + * invariant, checked for every preset: at every breakpoint, the `sizes` entry + * has to be one column's worth of the viewport, and past the cap one column's + * fixed width. */ -/** Tailwind's default breakpoints; `globals.css` does not override them. */ -const COLUMNS_AT: ReadonlyArray<{ minWidth: number | null; columns: number }> = - [ +/** + * Each preset's column steps, widest first. Tailwind's default breakpoints; + * `globals.css` does not override them. A `Record` so a new preset with no + * entry here fails the type check. + */ +const COLUMNS_AT: Record< + PosterGridColumns, + ReadonlyArray<{ minWidth: number | null; columns: number }> +> = { + catalog: [ + { minWidth: 1280, columns: 6 }, + { minWidth: 1024, columns: 5 }, { minWidth: 768, columns: 4 }, + { minWidth: 640, columns: 3 }, { minWidth: null, columns: 2 }, - ]; + ], + wide: [ + { minWidth: 768, columns: 4 }, + { minWidth: null, columns: 2 }, + ], +}; /** The grid's `max-w-[1200px]` cap, reached once the viewport passes it plus the 16px gutters. */ -const GRID_CAP = { width: 1200, viewport: 1232, columns: 4, gap: 16 }; +const GRID_CAP = { width: 1200, viewport: 1232, gap: 16 }; type SizesEntry = { minWidth: number | null; @@ -51,6 +72,41 @@ function parseSizes(sizes: string): SizesEntry[] { }); } +/** + * The `sizes` entries the AC-4 rule gives a preset: a fixed width for every + * column count shown at or past the capped viewport, then one floored `vw` + * entry per fluid step below it. + */ +function expectedSizes(steps: (typeof COLUMNS_AT)[PosterGridColumns]) { + const fixed: SizesEntry[] = []; + const fluid: SizesEntry[] = []; + for (const [index, step] of steps.entries()) { + const wider = steps[index - 1]; + // Capped while any part of this step's range is at or past 1232px. + const capped = + wider?.minWidth == null || wider.minWidth > GRID_CAP.viewport; + if (capped) { + fixed.push({ + minWidth: Math.max(step.minWidth ?? 0, GRID_CAP.viewport), + vw: null, + px: Math.ceil( + (GRID_CAP.width - GRID_CAP.gap * (step.columns - 1)) / step.columns, + ), + }); + } + if (step.minWidth === null || step.minWidth < GRID_CAP.viewport) { + fluid.push({ + minWidth: step.minWidth, + vw: Math.floor(100 / step.columns), + px: null, + }); + } + } + return [...fixed, ...fluid]; +} + +const PRESETS = Object.keys(COLUMNS_AT) as PosterGridColumns[]; + describe("PosterGrid", () => { it("is a list, so a screen reader can count the results", () => { render( @@ -76,17 +132,33 @@ describe("PosterGrid", () => { expect(items).toEqual(["First", "Second"]); }); - it("steps from 2 to 4 columns at md and never goes wider", () => { + it("steps the catalog grid from 2 to 6 columns by default", () => { render(); const grid = screen.getByRole("list"); expect(grid).toHaveClass( "grid-cols-2", + "sm:grid-cols-3", "md:grid-cols-4", + "lg:grid-cols-5", + "xl:grid-cols-6", "max-w-[1200px]", "mx-auto", + "gap-x-4", + "gap-y-6", ); + expect(grid.className).not.toMatch(/2xl:grid-cols-/); + }); + + it("keeps the wide preset at today's 2 and 4 columns", () => { + render(); + + const grid = screen.getByRole("list"); + expect(grid).toHaveClass("grid-cols-2", "md:grid-cols-4", "max-w-[1200px]"); expect(grid.className).not.toMatch(/(?:sm|lg|xl|2xl):grid-cols-/); + expect(POSTER_GRID_SIZES.wide).toBe( + "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw", + ); }); it("keeps a caller class without dropping the column steps", () => { @@ -95,26 +167,34 @@ describe("PosterGrid", () => { expect(screen.getByRole("list")).toHaveClass("mt-8", "grid-cols-2"); }); - it("matches POSTER_SIZES breakpoint for breakpoint to the column counts", () => { - const [capped, ...fluid] = parseSizes(POSTER_SIZES); - - // Past the cap a column has a fixed width: the grid's share less the gaps, - // rounded up so the browser never picks an image narrower than the slot. - expect(capped.minWidth).toBe(GRID_CAP.viewport); - expect(capped.px).toBe( - Math.ceil( - (GRID_CAP.width - GRID_CAP.gap * (GRID_CAP.columns - 1)) / - GRID_CAP.columns, - ), - ); + it("defaults a card's sizes to the catalog preset", () => { + expect(POSTER_SIZES).toBe(POSTER_GRID_SIZES.catalog); + }); - expect(fluid.map((entry) => entry.minWidth)).toEqual( - COLUMNS_AT.map((entry) => entry.minWidth), - ); - for (const [index, { columns }] of COLUMNS_AT.entries()) { - // One column is 100/columns vw, floored: the gaps make a column slightly - // narrower than its share, so rounding down is the right direction. - expect(fluid[index].vw).toBe(Math.floor(100 / columns)); + it.each(PRESETS)( + "matches the %s sizes breakpoint for breakpoint to its columns", + (preset) => { + render(); + const className = screen.getByRole("list").className; + + // The columns in the table are the ones the grid really renders. + for (const { minWidth, columns } of COLUMNS_AT[preset]) { + const prefix = + minWidth === null + ? "" + : `${{ 640: "sm", 768: "md", 1024: "lg", 1280: "xl" }[minWidth]}:`; + expect(className.split(" ")).toContain(`${prefix}grid-cols-${columns}`); + } + + expect(parseSizes(POSTER_GRID_SIZES[preset])).toEqual( + expectedSizes(COLUMNS_AT[preset]), + ); + }, + ); + + it("loads one full row eagerly at each preset's widest count", () => { + for (const preset of PRESETS) { + expect(POSTER_GRID_EAGER[preset]).toBe(COLUMNS_AT[preset][0].columns); } }); }); diff --git a/components/poster-grid.tsx b/components/poster-grid.tsx index aff3e88..b939d7e 100644 --- a/components/poster-grid.tsx +++ b/components/poster-grid.tsx @@ -2,32 +2,72 @@ import { cn } from "cn"; import type * as React from "react"; /** - * The poster grid, mobile first (spec 0004, AC-9). + * The grid's column presets (spec 0022, AC-1 and AC-3). `catalog` is every + * title grid; `wide` is the show page's season grid, which keeps the larger + * cards it has always had. + */ +type PosterGridColumns = "catalog" | "wide"; + +/** + * Full class literals, never built from parts, so Tailwind finds and emits + * every one of them. + */ +const COLUMN_CLASSES: Record = { + catalog: + "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6", + wide: "grid-cols-2 md:grid-cols-4", +}; + +/** + * Each preset's image `sizes`, matched breakpoint for breakpoint to its + * columns (spec 0022, AC-4): one `floor(100 / columns)vw` per fluid step, and + * one fixed width per column count the grid shows once it reaches its 1200px + * cap at a 1232px viewport. `poster-grid.test.tsx` recomputes every entry, so + * a column change that misses its sizes fails the suite. + */ +const POSTER_GRID_SIZES: Record = { + catalog: + "(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw", + wide: "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw", +}; + +/** + * Posters that load eagerly: one full row at the preset's widest count, so the + * first row is never lazy at any width (spec 0022, AC-5). + */ +const POSTER_GRID_EAGER: Record = { + catalog: 6, + wide: 4, +}; + +/** + * The poster grid, mobile first (spec 0004, AC-9, amended by spec 0022). * - * Column counts are fixed here and mirrored by `POSTER_SIZES` in - * `poster-card.tsx`; changing one without the other ships wrong sized images. - * Two columns below `md`, four from it: a page holds 20 titles, which fills - * every row at both counts (prompts/grid-tabs-footer-legend.md), where three - * or five columns would leave a ragged last row. Two columns at 390px keeps a - * poster wide enough to read its caption with no horizontal page scroll. + * `catalog` steps 2, 3 (`sm`), 4 (`md`), 5 (`lg`) and 6 (`xl`) columns, so a + * grid card stays close to the home row card's 180px; a 20 title page leaves a + * short last row at 3, 5 and 6 columns, accepted in spec 0022. `wide` keeps 2 + * and 4 for the season grid. The columns, `POSTER_GRID_SIZES` and + * `POSTER_GRID_EAGER` change together; a card in the grid passes the preset's + * sizes. * - * The 1200px cap keeps a four column poster near 285px wide, so a full page - * stays a sensible length on a wide screen; past the cap the grid is centred - * on every page, under the centred pagination. + * The 1200px cap keeps a full page a sensible length on a wide screen; past + * the cap the grid is centred on every page, under the centred pagination. * * A `
      ` rather than a bare div: a collection of titles is a list, and that * is what tells a screen reader how many results there are. */ function PosterGrid({ + columns = "catalog", className, children, ...props -}: React.ComponentProps<"ul">) { +}: React.ComponentProps<"ul"> & { columns?: PosterGridColumns }) { return (
        ; /** TMDB's page size, which the skeleton mirrors (AC-21). */ const PAGE_SIZE = 20; -/** One full row at the widest grid loads its posters eagerly. */ -const EAGER_POSTERS = 4; - /** * The results half of `/search`, for one URL (spec 0010, AC-11 to AC-19). * @@ -225,7 +222,7 @@ function ResultGrid({ result={result} gridIds={gridIds} returnPath={returnPath} - priority={index < EAGER_POSTERS} + priority={index < POSTER_GRID_EAGER.catalog} /> ))} diff --git a/components/show/episode-list.tsx b/components/show/episode-list.tsx index acde84e..83f9136 100644 --- a/components/show/episode-list.tsx +++ b/components/show/episode-list.tsx @@ -7,7 +7,7 @@ import type { Episode } from "@/lib/tmdb"; /** * Stills that load eagerly: the first two rows, which is what fits above the * fold on a desktop season page. The counterpart of the landing's - * `EAGER_POSTERS` (spec 0009, AC-10); every later still loads lazily, so a + * `POSTER_GRID_EAGER` (spec 0009, AC-10); every later still loads lazily, so a * season of hundreds of episodes stays usable. */ const EAGER_STILLS = 2; diff --git a/components/show/season-grid.tsx b/components/show/season-grid.tsx index d62e489..4246665 100644 --- a/components/show/season-grid.tsx +++ b/components/show/season-grid.tsx @@ -1,7 +1,7 @@ import { Suspense } from "react"; import { PosterCard } from "@/components/poster-card"; -import { PosterGrid } from "@/components/poster-grid"; +import { POSTER_GRID_SIZES, PosterGrid } from "@/components/poster-grid"; import { SeasonRatingBadgeSlot } from "@/components/tracking/season-rating-badge-slot"; import { seasonMetaParts } from "@/lib/format"; import type { SeasonSummary } from "@/lib/tmdb"; @@ -18,6 +18,9 @@ import type { SeasonSummary } from "@/lib/tmdb"; * Each card's badge is the signed in user's calculated season rating * (spec 0012, AC-8), streamed in its own boundary with no fallback so the * grid itself stays part of the public page. + * + * The grid keeps the `wide` preset, 2 and 4 columns (spec 0022, AC-3): a show + * has few seasons, and their cards stay as large as they have always been. */ function SeasonGrid({ showId, @@ -39,13 +42,14 @@ function SeasonGrid({ } return ( - + {seasons.map((season) => (
      • -
      • Date: Mon, 12 Oct 2026 19:08:47 +0200 Subject: [PATCH 03/26] feat(tmdb): read title extras and the new movie and show detail fields Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/batch.test.ts | 10 ++ lib/tmdb/extras.ts | 64 ++++++++++ lib/tmdb/images.ts | 4 + lib/tmdb/index.ts | 8 ++ lib/tmdb/normalize-extras.ts | 234 +++++++++++++++++++++++++++++++++++ lib/tmdb/normalize.ts | 93 ++++++++++++++ lib/tmdb/reads.ts | 56 +++++++++ lib/tmdb/schemas.ts | 127 +++++++++++++++++++ lib/tmdb/types.ts | 86 +++++++++++++ 9 files changed, 682 insertions(+) create mode 100644 lib/tmdb/extras.ts create mode 100644 lib/tmdb/normalize-extras.ts diff --git a/lib/tmdb/batch.test.ts b/lib/tmdb/batch.test.ts index e1f93e0..a7c1af4 100644 --- a/lib/tmdb/batch.test.ts +++ b/lib/tmdb/batch.test.ts @@ -35,6 +35,12 @@ function movie(id: number): Movie { genres: [], genreIds: [], cast: [], + status: null, + budget: null, + revenue: null, + productionCompanies: [], + directors: [], + writers: [], }; } @@ -71,6 +77,10 @@ function show(id: number, seasonNumbers: number[]): TvShow { })), lastEpisodeToAir: null, nextEpisodeToAir: null, + type: null, + createdBy: [], + networks: [], + productionCompanies: [], }; } diff --git a/lib/tmdb/extras.ts b/lib/tmdb/extras.ts new file mode 100644 index 0000000..fe26358 --- /dev/null +++ b/lib/tmdb/extras.ts @@ -0,0 +1,64 @@ +import "server-only"; +import { tmdbRequest } from "./client"; +import { normalizeMovieExtras, normalizeTvExtras } from "./normalize-extras"; +import { movieExtrasSchema, tvExtrasSchema } from "./schemas"; +import type { MovieExtras, TvExtras } from "./types"; +import { assertId } from "./validation"; + +/** + * The title page extras: trailer, recommendations, watch providers and age + * ratings, in one request per title (spec 0022, AC-8). + * + * A separate read from `fetchMovie` and `fetchTvShow` on purpose: the + * library pages read those for up to 500 titles, and none of them needs any + * of this, so they keep their size and their lifetime. The details TMDB sends + * back alongside the appended keys are ignored; the page reads them from the + * detail reads (AC-16). + * + * `include_video_language=en,null` keeps videos with no language, which + * `buildUrl`'s `language=en-US` would otherwise filter out. No region is + * sent: every country comes back, and the page picks its visitor's after the + * cache returns (AC-9). + */ + +/** + * Reads a movie's extras, uncached. The inner half of `getMovieExtras`. + * + * @param id TMDB movie id. + * @throws {TmdbError} `not_found` when TMDB has no such movie. + */ +export async function fetchMovieExtras(id: number): Promise { + const endpoint = `/movie/${id}`; + assertId(id, endpoint); + const raw = await tmdbRequest( + endpoint, + { + append_to_response: + "videos,recommendations,watch/providers,release_dates", + include_video_language: "en,null", + }, + movieExtrasSchema, + ); + return normalizeMovieExtras(raw); +} + +/** + * Reads a show's extras, uncached. The inner half of `getTvExtras`. + * + * @param id TMDB show id. + * @throws {TmdbError} `not_found` when TMDB has no such show. + */ +export async function fetchTvExtras(id: number): Promise { + const endpoint = `/tv/${id}`; + assertId(id, endpoint); + const raw = await tmdbRequest( + endpoint, + { + append_to_response: + "videos,recommendations,watch/providers,content_ratings", + include_video_language: "en,null", + }, + tvExtrasSchema, + ); + return normalizeTvExtras(raw); +} diff --git a/lib/tmdb/images.ts b/lib/tmdb/images.ts index 01edfee..b77e945 100644 --- a/lib/tmdb/images.ts +++ b/lib/tmdb/images.ts @@ -29,6 +29,10 @@ export const POSTER_SIZE: TmdbImageSize = "w500"; export const BACKDROP_SIZE: TmdbImageSize = "w1280"; export const PROFILE_SIZE: TmdbImageSize = "w185"; export const STILL_SIZE: TmdbImageSize = "w300"; +/** A company or network logo in the Facts panel (spec 0022, AC-16). */ +export const COMPANY_LOGO_SIZE: TmdbImageSize = "w154"; +/** A watch provider logo (spec 0022, AC-12). */ +export const PROVIDER_LOGO_SIZE: TmdbImageSize = "w92"; /** * Builds an absolute TMDB image URL, or null when there is no image. diff --git a/lib/tmdb/index.ts b/lib/tmdb/index.ts index e6b279d..9cd0b7b 100644 --- a/lib/tmdb/index.ts +++ b/lib/tmdb/index.ts @@ -23,6 +23,7 @@ export { discoverMovies, discoverTvShows, getMovie, + getMovieExtras, getMovieGenres, getMovieList, getMovieSummaries, @@ -31,6 +32,7 @@ export { getSeason, getShowCast, getShowEpisodes, + getTvExtras, getTvGenres, getTvList, getTvShow, @@ -42,21 +44,27 @@ export { export type { BatchResult, CastMember, + Company, DiscoverOptions, Episode, EpisodeToAir, Genre, ListOptions, Movie, + MovieExtras, MovieListKind, MovieSummary, Paged, + Provider, + RegionProviders, SearchOptions, SeasonDetail, SeasonSummary, ShowCastMember, ShowEpisodes, TmdbShowStatus, + Trailer, + TvExtras, TvListKind, TvShow, TvShowSummary, diff --git a/lib/tmdb/normalize-extras.ts b/lib/tmdb/normalize-extras.ts new file mode 100644 index 0000000..c7b9a7b --- /dev/null +++ b/lib/tmdb/normalize-extras.ts @@ -0,0 +1,234 @@ +import { imageUrl, PROVIDER_LOGO_SIZE } from "./images"; +import { normalizeMovieSummary, normalizeTvSummary } from "./normalize"; +import { + contentRatingSchema, + movieListItemSchema, + type RawMovieExtras, + type RawTvExtras, + regionProvidersSchema, + releaseDatesCountrySchema, + tvListItemSchema, + videoSchema, + watchProviderSchema, +} from "./schemas"; +import type { + MovieExtras, + MovieSummary, + Paged, + Provider, + RegionProviders, + Trailer, + TvExtras, + TvShowSummary, +} from "./types"; + +/** + * The appended extras of a title page turned into the app's shapes + * (spec 0022, AC-11 to AC-14). + * + * Every rule that picks one value out of many (the trailer, a provider's + * group, a country's certification) runs here, once per cached read, and for + * every country at once. The page only reads its visitor's entry after the + * cache returns, so nothing request specific enters the cached value + * (AC-9). Missing stays missing: an empty group, an empty certification or an + * unusable video is left out, never filled. + */ + +/** A YouTube key the embed URL can carry safely (AC-11). */ +const YOUTUBE_KEY = /^[A-Za-z0-9_-]{6,20}$/; + +/** + * The one trailer a title page offers (AC-11): a YouTube `Trailer`, else a + * YouTube `Teaser`; within the type, an official video first, then the newest + * `published_at`. A video with an unusable key is dropped before choosing, so + * a bad key never hides a good one; with none left there is no trailer. + */ +export function pickTrailer( + items: unknown[] | null | undefined, +): Trailer | null { + const videos = []; + for (const item of items ?? []) { + const parsed = videoSchema.safeParse(item); + if (!parsed.success) continue; + const video = parsed.data; + if (video.site !== "YouTube" || !YOUTUBE_KEY.test(video.key)) continue; + videos.push(video); + } + + for (const type of ["Trailer", "Teaser"]) { + const candidates = videos + .filter((video) => video.type === type) + .sort( + (a, b) => + Number(b.official === true) - Number(a.official === true) || + publishedAt(b.published_at) - publishedAt(a.published_at), + ); + const chosen = candidates[0]; + if (chosen !== undefined) { + return { key: chosen.key, name: chosen.name?.trim() || type }; + } + } + return null; +} + +/** `Date.parse` of `published_at`; a missing or unreadable date sorts oldest. */ +function publishedAt(value: string | null | undefined): number { + const time = value ? Date.parse(value) : Number.NaN; + return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time; +} + +/** + * One group's providers, merged in the order given by provider id (the first + * entry wins), then ordered by `display_priority` and name (AC-12). A + * provider with no priority sorts after those with one. + */ +function providerGroup(...lists: (unknown[] | null | undefined)[]): Provider[] { + const byId = new Map(); + for (const list of lists) { + for (const item of list ?? []) { + const parsed = watchProviderSchema.safeParse(item); + if (!parsed.success) continue; + const provider = parsed.data; + const name = provider.provider_name.trim(); + if (name === "" || byId.has(provider.provider_id)) continue; + byId.set(provider.provider_id, { + id: provider.provider_id, + name, + logoUrl: imageUrl(provider.logo_path, PROVIDER_LOGO_SIZE), + priority: provider.display_priority ?? Number.POSITIVE_INFINITY, + }); + } + } + return [...byId.values()] + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) + .map(({ priority: _, ...provider }) => provider); +} + +/** + * Every country's providers, grouped into Stream, Free and Rent or buy + * (AC-12). A country whose entry does not parse is dropped; a country with + * no provider in any group is kept with empty groups, so the page can still + * link to TMDB's watch page there. + */ +export function normalizeWatchProviders( + results: Record | null | undefined, +): Record { + const regions: Record = {}; + for (const [code, entry] of Object.entries(results ?? {})) { + const parsed = regionProvidersSchema.safeParse(entry); + if (!parsed.success) continue; + const region = parsed.data; + regions[code] = { + link: region.link?.trim() || null, + stream: providerGroup(region.flatrate), + free: providerGroup(region.free, region.ads), + rentOrBuy: providerGroup(region.rent, region.buy), + }; + } + return regions; +} + +/** TMDB's release type for a theatrical release (AC-13). */ +const THEATRICAL = 3; + +/** + * A movie's certification per country (AC-13): the non empty certification + * of the theatrical release, else the first non empty one in TMDB's array + * order. A country with none is left out, so the page never borrows another + * country's rating. + */ +export function movieAgeRatings( + items: unknown[] | null | undefined, +): Record { + const ratings: Record = {}; + for (const item of items ?? []) { + const parsed = releaseDatesCountrySchema.safeParse(item); + if (!parsed.success) continue; + const dates = parsed.data.release_dates.map((date) => ({ + type: date.type, + certification: date.certification?.trim() ?? "", + })); + const chosen = + dates.find((date) => date.type === THEATRICAL && date.certification) ?? + dates.find((date) => date.certification); + if (chosen) ratings[parsed.data.iso_3166_1] = chosen.certification; + } + return ratings; +} + +/** A show's `content_ratings` per country, empty ratings left out (AC-13). */ +export function showAgeRatings( + items: unknown[] | null | undefined, +): Record { + const ratings: Record = {}; + for (const item of items ?? []) { + const parsed = contentRatingSchema.safeParse(item); + if (!parsed.success) continue; + const rating = parsed.data.rating?.trim(); + if (rating) ratings[parsed.data.iso_3166_1] = rating; + } + return ratings; +} + +type RawRecommendations = RawMovieExtras["recommendations"]; + +/** + * Page 1 of recommendations through the list item schemas, so an adult + * title is dropped exactly as on the spec 0021 lists (AC-14), along with any + * malformed result. The totals pass through; a missing envelope is an empty + * page. + */ +function recommendationsPage( + raw: RawRecommendations, + parse: (item: unknown) => Summary | null, +): Paged { + const results: Summary[] = []; + for (const item of raw?.results ?? []) { + const summary = parse(item); + if (summary !== null) results.push(summary); + } + return { + page: raw?.page ?? 1, + results, + totalPages: raw?.total_pages ?? 0, + totalResults: raw?.total_results ?? 0, + }; +} + +function movieRecommendation(item: unknown): MovieSummary | null { + const parsed = movieListItemSchema.safeParse(item); + if (!parsed.success || parsed.data.adult === true) return null; + return normalizeMovieSummary(parsed.data); +} + +function tvRecommendation(item: unknown): TvShowSummary | null { + const parsed = tvListItemSchema.safeParse(item); + if (!parsed.success || parsed.data.adult === true) return null; + return normalizeTvSummary(parsed.data); +} + +/** A movie's extras, every country at once (AC-8). */ +export function normalizeMovieExtras(raw: RawMovieExtras): MovieExtras { + return { + trailer: pickTrailer(raw.videos?.results), + recommendations: { + mediaType: "movie", + paged: recommendationsPage(raw.recommendations, movieRecommendation), + }, + watchProviders: normalizeWatchProviders(raw["watch/providers"]?.results), + ageRatings: movieAgeRatings(raw.release_dates?.results), + }; +} + +/** A show's extras, every country at once (AC-8). */ +export function normalizeTvExtras(raw: RawTvExtras): TvExtras { + return { + trailer: pickTrailer(raw.videos?.results), + recommendations: { + mediaType: "tv", + paged: recommendationsPage(raw.recommendations, tvRecommendation), + }, + watchProviders: normalizeWatchProviders(raw["watch/providers"]?.results), + ageRatings: showAgeRatings(raw.content_ratings?.results), + }; +} diff --git a/lib/tmdb/normalize.ts b/lib/tmdb/normalize.ts index 7b6aa13..d27e0ff 100644 --- a/lib/tmdb/normalize.ts +++ b/lib/tmdb/normalize.ts @@ -3,6 +3,7 @@ import { parseTmdbDate } from "@/lib/format"; import { SHOW_CAST_LIMIT } from "./constants"; import { BACKDROP_SIZE, + COMPANY_LOGO_SIZE, imageUrl, POSTER_SIZE, PROFILE_SIZE, @@ -11,6 +12,9 @@ import { import { aggregateCastMemberSchema, castMemberSchema, + companySchema, + creatorSchema, + crewMemberSchema, episodeToAirSchema, type RawEpisode, type RawMovie, @@ -23,6 +27,7 @@ import { } from "./schemas"; import type { CastMember, + Company, Episode, EpisodeToAir, Genre, @@ -111,6 +116,85 @@ export function normalizeCast( return cast.sort((a, b) => a.order - b.order); } +/** A money amount TMDB reports as 0 when it does not know it, so 0 is null. */ +function moneyOrNull(value: number | null | undefined): number | null { + return typeof value === "number" && value > 0 ? value : null; +} + +/** + * Production companies or networks, dropping any entry that does not parse + * (spec 0022, AC-16), in TMDB's order. + */ +export function normalizeCompanies( + items: unknown[] | null | undefined, +): Company[] { + const companies: Company[] = []; + for (const item of items ?? []) { + const parsed = companySchema.safeParse(item); + if (!parsed.success) continue; + const name = textOrNull(parsed.data.name); + if (name === null) continue; + companies.push({ + id: parsed.data.id, + name, + logoUrl: imageUrl(parsed.data.logo_path, COMPANY_LOGO_SIZE), + }); + } + return companies; +} + +/** Most writers a movie page names (spec 0022, AC-15). */ +const WRITER_LIMIT = 3; + +/** Most show creators a show page names (spec 0022, AC-15). */ +const CREATOR_LIMIT = 3; + +/** The crew jobs that count as writing. `Story` is left out on purpose. */ +const WRITER_JOBS: ReadonlySet = new Set(["Screenplay", "Writer"]); + +/** + * A movie's directors and writers from the crew `credits` already carries, + * so the crew lines cost no request (spec 0022, AC-15). Names are + * deduplicated in credit order, because one person often holds both + * `Screenplay` and `Writer`; writers are cut to 3 after that. A malformed + * credit is dropped, like a cast credit. + */ +export function normalizeCrew(items: unknown[] | null | undefined): { + directors: string[]; + writers: string[]; +} { + const directors = new Set(); + const writers = new Set(); + for (const item of items ?? []) { + const parsed = crewMemberSchema.safeParse(item); + if (!parsed.success) continue; + const name = textOrNull(parsed.data.name); + if (name === null) continue; + if (parsed.data.job === "Director") directors.add(name); + if (parsed.data.job != null && WRITER_JOBS.has(parsed.data.job)) { + writers.add(name); + } + } + return { + directors: [...directors], + writers: [...writers].slice(0, WRITER_LIMIT), + }; +} + +/** A show's `created_by` names, deduplicated and capped at 3 (AC-15). */ +export function normalizeCreators( + items: unknown[] | null | undefined, +): string[] { + const names = new Set(); + for (const item of items ?? []) { + const parsed = creatorSchema.safeParse(item); + if (!parsed.success) continue; + const name = textOrNull(parsed.data.name); + if (name !== null) names.add(name); + } + return [...names].slice(0, CREATOR_LIMIT); +} + export function normalizeMovieSummary(raw: RawMovieSummary): MovieSummary { const releaseDate = textOrNull(raw.release_date); return { @@ -174,6 +258,11 @@ export function normalizeMovie(raw: RawMovie): Movie { // the list the page shows rather than staying empty. genreIds: normalizeGenres(raw.genres).map((genre) => genre.id), cast: normalizeCast(raw.credits?.cast), + ...normalizeCrew(raw.credits?.crew), + status: textOrNull(raw.status), + budget: moneyOrNull(raw.budget), + revenue: moneyOrNull(raw.revenue), + productionCompanies: normalizeCompanies(raw.production_companies), }; } @@ -254,6 +343,10 @@ export function normalizeTvShow(raw: RawTvShow): TvShow { seasons: (raw.seasons ?? []).map(normalizeSeasonSummary), lastEpisodeToAir: normalizeEpisodeToAir(raw.last_episode_to_air), nextEpisodeToAir: normalizeEpisodeToAir(raw.next_episode_to_air), + type: textOrNull(raw.type), + createdBy: normalizeCreators(raw.created_by), + networks: normalizeCompanies(raw.networks), + productionCompanies: normalizeCompanies(raw.production_companies), }; } diff --git a/lib/tmdb/reads.ts b/lib/tmdb/reads.ts index 211df2a..103d875 100644 --- a/lib/tmdb/reads.ts +++ b/lib/tmdb/reads.ts @@ -6,6 +6,7 @@ import { readEachSettled, type SettledBatch, } from "./batch"; +import { fetchMovieExtras, fetchTvExtras } from "./extras"; import { fetchMovieGenres, fetchTvGenres } from "./genres"; import { fetchMovieList, fetchTvList } from "./lists"; import { fetchMovie } from "./movies"; @@ -30,6 +31,7 @@ import type { Genre, ListOptions, Movie, + MovieExtras, MovieListKind, MovieSummary, Paged, @@ -37,6 +39,7 @@ import type { SeasonDetail, ShowCastMember, ShowEpisodes, + TvExtras, TvListKind, TvShow, TvShowSummary, @@ -127,6 +130,59 @@ export async function getTvShow(id: number): Promise { return unwrap(await getTvShowCached(id)); } +/** + * A title page's extras (spec 0022, AC-8): `hours`, because watch providers + * and recommendations move within a day, and one entry per title shared by + * every visitor, since it holds every country and no request state. Only the + * title pages read it; the library and catalog grid reads never do. + */ +async function getMovieExtrasCached( + id: number, +): Promise> { + "use cache"; + try { + const value = await fetchMovieExtras(id); + cacheLife("hours"); + return { ok: true, value }; + } catch (error) { + const failure = toFailure(error); + cacheFailure(failure); + return { ok: false, failure }; + } +} + +/** + * A movie's trailer, recommendations, watch providers and age ratings. + * + * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure. + */ +export async function getMovieExtras(id: number): Promise { + return unwrap(await getMovieExtrasCached(id)); +} + +/** The show twin of `getMovieExtrasCached`, with the same lifetime. */ +async function getTvExtrasCached(id: number): Promise> { + "use cache"; + try { + const value = await fetchTvExtras(id); + cacheLife("hours"); + return { ok: true, value }; + } catch (error) { + const failure = toFailure(error); + cacheFailure(failure); + return { ok: false, failure }; + } +} + +/** + * A show's trailer, recommendations, watch providers and age ratings. + * + * @throws {TmdbError} Rebuilt outside the cached scope from the stored failure. + */ +export async function getTvExtras(id: number): Promise { + return unwrap(await getTvExtrasCached(id)); +} + /** A series cast changes rarely, so it keeps the detail profile (AC-20). */ async function getShowCastCached( id: number, diff --git a/lib/tmdb/schemas.ts b/lib/tmdb/schemas.ts index 783538c..85cbab2 100644 --- a/lib/tmdb/schemas.ts +++ b/lib/tmdb/schemas.ts @@ -39,10 +39,37 @@ export const castMemberSchema = z.object({ order: z.number().nullish(), }); +/** + * One crew credit, read for a movie's directors and writers (spec 0022, + * AC-15). Parsed per item like a cast credit. + */ +export const crewMemberSchema = z.object({ + name: z.string(), + job: z.string().nullish(), +}); + +/** + * A production company or a network (spec 0022, AC-16). Parsed per item, so + * one malformed entry drops that entry rather than the title. + */ +export const companySchema = z.object({ + id: z.number(), + name: z.string(), + logo_path: nullableString, +}); + +/** + * A show creator from `created_by` (spec 0022, AC-15). Parsed per item. + */ +export const creatorSchema = z.object({ + name: z.string(), +}); + /** Credits arrive as unknown items so one bad credit can be dropped by hand. */ const creditsSchema = z .object({ cast: z.array(z.unknown()).nullish(), + crew: z.array(z.unknown()).nullish(), }) .nullish(); @@ -87,6 +114,10 @@ export const movieSchema = movieSummarySchema.extend({ genres: z.array(genreSchema).nullish(), credits: creditsSchema, translations: translationsSchema, + status: nullableString, + budget: nullableNumber, + revenue: nullableNumber, + production_companies: z.array(z.unknown()).nullish(), }); export const tvSummarySchema = z.object({ @@ -136,6 +167,10 @@ export const tvShowSchema = tvSummarySchema.extend({ genres: z.array(genreSchema).nullish(), translations: translationsSchema, seasons: z.array(seasonSummarySchema).nullish(), + type: nullableString, + created_by: z.array(z.unknown()).nullish(), + networks: z.array(z.unknown()).nullish(), + production_companies: z.array(z.unknown()).nullish(), /** * Left unparsed here and checked one by one in the normalizer, so a * malformed value is read as null rather than failing the whole show @@ -207,6 +242,96 @@ export const seasonDetailSchema = z.object({ episodes: z.array(episodeSchema), }); +/** + * One video from the appended `videos` (spec 0022, AC-11). Parsed per item, + * so one malformed video is dropped rather than the extras. + */ +export const videoSchema = z.object({ + site: z.string(), + type: z.string(), + key: z.string(), + name: nullableString, + official: z.boolean().nullish(), + published_at: nullableString, +}); + +/** One watch provider in a region's group (spec 0022, AC-12). */ +export const watchProviderSchema = z.object({ + provider_id: z.number(), + provider_name: z.string(), + logo_path: nullableString, + display_priority: nullableNumber, +}); + +/** + * One region's entry in `watch/providers.results`. The groups stay unknown + * items so one malformed provider is dropped by hand. + */ +export const regionProvidersSchema = z.object({ + link: nullableString, + flatrate: z.array(z.unknown()).nullish(), + free: z.array(z.unknown()).nullish(), + ads: z.array(z.unknown()).nullish(), + rent: z.array(z.unknown()).nullish(), + buy: z.array(z.unknown()).nullish(), +}); + +/** One country of a movie's `release_dates` (spec 0022, AC-13). */ +export const releaseDatesCountrySchema = z.object({ + iso_3166_1: z.string(), + release_dates: z.array( + z.object({ + certification: nullableString, + type: nullableNumber, + }), + ), +}); + +/** One country of a show's `content_ratings` (spec 0022, AC-13). */ +export const contentRatingSchema = z.object({ + iso_3166_1: z.string(), + rating: nullableString, +}); + +/** + * The appended keys both extras reads share. Every list is unknown items, + * checked one by one in the normalizer, so a malformed video, provider, + * country or recommendation drops that entry and never the whole read; a + * missing key reads as empty. + */ +const extrasSchema = z.object({ + videos: z.object({ results: z.array(z.unknown()).nullish() }).nullish(), + recommendations: z + .object({ + page: nullableNumber, + results: z.array(z.unknown()).nullish(), + total_pages: nullableNumber, + total_results: nullableNumber, + }) + .nullish(), + "watch/providers": z + .object({ results: z.record(z.string(), z.unknown()).nullish() }) + .nullish(), +}); + +/** + * `/movie/{id}` with the extras appended (spec 0022, AC-8). Only the + * appended keys are read: the details in the same response are ignored, and + * the page takes them from `getMovie` (AC-16). + */ +export const movieExtrasSchema = extrasSchema.extend({ + release_dates: z + .object({ results: z.array(z.unknown()).nullish() }) + .nullish(), +}); + +/** The show twin of `movieExtrasSchema`, with `content_ratings`. */ +export const tvExtrasSchema = extrasSchema.extend({ + content_ratings: z + .object({ results: z.array(z.unknown()).nullish() }) + .nullish(), +}); + /** * TMDB's pagination envelope. `page`, `total_pages` and `total_results` are * returned to callers unchanged and never recomputed (spec 0002, AC-18). @@ -228,3 +353,5 @@ export type RawTvSummary = z.infer; export type RawSeasonSummary = z.infer; export type RawSeasonDetail = z.infer; export type RawEpisode = z.infer; +export type RawMovieExtras = z.infer; +export type RawTvExtras = z.infer; diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts index f9fee79..ad642ab 100644 --- a/lib/tmdb/types.ts +++ b/lib/tmdb/types.ts @@ -89,6 +89,20 @@ export type Movie = Omit & { runtimeMinutes: number | null; genres: Genre[]; cast: CastMember[]; + /** TMDB's English status word as is, for example `Released` (spec 0022, AC-16). */ + status: string | null; + /** US dollars; null when TMDB reports 0 or nothing. */ + budget: number | null; + /** US dollars; null when TMDB reports 0 or nothing. */ + revenue: number | null; + productionCompanies: Company[]; + /** Crew job `Director`, in credit order, deduplicated (spec 0022, AC-15). */ + directors: string[]; + /** + * Crew jobs `Screenplay` and `Writer` (not `Story`), deduplicated in credit + * order, then capped at 3 (spec 0022, AC-15). + */ + writers: string[]; }; export type TvShowSummary = { @@ -164,8 +178,80 @@ export type TvShow = Omit & { lastEpisodeToAir: EpisodeToAir | null; /** The next episode TMDB has announced, dated or not. */ nextEpisodeToAir: EpisodeToAir | null; + /** TMDB's English type word as is, for example `Miniseries` (spec 0022, AC-16). */ + type: string | null; + /** `created_by` names, at most 3 (spec 0022, AC-15). */ + createdBy: string[]; + networks: Company[]; + productionCompanies: Company[]; +}; + +/** A production company or a network (spec 0022, AC-16). */ +export type Company = { + id: number; + name: string; + /** `w154`, or null when TMDB has no logo, so the name shows instead. */ + logoUrl: string | null; +}; + +/** The one trailer a title page offers (spec 0022, AC-11). */ +export type Trailer = { + /** A YouTube video key, already checked against `[A-Za-z0-9_-]{6,20}`. */ + key: string; + name: string; +}; + +/** One watch provider (spec 0022, AC-12). */ +export type Provider = { + id: number; + name: string; + /** `w92`, or null when TMDB has no logo, so the name shows instead. */ + logoUrl: string | null; }; +/** + * One country's watch providers, already grouped, deduplicated and ordered + * (spec 0022, AC-12). A provider may sit in more than one group. + */ +export type RegionProviders = { + /** TMDB's watch page for the title in this country. */ + link: string | null; + /** `flatrate`. */ + stream: Provider[]; + /** `free` then `ads`, merged by provider id. */ + free: Provider[]; + /** `rent` then `buy`, merged by provider id. */ + rentOrBuy: Provider[]; +}; + +/** + * The extras of one title, every country at once (spec 0022, AC-8, AC-9). + * Region maps are keyed by ISO 3166-1 code; the page reads its visitor's + * entry after the cached read returns, so no request state enters the cache. + */ +type TitleExtras = { + trailer: Trailer | null; + recommendations: Recommendations; + watchProviders: Record; + /** The certification already picked per country by the AC-13 rule. */ + ageRatings: Record; +}; + +/** + * Page 1 of a movie's recommendations, in the tagged shape `CatalogListCards` + * renders (spec 0022, AC-14), adult titles already dropped. + */ +export type MovieExtras = TitleExtras<{ + mediaType: "movie"; + paged: Paged; +}>; + +/** The show twin of `MovieExtras`. */ +export type TvExtras = TitleExtras<{ + mediaType: "tv"; + paged: Paged; +}>; + export type Episode = { id: number; /** Injected from the request argument; TMDB's season payload omits it. */ From f644e54c8a18748bc0ffb7c7fd9b9a02d7ad05ad Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 12 Oct 2026 19:08:47 +0200 Subject: [PATCH 04/26] feat(catalog): pick the visitor's region from the Vercel country header Co-Authored-By: Claude Opus 5.5 --- lib/catalog/region.ts | 46 +++++++++++++++++++++++++++++++++++++++++++ lib/region.ts | 21 ++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 lib/catalog/region.ts create mode 100644 lib/region.ts diff --git a/lib/catalog/region.ts b/lib/catalog/region.ts new file mode 100644 index 0000000..011469b --- /dev/null +++ b/lib/catalog/region.ts @@ -0,0 +1,46 @@ +/** A country the title page names and picks its region data for (spec 0022, AC-9). */ +export type Region = { + /** ISO 3166-1 alpha-2, upper case. */ + code: string; + /** The English country name, from `Intl.DisplayNames`. */ + name: string; +}; + +/** + * The fallback when the request names no usable country: local development, + * a preview with no header, or a code nobody can name. + */ +export const DEFAULT_REGION: Region = { code: "US", name: "United States" }; + +/** + * Vercel's codes for an unknown country and for Tor traffic, plus Unicode's + * unknown region, which `Intl.DisplayNames` names "Unknown Region". + */ +const UNKNOWN_CODES: ReadonlySet = new Set(["XX", "T1", "ZZ"]); + +const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); + +/** + * The visitor's region from Vercel's `x-vercel-ip-country` header value + * (spec 0022, AC-9): two capital letters that `Intl.DisplayNames` can name, + * and not `XX` or `T1`, otherwise `US`. + * + * Pure and free of `server-only`, like the rest of `lib/catalog/`, so it is + * tested alone. The header read itself lives in `lib/region.ts`. + * + * @param header The raw header value, or null when there is none. + */ +export function parseRegion(header: string | null | undefined): Region { + if (!header || !/^[A-Z]{2}$/.test(header) || UNKNOWN_CODES.has(header)) { + return DEFAULT_REGION; + } + let name: string | undefined; + try { + name = regionNames.of(header); + } catch { + return DEFAULT_REGION; + } + // An unnameable code comes back unchanged rather than as a name. + if (!name || name === header) return DEFAULT_REGION; + return { code: header, name }; +} diff --git a/lib/region.ts b/lib/region.ts new file mode 100644 index 0000000..18caebe --- /dev/null +++ b/lib/region.ts @@ -0,0 +1,21 @@ +import "server-only"; +import { headers } from "next/headers"; +import { cache } from "react"; + +import { parseRegion, type Region } from "@/lib/catalog/region"; + +/** + * The visitor's country for Where to watch and the age rating (spec 0022, + * AC-9), from the approximate country Vercel derives from the connection. + * + * Read once per request (React `cache()`), always outside a `use cache` + * scope and inside the Suspense boundary of the section that needs it, so + * the title pages keep their prerendered shells (AC-10). It lives outside + * `lib/tmdb`, which may not read request state, and the value is never + * stored, logged or sent to TMDB: the cached extras hold every country and + * the page picks this one after they return. + */ +export const getVisitorRegion = cache(async (): Promise => { + const requestHeaders = await headers(); + return parseRegion(requestHeaders.get("x-vercel-ip-country")); +}); From f5a3779db2d275dc2d7a46678af6d70ded9b72b1 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 12 Oct 2026 19:08:47 +0200 Subject: [PATCH 05/26] feat(legal): add YouTube, the region sentence and the JustWatch credit Co-Authored-By: Claude Opus 5.5 --- app/privacy/page.tsx | 10 ++++++++++ lib/format.ts | 16 ++++++++++++++++ lib/legal/operator.ts | 17 ++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 092e720..445c21a 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -76,6 +76,11 @@ export default function PrivacyPage() { Nothing else. We collect no payment data, no contacts, and no location beyond what an IP address implies.

        +

        + On a title page, the approximate country Vercel derives from your + connection picks the region for where to watch and the age rating. It + is not stored. +

        @@ -110,6 +115,11 @@ export default function PrivacyPage() { so there is no consent banner. Fonts are served from BeStats itself, not loaded from another site.

        +

        + A trailer plays in YouTube’s privacy enhanced player, which + loads only after you press Trailer. From then on YouTube’s own + privacy policy applies to that player. +

        diff --git a/lib/format.ts b/lib/format.ts index face8c2..4ae6838 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -49,6 +49,22 @@ export function languageName(code: string): string { } } +const USD_COMPACT_FORMAT = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + maximumFractionDigits: 1, +}); + +/** + * A budget or revenue in US dollars, compact with at most one decimal, for + * example `$165M` or `$1.5B` (spec 0022, AC-16). TMDB reports both in US + * dollars; a 0 never reaches here, because the normalizer turns it to null. + */ +export function formatUsdCompact(amount: number): string { + return USD_COMPACT_FORMAT.format(amount); +} + /** Whitespace and punctuation left dangling where a sentence was cut. */ const TRAILING_CUT = /[\s.,;:!?\-–—]+$/; diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts index 66de7d7..42a45a2 100644 --- a/lib/legal/operator.ts +++ b/lib/legal/operator.ts @@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16; * whenever the text changes materially. Pages format it in UTC and never read * the clock (spec 0017, AC-7). */ -export const LEGAL_LAST_UPDATED = "2026-10-11"; +export const LEGAL_LAST_UPDATED = "2026-10-12"; /** * Where the production Supabase project stores your data, read from @@ -56,6 +56,13 @@ export const SUPABASE_REGION = { name: "Frankfurt, Germany", } as const; +/** + * The credit TMDB's watch provider data asks for, shown under every Where to + * watch section (spec 0022, AC-12). One home, like `TMDB_ATTRIBUTION`, so the + * wording cannot drift between the movie and show pages. + */ +export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch"; + /** Days within which an account deletion request is completed. */ export const DELETION_DAYS = 30; @@ -72,6 +79,8 @@ export type Processor = { name: string; purpose: string; url: string }; * Only the processors live today. Production sends no email (spec 0018), so * neither Google nor an email provider is listed; both join with the deferred * email delivery and Google sign in work, in the PR that turns them on. + * YouTube joined with the title page trailers (spec 0022, AC-19): its player + * loads only after you press Trailer. */ export const PROCESSORS: readonly Processor[] = [ { @@ -85,4 +94,10 @@ export const PROCESSORS: readonly Processor[] = [ "Hosts the website, delivers catalog images, and keeps request logs.", url: "https://vercel.com", }, + { + name: "YouTube", + purpose: + "Plays a title's trailer, only after you press Trailer on its page.", + url: "https://www.youtube.com", + }, ]; From 1249468cf0c7fc098e3ffe1bedaac1240f17efd2 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Mon, 12 Oct 2026 19:08:47 +0200 Subject: [PATCH 06/26] feat(movies): add trailer, age rating, crew, where to watch, more like this and facts Co-Authored-By: Claude Opus 5.5 --- app/movies/[id]/page.tsx | 34 +++- components/catalog/detail-hero.tsx | 8 + components/catalog/title-details.tsx | 185 +++++++++++++++++++++ components/catalog/title-extras.tsx | 223 ++++++++++++++++++++++++++ components/catalog/title-section.tsx | 7 + components/catalog/trailer-button.tsx | 84 ++++++++++ components/catalog/where-to-watch.tsx | 195 ++++++++++++++++++++++ components/movie/movie-hero.tsx | 21 ++- components/show/show-meta.tsx | 11 +- 9 files changed, 755 insertions(+), 13 deletions(-) create mode 100644 components/catalog/title-details.tsx create mode 100644 components/catalog/title-extras.tsx create mode 100644 components/catalog/title-section.tsx create mode 100644 components/catalog/trailer-button.tsx create mode 100644 components/catalog/where-to-watch.tsx diff --git a/app/movies/[id]/page.tsx b/app/movies/[id]/page.tsx index 9978a63..d957c74 100644 --- a/app/movies/[id]/page.tsx +++ b/app/movies/[id]/page.tsx @@ -3,6 +3,14 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { JsonLd } from "@/components/catalog/json-ld"; +import { MovieCrew, MovieFacts } from "@/components/catalog/title-details"; +import { + AgeRatingSlot, + MoreLikeThisSection, + TrailerSlot, + WhereToWatchSection, +} from "@/components/catalog/title-extras"; +import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section"; import { CastRow } from "@/components/movie/cast-row"; import { MovieDetailSkeleton } from "@/components/movie/movie-detail-skeleton"; import { MovieHero } from "@/components/movie/movie-hero"; @@ -71,6 +79,12 @@ export async function generateMetadata({ * scoped piece is the tracking slot from spec 0007, which lives in * `components/tracking/` and streams inside its own Suspense boundary, so the * catalog content never waits on the session. + * + * Spec 0022 adds the Trailer button and age chip to the hero, the crew under + * the overview, then Where to watch, Cast, More like this and Facts, in that + * order (AC-17). The trailer, chip, Where to watch and More like this stream + * from the extras read in their own boundaries; the chip and Where to watch + * read the visitor's region there, never in this shell (AC-10). */ export default function MoviePage({ params }: PageProps<"/movies/[id]">) { return ( @@ -107,6 +121,7 @@ async function MovieDetail({ } const { movie } = result; + const title = { mediaType: "movie", id } as const; return (
        @@ -130,33 +145,36 @@ async function MovieDetail({ /> } + actions={} + ageRating={} />
        -

        +

        Overview

        +
        + +
        -

        +

        Cast

        + + + +
        ); } diff --git a/components/catalog/detail-hero.tsx b/components/catalog/detail-hero.tsx index 1d47305..4074ebe 100644 --- a/components/catalog/detail-hero.tsx +++ b/components/catalog/detail-hero.tsx @@ -28,6 +28,12 @@ type DetailHeroProps = { * This component reads no session itself, so it stays a plain catalog piece. */ tracking?: React.ReactNode; + /** + * Public actions beside the tracking row: the Trailer button (spec 0022, + * AC-11), streamed in its own boundary with no fallback, so it reserves no + * height and appears only when the title has a trailer. + */ + actions?: React.ReactNode; }; /** @@ -54,6 +60,7 @@ function DetailHero({ tmdbRating, tmdbVoteCount, tracking, + actions, }: DetailHeroProps) { return (
        @@ -128,6 +135,7 @@ function DetailHero({ {meta} {tracking} + {actions}
        diff --git a/components/catalog/title-details.tsx b/components/catalog/title-details.tsx new file mode 100644 index 0000000..0e9e151 --- /dev/null +++ b/components/catalog/title-details.tsx @@ -0,0 +1,185 @@ +import Image from "next/image"; +import type * as React from "react"; + +import { GlassPill } from "@/components/glass-pill"; +import type { Region } from "@/lib/catalog/region"; +import { formatAirDate, formatUsdCompact, languageName } from "@/lib/format"; +import type { Company, Movie, TvShow } from "@/lib/tmdb"; + +import { SECTION_HEADING_CLASS } from "./title-section"; + +/** + * The key crew under the overview (spec 0022, AC-15): "Directed by" and + * "Written by" for a movie, "Created by" for a show. Each line only when it + * has a name, and nothing at all when none does. + */ +function CrewLines({ + lines, +}: { + lines: ReadonlyArray<{ label: string; names: string[] }>; +}) { + const shown = lines.filter((line) => line.names.length > 0); + if (shown.length === 0) return null; + + return ( +
        + {shown.map((line) => ( +
        +
        {line.label}
        +
        + {line.names.join(", ")} +
        +
        + ))} +
        + ); +} + +/** A movie's crew lines, from the crew `getMovie` already read. */ +function MovieCrew({ movie }: { movie: Pick }) { + return ( + + ); +} + +/** A show's crew line, from `created_by` on `getTvShow`. */ +function ShowCrew({ show }: { show: Pick }) { + return ; +} + +/** + * The hero's age rating chip for the visitor's country (spec 0022, AC-13). + * The country is named for assistive technology and on hover, since a bare + * "16" means little without it (AC-9). Rendered only when the country has a + * rating; another country's is never borrowed. + */ +function AgeRatingChip({ rating, region }: { rating: string; region: Region }) { + return ( + + Age rating in {region.name}: + {rating} + + ); +} + +type Fact = { label: string; value: React.ReactNode }; + +/** + * The Facts panel at the end of a title page (spec 0022, AC-16): a list of + * labelled values, each only when TMDB has one. Nothing is shown as Unknown + * or 0; with no facts at all the panel is left out. + */ +function FactsPanel({ facts }: { facts: Array }) { + const shown = facts.filter((fact): fact is Fact => fact !== null); + if (shown.length === 0) return null; + + return ( +
        +

        + Facts +

        +
        + {shown.map((fact) => ( +
        +
        {fact.label}
        +
        + {fact.value} +
        +
        + ))} +
        +
        + ); +} + +/** A fact with a text value, or null when there is no value. */ +function textFact(label: string, value: string | null): Fact | null { + return value === null || value === "" ? null : { label, value }; +} + +/** Company names joined, or null when TMDB lists none. */ +function companiesFact(label: string, companies: Company[]): Fact | null { + return companies.length === 0 + ? null + : { label, value: companies.map((company) => company.name).join(", ") }; +} + +/** A movie's facts, all from `getMovie` (AC-16). */ +function MovieFacts({ movie }: { movie: Movie }) { + return ( + + ); +} + +/** A show's facts, all from `getTvShow` (AC-16). */ +function ShowFacts({ show }: { show: TvShow }) { + return ( + , + }, + companiesFact("Production companies", show.productionCompanies), + textFact("First aired", formatAirDate(show.firstAirDate)), + textFact("Last aired", formatAirDate(show.lastAirDate)), + ]} + /> + ); +} + +/** + * A show's networks: each `w154` logo on a light tile, since most network + * logos are drawn dark for a white page, or the name when TMDB has no logo. + */ +function NetworkList({ networks }: { networks: Company[] }) { + return ( +
          + {networks.map((network) => ( +
        • + {network.logoUrl === null ? ( + network.name + ) : ( + + {network.name} + + )} +
        • + ))} +
        + ); +} + +export { AgeRatingChip, MovieCrew, MovieFacts, ShowCrew, ShowFacts }; diff --git a/components/catalog/title-extras.tsx b/components/catalog/title-extras.tsx new file mode 100644 index 0000000..8ea10d8 --- /dev/null +++ b/components/catalog/title-extras.tsx @@ -0,0 +1,223 @@ +import type * as React from "react"; +import { cache, Suspense } from "react"; + +import { + CarouselRowSkeleton, + ROW_ITEM_CLASS, + ROW_POSTER_SIZES, + ROW_SIZE, +} from "@/components/catalog/carousel-row-states"; +import { CarouselScroller } from "@/components/catalog/carousel-scroller"; +import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; +import { RetryLink } from "@/components/retry-link"; +import { getVisitorRegion } from "@/lib/region"; +import { + getMovieExtras, + getTvExtras, + type MovieExtras, + TmdbError, + type TvExtras, +} from "@/lib/tmdb"; + +import { AgeRatingChip } from "./title-details"; +import { SECTION_HEADING_CLASS } from "./title-section"; +import { TrailerButton } from "./trailer-button"; +import { + WhereToWatch, + WhereToWatchError, + WhereToWatchSkeleton, +} from "./where-to-watch"; + +/** + * The title page sections fed by the extras read (spec 0022, AC-11 to + * AC-14, AC-18), shared by the movie and show pages. + * + * Each slot is its own async Server Component inside its own Suspense + * boundary and calls `readExtras` itself. The calls share one read: the + * cached `getMovieExtras` or `getTvExtras` underneath, and React `cache()` + * on top, so one render asks once. The visitor's region is read only in the + * slots that need it, outside the cache and inside their boundaries, so the + * title pages keep their prerendered shells (AC-9, AC-10). + */ + +/** Which extras read a slot makes, and the page a Retry reloads. */ +type TitleRef = { mediaType: "movie" | "tv"; id: number }; + +/** + * The title's extras, or null when TMDB failed, a 404 included (AC-18). A + * `TmdbError` stays inside the slot that asked; anything else is a bug and + * is rethrown. + */ +const readExtras = cache( + async ( + mediaType: TitleRef["mediaType"], + id: number, + ): Promise => { + try { + return mediaType === "movie" + ? await getMovieExtras(id) + : await getTvExtras(id); + } catch (error) { + if (!(error instanceof TmdbError)) throw error; + return null; + } + }, +); + +/** The title page's own URL, which a Retry reloads in full. */ +function titleHref({ mediaType, id }: TitleRef): string { + return mediaType === "movie" ? `/movies/${id}` : `/shows/${id}`; +} + +/** + * The hero's Trailer button (AC-11), or nothing: while the extras load, + * when they failed, and when the title has no usable trailer. + */ +function TrailerSlot(props: TitleRef & { title: string }) { + return ( + + + + ); +} + +async function TrailerSlotContent({ + mediaType, + id, + title, +}: TitleRef & { title: string }) { + const trailer = (await readExtras(mediaType, id))?.trailer ?? null; + if (trailer === null) return null; + return ( + + ); +} + +/** + * The hero's age rating chip for the visitor's country (AC-13), or nothing: + * while loading, on failure, and when that country has no rating. + */ +function AgeRatingSlot(props: TitleRef) { + return ( + + + + ); +} + +async function AgeRatingSlotContent({ mediaType, id }: TitleRef) { + const [extras, region] = await Promise.all([ + readExtras(mediaType, id), + getVisitorRegion(), + ]); + const rating = extras?.ageRatings[region.code]; + if (rating === undefined) return null; + return ; +} + +/** Where to watch in the visitor's country, with its own states (AC-12, AC-18). */ +function WhereToWatchSection(props: TitleRef) { + return ( + }> + + + ); +} + +async function WhereToWatchContent(props: TitleRef) { + const [extras, region] = await Promise.all([ + readExtras(props.mediaType, props.id), + getVisitorRegion(), + ]); + if (extras === null) { + return ; + } + return ( + + ); +} + +/** + * More like this (AC-14): up to 12 of TMDB's recommendations in the home + * row card, through the same `CatalogListCards` as a landing row, so each + * card carries the visitor's tracked, planned or watched status. No See all, + * since TMDB's recommendations have no list page here. With none, the whole + * section is left out; on failure it keeps its heading with a one line + * error and Retry. + */ +function MoreLikeThisSection(props: TitleRef) { + return ( + + + + } + > + + + ); +} + +async function MoreLikeThisContent(props: TitleRef) { + const extras = await readExtras(props.mediaType, props.id); + if (extras === null) { + return ( + +
        +

        + Couldn't load recommendations. TMDB didn't respond. +

        + +
        +
        + ); + } + if (extras.recommendations.paged.results.length === 0) return null; + + return ( + +
          + +
        +
        + ); +} + +/** The section, its heading and the row's arrow scroller, in every state. */ +function MoreLikeThisFrame({ children }: { children: React.ReactNode }) { + return ( +
        + + More like this + + } + seeAll={null} + > + {children} + +
        + ); +} + +export { AgeRatingSlot, MoreLikeThisSection, TrailerSlot, WhereToWatchSection }; diff --git a/components/catalog/title-section.tsx b/components/catalog/title-section.tsx new file mode 100644 index 0000000..1d4e765 --- /dev/null +++ b/components/catalog/title-section.tsx @@ -0,0 +1,7 @@ +/** + * The h2 look every title page section shares: Overview, Seasons, Where to + * watch, Cast, More like this and Facts (spec 0006, spec 0009, spec 0022). + * One constant, so a new section cannot drift from the old ones. + */ +export const SECTION_HEADING_CLASS = + "text-xl leading-tight font-bold text-foreground md:text-2xl"; diff --git a/components/catalog/trailer-button.tsx b/components/catalog/trailer-button.tsx new file mode 100644 index 0000000..db7002a --- /dev/null +++ b/components/catalog/trailer-button.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { PlayIcon, XIcon } from "lucide-react"; +import { useRef } from "react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +/** + * The hero's Trailer button and its player dialog (spec 0022, AC-11). + * + * The trigger is base-ui's `Dialog.Trigger`, so focus returns to it when the + * dialog closes. The YouTube iframe exists only while the dialog is open: the + * popup unmounts when closed, so no request reaches YouTube until the visitor + * presses Trailer, and closing it stops playback. The privacy enhanced + * `youtube-nocookie.com` host is the one YouTube joined `PROCESSORS` with + * (AC-19). Full screen below `md`, a 16:9 panel from it. + * + * Focus opens on the Close button rather than the player: once focus is + * inside the cross origin iframe, Escape goes to YouTube and never reaches + * the dialog, so a keyboard user could not close it. + * + * @param videoKey Already checked against `[A-Za-z0-9_-]{6,20}` by the + * normalizer, so it is safe in the embed path. + * @param videoName TMDB's name for the video, used in the iframe's title. + * @param title The movie or show title, so the dialog names what it plays. + */ +function TrailerButton({ + videoKey, + videoName, + title, +}: { + videoKey: string; + videoName: string; + title: string; +}) { + const closeRef = useRef(null); + + return ( + + + + + + {title} trailer + +
        +