From 002706bd9b36330c119ea99f8a43842e1780bc3b Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 20:27:18 +0200 Subject: [PATCH 01/10] feat(tmdb): add an image loader that maps widths onto TMDB sizes Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/image-loader.test.ts | 64 +++++++++++++++++++++++++++++++++++ lib/tmdb/image-loader.ts | 53 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 lib/tmdb/image-loader.test.ts create mode 100644 lib/tmdb/image-loader.ts diff --git a/lib/tmdb/image-loader.test.ts b/lib/tmdb/image-loader.test.ts new file mode 100644 index 0000000..6cb4fd4 --- /dev/null +++ b/lib/tmdb/image-loader.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { TMDB_IMAGE_BASE } from "./constants"; +import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader"; + +/** + * covers: prompts/tmdb-image-loader.md, AC-1 to AC-3 + * + * The loader is what keeps Vercel Image Optimization at zero transformations, + * so a wrong rung is either a broken TMDB URL (a 400 for `w640`) or a heavier + * image than the field size allows. Both are invisible until someone looks. + */ +const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`; + +describe("tmdbImageLoader", () => { + it("is the module's default export, which loaderFile needs", () => { + expect(tmdbImageLoaderDefault).toBe(tmdbImageLoader); + }); + + it.each([ + [32, "w92"], + [92, "w92"], + [93, "w154"], + [200, "w300"], + [342, "w342"], + [384, "w500"], + [3840, "w500"], + ])("serves a w500 poster at width %i from %s (AC-1)", (width, size) => { + expect(tmdbImageLoader({ src: at("w500"), width, quality: 75 })).toBe( + at(size), + ); + }); + + it.each([ + [640, "w780"], + [828, "w1280"], + [3840, "w1280"], + ])("serves a w1280 backdrop at width %i from %s (AC-2)", (width, size) => { + expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size)); + }); + + it("never asks for more than the pinned profile size", () => { + expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(at("w185")); + }); + + it("caps an original source at w1280 and still sizes it down (AC-3)", () => { + expect(tmdbImageLoader({ src: at("original"), width: 3840 })).toBe( + at("w1280"), + ); + expect(tmdbImageLoader({ src: at("original"), width: 128 })).toBe( + at("w154"), + ); + }); + + it.each([ + at("h632"), + at("w45"), + `${TMDB_IMAGE_BASE}/abc123.jpg`, + "https://example.com/t/p/w500/abc123.jpg", + "/tmdb-logo.svg", + ])("returns %s unchanged (AC-3)", (src) => { + expect(tmdbImageLoader({ src, width: 200 })).toBe(src); + }); +}); diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts new file mode 100644 index 0000000..141847d --- /dev/null +++ b/lib/tmdb/image-loader.ts @@ -0,0 +1,53 @@ +"use client"; + +import { TMDB_IMAGE_BASE } from "./constants"; + +/** + * The TMDB widths the loader may pick, smallest first. Each answers 200 for + * posters, backdrops, profiles and stills alike (checked live on 2026-10-16); + * `original` is left out because it can be several megabytes. + */ +const WIDTH_LADDER = [92, 154, 185, 300, 342, 500, 780, 1280] as const; + +/** What `original` is capped to, so a full size source never ships as is. */ +const ORIGINAL_CEILING = 1280; + +type LoaderParams = { src: string; width: number; quality?: number }; + +/** + * Maps a `next/image` width request onto TMDB's own pre-sized images, so the + * browser fetches straight from TMDB's CDN and Vercel Image Optimization runs + * no transformations (prompts/tmdb-image-loader.md). TMDB already negotiates + * WebP from the `Accept` header, so nothing is lost by skipping the optimizer. + * + * The size `lib/tmdb/images.ts` pinned is the ceiling: a `w500` poster is never + * upgraded on a dense screen, so no page gets heavier than its field size allows. + * `quality` is ignored because TMDB has no such parameter. Any URL that is not + * a TMDB size path comes back unchanged, so the loader never builds a URL for a + * host it was not handed. + */ +export function tmdbImageLoader({ src, width }: LoaderParams): string { + const prefix = `${TMDB_IMAGE_BASE}/`; + if (!src.startsWith(prefix)) return src; + const rest = src.slice(prefix.length); + const slash = rest.indexOf("/"); + if (slash === -1) return src; + + const size = rest.slice(0, slash); + const ceiling = sizeCeiling(size); + if (ceiling === null) return src; + + const fit = WIDTH_LADDER.find((rung) => rung >= width) ?? ceiling; + return `${prefix}w${Math.min(fit, ceiling)}${rest.slice(slash)}`; +} + +/** The pixel width a source size allows, or null when it is not on the ladder. */ +function sizeCeiling(size: string): number | null { + if (size === "original") return ORIGINAL_CEILING; + const match = /^w(\d+)$/.exec(size); + if (!match) return null; + const value = Number(match[1]); + return (WIDTH_LADDER as readonly number[]).includes(value) ? value : null; +} + +export default tmdbImageLoader; From 9a5489b9603278d6c1beaad5034b4abb5b404ac4 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 20:27:18 +0200 Subject: [PATCH 02/10] feat(images): load TMDB images through the custom loader, skipping the optimizer Co-Authored-By: Claude Opus 5.5 --- next.config.test.ts | 6 ++++++ next.config.ts | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/next.config.test.ts b/next.config.test.ts index a8c0e0c..282e540 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -84,6 +84,12 @@ describe("next.config", () => { ]); }); + /** covers: prompts/tmdb-image-loader.md, AC-4 */ + it("loads images through the TMDB loader, never Vercel's optimizer", () => { + expect(nextConfig.images?.loader).toBe("custom"); + expect(nextConfig.images?.loaderFile).toBe("./lib/tmdb/image-loader.ts"); + }); + it("keeps Cache Components on, which every route is written against", () => { expect(nextConfig.cacheComponents).toBe(true); }); diff --git a/next.config.ts b/next.config.ts index de9abcd..c65d5f7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -60,7 +60,15 @@ const nextConfig: NextConfig = { : []), ]; }, + /** + * A custom loader points every `next/image` at TMDB's own pre-sized files, so + * the browser loads from TMDB's CDN and Vercel's optimizer, whose Hobby quota + * a catalog this size outgrows, never runs (prompts/tmdb-image-loader.md). + * `remotePatterns` stays as the record of the one image host. + */ images: { + loader: "custom", + loaderFile: "./lib/tmdb/image-loader.ts", remotePatterns: [ { protocol: "https", From 149670611f5c519f2185bbc1a25443dfc54d69c3 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 20:27:53 +0200 Subject: [PATCH 03/10] docs(legal): say catalog images load from TMDB, which sees your IP address Co-Authored-By: Claude Opus 5.5 --- app/privacy/page.test.tsx | 12 ++++++++++++ app/privacy/page.tsx | 13 ++++++++++--- lib/legal/operator.ts | 14 ++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx index dd8716d..0d7f591 100644 --- a/app/privacy/page.test.tsx +++ b/app/privacy/page.test.tsx @@ -97,6 +97,18 @@ describe("/privacy", () => { ); }); + /** covers: prompts/tmdb-image-loader.md, AC-6 */ + it("says images load from TMDB, not from the host", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + expect(text).toContain("Your browser loads the images from"); + expect(text).not.toContain("our host delivers the images"); + expect( + PROCESSORS.find((processor) => processor.name === "Vercel")?.purpose, + ).not.toMatch(/image/i); + }); + it("opens external links in a new tab, safely (AC-7)", () => { const { container } = render(); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 445c21a..6740382 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -134,9 +134,11 @@ export default function PrivacyPage() {

Catalog details come from{" "} - TMDB, - which receives no personal data from you: our server makes the catalog - requests, and our host delivers the images. + TMDB. + Our server makes the catalog requests, so TMDB never receives your + account or tracking data. Your browser loads the images from + TMDB’s image service, which sees your IP address and browser + details, and TMDB’s own privacy policy applies to them.

@@ -151,6 +153,11 @@ export default function PrivacyPage() { access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.

+

+ TMDB is a United States company. The images your browser loads from it + come from its content delivery network, which may serve them from + outside the EEA. +

diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts index 42a45a2..37b0257 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-12"; +export const LEGAL_LAST_UPDATED = "2026-10-16"; /** * Where the production Supabase project stores your data, read from @@ -80,7 +80,8 @@ export type Processor = { name: string; purpose: string; url: string }; * 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. + * loads only after you press Trailer. TMDB joined when images moved off + * Vercel's optimizer onto TMDB's own CDN (prompts/tmdb-image-loader.md). */ export const PROCESSORS: readonly Processor[] = [ { @@ -90,10 +91,15 @@ export const PROCESSORS: readonly Processor[] = [ }, { name: "Vercel", - purpose: - "Hosts the website, delivers catalog images, and keeps request logs.", + purpose: "Hosts the website and keeps request logs.", url: "https://vercel.com", }, + { + name: "TMDB", + purpose: + "Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.", + url: "https://www.themoviedb.org", + }, { name: "YouTube", purpose: From 38770b7418bc9315e734adde88a550170d1f4257 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 20:27:53 +0200 Subject: [PATCH 04/10] docs(tmdb): record the image loader plan and its ceiling rule Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/AGENTS.md | 6 ++ prompts/tmdb-image-loader.md | 149 +++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 prompts/tmdb-image-loader.md diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md index fefe133..e1bffdd 100644 --- a/lib/tmdb/AGENTS.md +++ b/lib/tmdb/AGENTS.md @@ -69,6 +69,12 @@ feature 4. Runtime steps live in that spec's `verify.md`. (`resolveOverview` in `normalize.ts`, spec 0006, AC-5). - **Errors carry a `kind`**, never a bare status. Pages branch through `isTmdbNotFound`, not through a hand rolled catch. +- **Images skip Vercel's optimizer.** `image-loader.ts` (`'use client'`, the + `loaderFile` in `next.config.ts`) maps a `next/image` width onto TMDB's own + sizes, never above the size `images.ts` pinned and never `original`, so the + browser loads straight from TMDB's CDN (prompts/tmdb-image-loader.md). A new + image field keeps a size on its ladder; `w45`, `h632` or another host passes + through unresized. - Adding a field the app needs is a three place change: `schemas.ts`, `types.ts`, `normalize.ts`. diff --git a/prompts/tmdb-image-loader.md b/prompts/tmdb-image-loader.md new file mode 100644 index 0000000..308fbd8 --- /dev/null +++ b/prompts/tmdb-image-loader.md @@ -0,0 +1,149 @@ +# TMDB image loader + +## Goal + +Stop Vercel Image Optimization from running on every TMDB image. The Hobby +quota (5,000 transformations a month) is at 5.1K. A transformation is one unique +source URL × width × quality × format, so a catalog with thousands of posters, +backdrops, profiles and stills, several `srcset` widths each, and crawlers that +walk the sitemap will pass that quota at any traffic level. + +TMDB's CDN already serves every image pre-sized (`w92` … `w1280`) and returns +WebP when the browser's `Accept` header allows it, so Vercel's resize is +redundant. A custom `next/image` loader sends the browser straight to the +TMDB size that fits, so Vercel does no transformations at all. + +## Inspected + +- `next.config.ts`: `images.remotePatterns` for `image.tmdb.org/t/p/**` and + nothing else, so the default loader with default `deviceSizes`/`imageSizes` + is in use. +- `lib/tmdb/images.ts`: `TmdbImageSize`, the pinned per field sizes + (`POSTER_SIZE` w500, `BACKDROP_SIZE` w1280, `PROFILE_SIZE` w185, + `STILL_SIZE` w300, `COMPANY_LOGO_SIZE` w154, `PROVIDER_LOGO_SIZE` w92), + `imageUrl`, `resizeImageUrl`. +- Every `next/image` user: `poster-card`, `list-cards`, `catalog-list-cards`, + `carousel-row`, `cast-row`, `title-extras`, `detail-hero`, `season-header`, + `season-grid`, `episode-row`, `quick-search-row`, `where-to-watch`, + `title-details`, the library cards, `/showcase`. All pass TMDB URLs built by + `lib/tmdb`. `site-footer` renders the local TMDB logo SVG `unoptimized`, so + the loader is never called for it. +- `node_modules/next/dist/docs/.../images.md`: `loader: "custom"` + + `loaderFile`, a `'use client'` module with a default export + `({ src, width, quality }) => string`. +- `node_modules/next/dist/shared/lib/get-img-props.js`: in development Next + warns when the loader's URL equals `src`, i.e. it "does not implement width". +- Live TMDB check (2026-10-16): `w45, w92, w154, w185, w300, w342, w500, w780, + w1280, h632, original` all return 200 for a poster, a backdrop and a profile + path; `w640` returns 400. Responses carry `cache-control: public, + max-age=31919000` and come back as `image/webp` for `Accept: image/webp`. +- `lib/legal/operator.ts` and `app/privacy/page.tsx`: the policy says TMDB + "receives no personal data from you … our host delivers the images", and + `PROCESSORS` gives Vercel the job "delivers catalog images". + +## Skills used + +None of the installed skills cover image loaders. The Next.js docs shipped in +`node_modules` are the reference. + +## Decisions + +1. **Images load directly from `image.tmdb.org`** (your choice, 2026-10-16), + not through a same-origin Vercel rewrite, which would only swap image + transformations for edge requests and origin transfer. +2. **Width ladder:** `w92, w154, w185, w300, w342, w500, w780, w1280`. The + loader picks the smallest rung ≥ the requested width. `original` is never + chosen, because it can be several MB. +3. **The size `lib/tmdb` pinned stays the ceiling.** A `w500` poster is never + upgraded to `w780` on a 3× screen, so no image becomes heavier than it is + today and `images.ts`' rule that a page can't quietly request larger images + still holds. Wide `srcset` entries collapse to the same URL, and the browser + dedupes them. +4. **Anything else passes through unchanged:** a URL that isn't + `https://image.tmdb.org/t/p//`, or that has an unknown size, + comes back untouched. The loader never builds a URL for another host. + `remotePatterns` stays in the config as documentation of the one allowed + host. +5. **`quality` is ignored**, because TMDB has no quality parameter. +6. **Privacy text follows the code** (AGENTS.md: legal pages describe only what + the code does): + - Vercel's `PROCESSORS` purpose becomes "Hosts the website and keeps request + logs." + - TMDB joins `PROCESSORS` ("Delivers posters, photos and other catalog + images straight to your browser, which shares your IP address with it.", + `https://www.themoviedb.org`), following the YouTube precedent. + - The paragraph under "Who processes your data" becomes: our server makes + the catalog requests, so TMDB receives no account or tracking data; your + browser loads the images from TMDB's image service, which sees your IP + address and browser details, and TMDB's privacy policy applies. + - "Transfers outside the EEA" adds that TMDB is a United States company. + - `LEGAL_LAST_UPDATED` moves to `2026-10-16`. + - The wording is the operator's legal text. Review it in the PR. + +## Expected files + +- `lib/tmdb/image-loader.ts` (new): `'use client'`, pure, the default export + loader plus an exported `tmdbImageLoader` for tests. Imports only + `TMDB_IMAGE_BASE` and the size type, nothing `server-only`. +- `lib/tmdb/image-loader.test.ts` (new). +- `next.config.ts`: `images.loader: "custom"`, `loaderFile: + "./lib/tmdb/image-loader.ts"`, and its JSDoc saying why. +- `next.config.test.ts`: pin `loader: "custom"` and the loader file. +- `lib/legal/operator.ts`, `app/privacy/page.tsx`, `app/privacy/page.test.tsx` + where needed. +- `lib/tmdb/AGENTS.md`: one line on the loader and the ceiling rule. + +## Requirements + +- R1. No `next/image` in the app requests `/_next/image`. +- R2. Every TMDB `srcset` candidate is a valid TMDB size path no larger than the + size `lib/tmdb` chose. +- R3. Non TMDB or unrecognised URLs pass through unchanged. +- R4. No visual change: same layout, `sizes`, placeholders and missing image + states. +- R5. The privacy page and `PROCESSORS` describe the new image path. + +## Security and privacy + +- The loader runs in the browser, but it only rewrites the size segment of + public CDN URLs. No token or env value is involved, and + `security-boundary.test.ts` still holds. +- Visitors' IP addresses now reach TMDB's image CDN. The privacy text changes + in the same PR, as AGENTS.md requires. +- There is no open redirect or host widening: the loader never builds a URL for + a host it was not given. + +## Acceptance criteria + +- AC-1. `tmdbImageLoader({ src: ".../w500/x.jpg", width: 200 })` returns + `.../w300/x.jpg`. Width 92 returns `w92`, width 342 returns `w342`, and width + 3840 returns `w500` (the ceiling). +- AC-2. A `w1280` backdrop at width 828 returns `w1280`. At width 640 it returns + `w780`. +- AC-3. `original`, `h632` or unknown sources: the ceiling is treated as + `w1280` for `original`, and anything not on the ladder or not under the TMDB + base is returned unchanged. +- AC-4. `next.config` declares `loader: "custom"` and `loaderFile`. +- AC-5. A production build and a page load show `` pointing at + `image.tmdb.org`, with no `/_next/image` requests and no loader width + warning in dev. +- AC-6. `/privacy` lists TMDB, Vercel's purpose no longer mentions images, and + `Last updated` reads 16 October 2026. + +## Automated checks + +`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`. + +## Manual test steps + +1. `pnpm dev`, then open `/shows`. In DevTools → Network → Img, every poster + request goes to `image.tmdb.org/t/p/w…/…`, and none to `/_next/image`. +2. Inspect a poster ``: `srcset` lists TMDB size URLs, none larger than + `w500`. +3. Open a show page: the backdrop loads from `w780`/`w1280`, cast photos from + `w92`–`w185`, and the season episode stills from up to `w300`. +4. Toggle a mobile viewport and reload: smaller rungs are fetched. +5. Check that a title with no poster still shows the placeholder. +6. Open `/privacy`: TMDB is listed and the date is updated. +7. After deploy, Vercel → Usage → Image Optimization: the transformation count + stops growing. From 7c6d586d22d3f5ae7dfa0222b40ad705b178dcf3 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 20:30:28 +0200 Subject: [PATCH 05/10] fix(tmdb): mark same size image URLs so Next sees the loader honour width Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/image-loader.test.ts | 30 +++++++++++++++++++++--------- lib/tmdb/image-loader.ts | 11 ++++++++++- prompts/tmdb-image-loader.md | 5 +++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/tmdb/image-loader.test.ts b/lib/tmdb/image-loader.test.ts index 6cb4fd4..26ace20 100644 --- a/lib/tmdb/image-loader.test.ts +++ b/lib/tmdb/image-loader.test.ts @@ -11,6 +11,8 @@ import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader"; * image than the field size allows. Both are invisible until someone looks. */ const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`; +/** The source's own size comes back marked, so Next sees the width honoured. */ +const same = (size: string) => `${at(size)}#tmdb`; describe("tmdbImageLoader", () => { it("is the module's default export, which loaderFile needs", () => { @@ -23,24 +25,34 @@ describe("tmdbImageLoader", () => { [93, "w154"], [200, "w300"], [342, "w342"], - [384, "w500"], - [3840, "w500"], ])("serves a w500 poster at width %i from %s (AC-1)", (width, size) => { expect(tmdbImageLoader({ src: at("w500"), width, quality: 75 })).toBe( at(size), ); }); - it.each([ - [640, "w780"], - [828, "w1280"], - [3840, "w1280"], - ])("serves a w1280 backdrop at width %i from %s (AC-2)", (width, size) => { - expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size)); + it.each([384, 3840])( + "serves a w500 poster at width %i from its own size, marked so Next does not warn", + (width) => { + expect(tmdbImageLoader({ src: at("w500"), width })).toBe(same("w500")); + }, + ); + + it.each([[640, "w780"]])( + "serves a w1280 backdrop at width %i from %s (AC-2)", + (width, size) => { + expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size)); + }, + ); + + it("serves a w1280 backdrop from its own size above w780 (AC-2)", () => { + expect(tmdbImageLoader({ src: at("w1280"), width: 828 })).toBe( + same("w1280"), + ); }); it("never asks for more than the pinned profile size", () => { - expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(at("w185")); + expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(same("w185")); }); it("caps an original source at w1280 and still sizes it down (AC-3)", () => { diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts index 141847d..9d5f5bd 100644 --- a/lib/tmdb/image-loader.ts +++ b/lib/tmdb/image-loader.ts @@ -12,6 +12,14 @@ const WIDTH_LADDER = [92, 154, 185, 300, 342, 500, 780, 1280] as const; /** What `original` is capped to, so a full size source never ships as is. */ const ORIGINAL_CEILING = 1280; +/** + * Appended when the fitting size is the source's own. Next warns in + * development that a loader "does not implement width" whenever it returns + * `src` unchanged, which every image at its pinned size would. A fragment never + * reaches TMDB, so the request and its CDN cache entry are the same file. + */ +const UNCHANGED_SIZE_MARK = "#tmdb"; + type LoaderParams = { src: string; width: number; quality?: number }; /** @@ -38,7 +46,8 @@ export function tmdbImageLoader({ src, width }: LoaderParams): string { if (ceiling === null) return src; const fit = WIDTH_LADDER.find((rung) => rung >= width) ?? ceiling; - return `${prefix}w${Math.min(fit, ceiling)}${rest.slice(slash)}`; + const url = `${prefix}w${Math.min(fit, ceiling)}${rest.slice(slash)}`; + return url === src ? `${url}${UNCHANGED_SIZE_MARK}` : url; } /** The pixel width a source size allows, or null when it is not on the ladder. */ diff --git a/prompts/tmdb-image-loader.md b/prompts/tmdb-image-loader.md index 308fbd8..e834fb4 100644 --- a/prompts/tmdb-image-loader.md +++ b/prompts/tmdb-image-loader.md @@ -65,6 +65,11 @@ None of the installed skills cover image loaders. The Next.js docs shipped in `remotePatterns` stays in the config as documentation of the one allowed host. 5. **`quality` is ignored**, because TMDB has no quality parameter. +5a. **A URL already at its own size gets a `#tmdb` fragment** (found while + verifying): Next's dev check warns "does not implement width" whenever the + loader returns `src` unchanged, which every image at its pinned size does. + A fragment never reaches TMDB, so the request and its cache entry are + unchanged. 6. **Privacy text follows the code** (AGENTS.md: legal pages describe only what the code does): - Vercel's `PROCESSORS` purpose becomes "Hosts the website and keeps request From ae69334f9102e5e2a187e74d57e8619493159283 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 21:02:37 +0200 Subject: [PATCH 06/10] refactor(tmdb): share one image width list and URL splitter with the loader The loader's ladder had w342 the size union lacked, and both files split a TMDB URL by hand. images.ts now owns the widths and splitImageUrl. Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/image-loader.ts | 28 ++++++++--------------- lib/tmdb/images.ts | 49 ++++++++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts index 9d5f5bd..85a7547 100644 --- a/lib/tmdb/image-loader.ts +++ b/lib/tmdb/image-loader.ts @@ -1,13 +1,7 @@ "use client"; import { TMDB_IMAGE_BASE } from "./constants"; - -/** - * The TMDB widths the loader may pick, smallest first. Each answers 200 for - * posters, backdrops, profiles and stills alike (checked live on 2026-10-16); - * `original` is left out because it can be several megabytes. - */ -const WIDTH_LADDER = [92, 154, 185, 300, 342, 500, 780, 1280] as const; +import { splitImageUrl, TMDB_IMAGE_WIDTHS } from "./images"; /** What `original` is capped to, so a full size source never ships as is. */ const ORIGINAL_CEILING = 1280; @@ -35,18 +29,14 @@ type LoaderParams = { src: string; width: number; quality?: number }; * host it was not handed. */ export function tmdbImageLoader({ src, width }: LoaderParams): string { - const prefix = `${TMDB_IMAGE_BASE}/`; - if (!src.startsWith(prefix)) return src; - const rest = src.slice(prefix.length); - const slash = rest.indexOf("/"); - if (slash === -1) return src; - - const size = rest.slice(0, slash); - const ceiling = sizeCeiling(size); + const parts = splitImageUrl(src); + if (!parts) return src; + const ceiling = sizeCeiling(parts.size); if (ceiling === null) return src; - const fit = WIDTH_LADDER.find((rung) => rung >= width) ?? ceiling; - const url = `${prefix}w${Math.min(fit, ceiling)}${rest.slice(slash)}`; + // `original` is left off the ladder because it can be several megabytes. + const fit = TMDB_IMAGE_WIDTHS.find((rung) => rung >= width) ?? ceiling; + const url = `${TMDB_IMAGE_BASE}/w${Math.min(fit, ceiling)}${parts.path}`; return url === src ? `${url}${UNCHANGED_SIZE_MARK}` : url; } @@ -56,7 +46,9 @@ function sizeCeiling(size: string): number | null { const match = /^w(\d+)$/.exec(size); if (!match) return null; const value = Number(match[1]); - return (WIDTH_LADDER as readonly number[]).includes(value) ? value : null; + return (TMDB_IMAGE_WIDTHS as readonly number[]).includes(value) + ? value + : null; } export default tmdbImageLoader; diff --git a/lib/tmdb/images.ts b/lib/tmdb/images.ts index b77e945..8d44c91 100644 --- a/lib/tmdb/images.ts +++ b/lib/tmdb/images.ts @@ -1,22 +1,47 @@ import { TMDB_IMAGE_BASE } from "./constants"; /** - * The TMDB image widths this app is allowed to request. + * The TMDB widths this app may request, smallest first. Each answers 200 for + * posters, backdrops, profiles and stills alike (checked live on 2026-10-16). + * + * One list for both the pinned sizes below and the `next/image` loader's + * ladder (`image-loader.ts`), so a width the loader can pick is always one the + * type allows, and adding a size is one edit. + */ +export const TMDB_IMAGE_WIDTHS = [ + 92, 154, 185, 300, 342, 500, 780, 1280, +] as const; + +/** + * The TMDB image sizes this app is allowed to request. * * A closed union rather than a free string: a typo in a size produces a URL * TMDB answers with a 404 image, which is invisible until someone looks at the * page. `original` is included for the rare full size case. */ export type TmdbImageSize = - | "w92" - | "w154" - | "w185" - | "w300" - | "w500" - | "w780" - | "w1280" + | `w${(typeof TMDB_IMAGE_WIDTHS)[number]}` | "original"; +/** A TMDB image URL split at its size segment. */ +export type TmdbImageParts = { size: string; path: string }; + +/** + * Splits an absolute TMDB image URL into its size segment and file path, or + * returns null for a URL this module did not build. The one parser both + * `resizeImageUrl` and the `next/image` loader use, so they agree on what a + * TMDB size path is. The size comes back as a plain string because a URL from + * TMDB itself can carry sizes the app never requests, such as `h632`. + */ +export function splitImageUrl(url: string): TmdbImageParts | null { + const prefix = `${TMDB_IMAGE_BASE}/`; + if (!url.startsWith(prefix)) return null; + const rest = url.slice(prefix.length); + const slash = rest.indexOf("/"); + if (slash === -1) return null; + return { size: rest.slice(0, slash), path: rest.slice(slash) }; +} + /** * The size each normalized field is built with (spec 0002, AC-27). * @@ -72,10 +97,6 @@ export function resizeImageUrl( size: TmdbImageSize, ): string | null { if (!url) return null; - const prefix = `${TMDB_IMAGE_BASE}/`; - if (!url.startsWith(prefix)) return url; - const rest = url.slice(prefix.length); - const slash = rest.indexOf("/"); - if (slash === -1) return url; - return `${prefix}${size}${rest.slice(slash)}`; + const parts = splitImageUrl(url); + return parts ? `${TMDB_IMAGE_BASE}/${size}${parts.path}` : url; } From 0b68387dbbe041f54c2b71d2031a4793830beb0f Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 21:03:07 +0200 Subject: [PATCH 07/10] refactor(tmdb): name the same size mark for what it is #tmdb said nothing about why it is there; #same-size does. The loader's JSDoc now says Next's width warning flags a pass-through source. Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/image-loader.test.ts | 11 +++++++---- lib/tmdb/image-loader.ts | 9 +++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/tmdb/image-loader.test.ts b/lib/tmdb/image-loader.test.ts index 26ace20..8165e3e 100644 --- a/lib/tmdb/image-loader.test.ts +++ b/lib/tmdb/image-loader.test.ts @@ -12,7 +12,7 @@ import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader"; */ const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`; /** The source's own size comes back marked, so Next sees the width honoured. */ -const same = (size: string) => `${at(size)}#tmdb`; +const same = (size: string) => `${at(size)}#same-size`; describe("tmdbImageLoader", () => { it("is the module's default export, which loaderFile needs", () => { @@ -70,7 +70,10 @@ describe("tmdbImageLoader", () => { `${TMDB_IMAGE_BASE}/abc123.jpg`, "https://example.com/t/p/w500/abc123.jpg", "/tmdb-logo.svg", - ])("returns %s unchanged (AC-3)", (src) => { - expect(tmdbImageLoader({ src, width: 200 })).toBe(src); - }); + ])( + "returns %s unchanged, leaving Next's width warning to flag it (AC-3)", + (src) => { + expect(tmdbImageLoader({ src, width: 200 })).toBe(src); + }, + ); }); diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts index 85a7547..94bf55d 100644 --- a/lib/tmdb/image-loader.ts +++ b/lib/tmdb/image-loader.ts @@ -12,7 +12,7 @@ const ORIGINAL_CEILING = 1280; * `src` unchanged, which every image at its pinned size would. A fragment never * reaches TMDB, so the request and its CDN cache entry are the same file. */ -const UNCHANGED_SIZE_MARK = "#tmdb"; +const SAME_SIZE_FRAGMENT = "#same-size"; type LoaderParams = { src: string; width: number; quality?: number }; @@ -25,8 +25,9 @@ type LoaderParams = { src: string; width: number; quality?: number }; * The size `lib/tmdb/images.ts` pinned is the ceiling: a `w500` poster is never * upgraded on a dense screen, so no page gets heavier than its field size allows. * `quality` is ignored because TMDB has no such parameter. Any URL that is not - * a TMDB size path comes back unchanged, so the loader never builds a URL for a - * host it was not handed. + * a TMDB size path on the ladder comes back unchanged, so the loader never + * builds a URL for a host it was not handed. Next's development warning then + * names that image, which is the signal to pin it to a ladder size. */ export function tmdbImageLoader({ src, width }: LoaderParams): string { const parts = splitImageUrl(src); @@ -37,7 +38,7 @@ export function tmdbImageLoader({ src, width }: LoaderParams): string { // `original` is left off the ladder because it can be several megabytes. const fit = TMDB_IMAGE_WIDTHS.find((rung) => rung >= width) ?? ceiling; const url = `${TMDB_IMAGE_BASE}/w${Math.min(fit, ceiling)}${parts.path}`; - return url === src ? `${url}${UNCHANGED_SIZE_MARK}` : url; + return url === src ? `${url}${SAME_SIZE_FRAGMENT}` : url; } /** The pixel width a source size allows, or null when it is not on the ladder. */ From 0e73114b6da79b3f761bad568a5c86e842b1ce24 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 21:03:10 +0200 Subject: [PATCH 08/10] docs(tmdb): match the loader notes and plan ACs to the code The folder header now names the one browser file, pass-through sources are said to warn in dev, and AC-1/AC-2 expect the #same-size fragment. Co-Authored-By: Claude Opus 5.5 --- lib/tmdb/AGENTS.md | 13 +++++++++---- prompts/tmdb-image-loader.md | 15 +++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md index e1bffdd..c1d4266 100644 --- a/lib/tmdb/AGENTS.md +++ b/lib/tmdb/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md · lib/tmdb -The server only module every TMDB request goes through. Decided in +The server only module every TMDB request goes through, plus the one browser +file, `image-loader.ts`, that `next.config.ts` hands to `next/image`. Decided in [spec 0002](../../docs/specs/0002-tmdb-integration-module/index.md); scope feature 4. Runtime steps live in that spec's `verify.md`. @@ -8,7 +9,9 @@ feature 4. Runtime steps live in that spec's `verify.md`. - `index.ts` is the only entry point a page may import. It exports the cached reads, `imageUrl`, `isTmdbNotFound`, `TmdbError`, `TMDB_ATTRIBUTION` and the - normalized types. Everything else in this folder is internal. + normalized types. Everything else in this folder is internal, except + `image-loader.ts` (`'use client'`), which only the `loaderFile` setting + imports, and the pure `images.ts` it reads. - `reads.ts` holds every `use cache` wrapper and is the only file that imports `next/cache`. - `client.ts` owns auth, the deadline, the retry budget, the status mapping and @@ -73,8 +76,10 @@ feature 4. Runtime steps live in that spec's `verify.md`. `loaderFile` in `next.config.ts`) maps a `next/image` width onto TMDB's own sizes, never above the size `images.ts` pinned and never `original`, so the browser loads straight from TMDB's CDN (prompts/tmdb-image-loader.md). A new - image field keeps a size on its ladder; `w45`, `h632` or another host passes - through unresized. + image field keeps a size from `TMDB_IMAGE_WIDTHS` in `images.ts`, the one + list the loader and `TmdbImageSize` share. `h632`, another host or a size + off that list passes through unresized, and Next's dev warning ("does not + implement width") names it. - Adding a field the app needs is a three place change: `schemas.ts`, `types.ts`, `normalize.ts`. diff --git a/prompts/tmdb-image-loader.md b/prompts/tmdb-image-loader.md index e834fb4..a43e441 100644 --- a/prompts/tmdb-image-loader.md +++ b/prompts/tmdb-image-loader.md @@ -51,8 +51,9 @@ None of the installed skills cover image loaders. The Next.js docs shipped in 1. **Images load directly from `image.tmdb.org`** (your choice, 2026-10-16), not through a same-origin Vercel rewrite, which would only swap image transformations for edge requests and origin transfer. -2. **Width ladder:** `w92, w154, w185, w300, w342, w500, w780, w1280`. The - loader picks the smallest rung ≥ the requested width. `original` is never +2. **Width ladder:** `w92, w154, w185, w300, w342, w500, w780, w1280`, kept as + `TMDB_IMAGE_WIDTHS` in `images.ts`, the one list `TmdbImageSize` is built + from too (amended after review). The loader picks the smallest rung ≥ the requested width. `original` is never chosen, because it can be several MB. 3. **The size `lib/tmdb` pinned stays the ceiling.** A `w500` poster is never upgraded to `w780` on a 3× screen, so no image becomes heavier than it is @@ -62,10 +63,12 @@ None of the installed skills cover image loaders. The Next.js docs shipped in 4. **Anything else passes through unchanged:** a URL that isn't `https://image.tmdb.org/t/p//`, or that has an unknown size, comes back untouched. The loader never builds a URL for another host. + Next's dev warning ("does not implement width") then names that image, the + signal to pin it to a ladder size; no caller does so today. `remotePatterns` stays in the config as documentation of the one allowed host. 5. **`quality` is ignored**, because TMDB has no quality parameter. -5a. **A URL already at its own size gets a `#tmdb` fragment** (found while +5a. **A URL already at its own size gets a `#same-size` fragment** (found while verifying): Next's dev check warns "does not implement width" whenever the loader returns `src` unchanged, which every image at its pinned size does. A fragment never reaches TMDB, so the request and its cache entry are @@ -122,9 +125,9 @@ None of the installed skills cover image loaders. The Next.js docs shipped in - AC-1. `tmdbImageLoader({ src: ".../w500/x.jpg", width: 200 })` returns `.../w300/x.jpg`. Width 92 returns `w92`, width 342 returns `w342`, and width - 3840 returns `w500` (the ceiling). -- AC-2. A `w1280` backdrop at width 828 returns `w1280`. At width 640 it returns - `w780`. + 3840 returns `.../w500/x.jpg#same-size` (the ceiling, marked per 5a). +- AC-2. A `w1280` backdrop at width 828 returns `.../w1280/x.jpg#same-size`. + At width 640 it returns `w780`. - AC-3. `original`, `h632` or unknown sources: the ceiling is treated as `w1280` for `original`, and anything not on the ladder or not under the TMDB base is returned unchanged. From 33d3abab2e238a54bc7897eb86163921fff01c69 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 21:04:16 +0200 Subject: [PATCH 09/10] refactor(legal): give TMDB's site URL one home in the operator module Four files spelled https://www.themoviedb.org; TMDB_HOME_URL now does, and the boundary test fails if a copy reappears. Co-Authored-By: Claude Opus 5.5 --- app/privacy/page.tsx | 11 ++++++----- app/showcase/page.tsx | 5 ++--- components/layout/site-footer.tsx | 8 ++++++-- lib/legal/boundary.test.ts | 5 +++++ lib/legal/operator.ts | 11 ++++++++++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 6740382..8ca6562 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -18,6 +18,7 @@ import { RIGHTS_RESPONSE, SUPABASE_REGION, SUPERVISORY_AUTHORITY, + TMDB_HOME_URL, } from "@/lib/legal/operator"; import { catalogMetadata } from "@/lib/seo/metadata"; @@ -134,11 +135,11 @@ export default function PrivacyPage() {

Catalog details come from{" "} - TMDB. - Our server makes the catalog requests, so TMDB never receives your - account or tracking data. Your browser loads the images from - TMDB’s image service, which sees your IP address and browser - details, and TMDB’s own privacy policy applies to them. + TMDB. Our server + makes the catalog requests, so TMDB never receives your account or + tracking data. Your browser loads the images from TMDB’s image + service, which sees your IP address and browser details, and + TMDB’s own privacy policy applies to them.

diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index e68ba8b..02d7411 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -50,6 +50,7 @@ import { import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { catalogList, listPath } from "@/lib/catalog/lists"; +import { TMDB_HOME_URL } from "@/lib/legal/operator"; import { CardRoundButtonDemo } from "./card-round-button-demo"; @@ -568,9 +569,7 @@ export default function ShowcasePage() {

Body text in the secondary token, with an{" "} - - external link - {" "} + external link{" "} and the contact address, .

diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx index ec9741f..e09c680 100644 --- a/components/layout/site-footer.tsx +++ b/components/layout/site-footer.tsx @@ -2,7 +2,11 @@ import { cn } from "cn"; import Image from "next/image"; import Link from "next/link"; import type * as React from "react"; -import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator"; +import { + OPERATOR_GITHUB_URL, + OPERATOR_HANDLE, + TMDB_HOME_URL, +} from "@/lib/legal/operator"; import { TMDB_ATTRIBUTION } from "@/lib/tmdb"; import { IconKey } from "./icon-key"; @@ -32,7 +36,7 @@ export function SiteFooter() {
{ ]); }); + it("writes TMDB's site URL only in lib/legal/operator.ts", () => { + expect(filesContaining(TMDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]); + }); + it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => { expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([ "lib/legal/operator.ts", diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts index 37b0257..1ff8a9d 100644 --- a/lib/legal/operator.ts +++ b/lib/legal/operator.ts @@ -63,6 +63,15 @@ export const SUPABASE_REGION = { */ export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch"; +/** + * TMDB's own site, where the footer logo, the privacy policy and TMDB's + * processor entry link. One home, like `JUSTWATCH_ATTRIBUTION`, so the link + * cannot drift between the places that name TMDB. It lives here rather than in + * `lib/tmdb` because that entry point is server only and the footer test and + * the legal pages render in jsdom. + */ +export const TMDB_HOME_URL = "https://www.themoviedb.org"; + /** Days within which an account deletion request is completed. */ export const DELETION_DAYS = 30; @@ -98,7 +107,7 @@ export const PROCESSORS: readonly Processor[] = [ name: "TMDB", purpose: "Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.", - url: "https://www.themoviedb.org", + url: TMDB_HOME_URL, }, { name: "YouTube", From 31ba78d741d6ccc0272865304bc38fadce3b7cc6 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 16 Oct 2026 21:04:50 +0200 Subject: [PATCH 10/10] refactor(legal): keep each processor's EEA transfer in PROCESSORS /privacy wrote Supabase, Vercel and TMDB's country in page text; each processor now carries its transfer sentence and the page renders them. Co-Authored-By: Claude Opus 5.5 --- app/privacy/page.test.tsx | 12 ++++++++++++ app/privacy/page.tsx | 17 +++++++---------- lib/legal/boundary.test.ts | 6 ++++++ lib/legal/operator.ts | 19 +++++++++++++++++-- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx index 0d7f591..c91f64d 100644 --- a/app/privacy/page.test.tsx +++ b/app/privacy/page.test.tsx @@ -147,6 +147,18 @@ describe("/privacy", () => { expect(text).toContain("EU Standard Contractual Clauses"); }); + it("states each processor's transfer from the one module", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + for (const processor of PROCESSORS) { + if (processor.transfer) expect(text).toContain(processor.transfer); + } + expect( + PROCESSORS.find((processor) => processor.name === "TMDB")?.transfer, + ).toMatch(/outside the EEA/); + }); + it("promises no account email production can't send (spec 0018, AC-23)", () => { const { container } = render(); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 8ca6562..90084d6 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -149,16 +149,13 @@ export default function PrivacyPage() { {SUPABASE_REGION.name}, and the app’s server functions in the matching Vercel region.

-

- Supabase and Vercel are United States companies. Where their staff - access your data for support and operations, that access is covered by - the EU Standard Contractual Clauses. -

-

- TMDB is a United States company. The images your browser loads from it - come from its content delivery network, which may serve them from - outside the EEA. -

+ {PROCESSORS.flatMap((processor) => + processor.transfer ? ( +

{processor.transfer}

+ ) : ( + [] + ), + )} diff --git a/lib/legal/boundary.test.ts b/lib/legal/boundary.test.ts index e076864..92896b9 100644 --- a/lib/legal/boundary.test.ts +++ b/lib/legal/boundary.test.ts @@ -60,6 +60,12 @@ describe("one home for each legal fact", () => { expect(filesContaining(TMDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]); }); + it("names no processor's country in /privacy itself", () => { + expect(readFileSync("app/privacy/page.tsx", "utf8")).not.toContain( + "United States", + ); + }); + it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => { expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([ "lib/legal/operator.ts", diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts index 1ff8a9d..5aedef4 100644 --- a/lib/legal/operator.ts +++ b/lib/legal/operator.ts @@ -81,8 +81,17 @@ export const RIGHTS_RESPONSE = "one month"; /** Days within which a complaint about the service is answered. */ export const COMPLAINT_RESPONSE_DAYS = 14; -/** A service provider that processes personal data on the operator's behalf. */ -export type Processor = { name: string; purpose: string; url: string }; +/** + * A service provider that processes personal data on the operator's behalf. + * `transfer`, when set, is what "Transfers outside the EEA" says about it, so + * the company's country lives beside its other facts and not in page text. + */ +export type Processor = { + name: string; + purpose: string; + url: string; + transfer?: string; +}; /** * Only the processors live today. Production sends no email (spec 0018), so @@ -97,17 +106,23 @@ export const PROCESSORS: readonly Processor[] = [ name: "Supabase", purpose: "Stores your account and tracking data and handles sign in.", url: "https://supabase.com", + transfer: + "Supabase is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.", }, { name: "Vercel", purpose: "Hosts the website and keeps request logs.", url: "https://vercel.com", + transfer: + "Vercel is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.", }, { name: "TMDB", purpose: "Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.", url: TMDB_HOME_URL, + transfer: + "TMDB is a United States company. The images your browser loads from it come from its content delivery network, which may serve them from outside the EEA.", }, { name: "YouTube",