sunnymh-manga-site/CLAUDE.md
yiekheng 33087cc5b3 Make list pages fully dynamic; cache only at query level
Docker production build runs without DATABASE_URL, so any page Next
tries to prerender at build (force-static / revalidate without a
dynamic segment) fails on the Prisma call. Homepage, genre page, and
sitemap previously had page-level revalidate, forcing prerender. Add
force-dynamic to each and move the revalidation inside an
unstable_cache wrapper around the Prisma query — result still cached
5m / 1h at runtime, but build no longer touches the DB.

Detail page (/manga/[slug]) keeps page-level revalidate since its
dynamic segment without generateStaticParams was never prerendered at
build anyway.

Update CLAUDE.md caching section to reflect the new strategy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:56:26 +08:00

250 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **This is NOT the Next.js you know.** Next 16 / App Router has breaking
> changes from older training data — APIs, conventions, and file structure
> may differ. Check `node_modules/next/dist/docs/` before writing routing
> or metadata code, and heed deprecation notices.
## Build & Development Commands
```bash
npm run dev # Start dev server (hot reload, Turbopack)
npm run build # Production build
npm start # Start production server
npm run lint # ESLint (flat config, v9+)
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma db push # Sync schema to DB (this repo uses db push, not migrate)
npx tsx scripts/backfill-page-dims.ts # Probe Page.width/height via image-size against R2
```
The DB is managed via `prisma db push`, not `migrate dev`. `prisma/migrations/`
holds SQL files kept for reference only; `migrate dev` won't work (no shadow
DB permission on this Postgres).
## Architecture
**Stack**: Next.js 16.2.1 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · Prisma · PostgreSQL 16 · Cloudflare R2
**Path alias**: `@/*` maps to project root.
### Reader is placeholder-based — read this before touching `PageReader.tsx`
`components/PageReader.tsx` is deliberately **not** a naive `pages.map(<img>)`.
Every page renders as a `<div>` placeholder with `style={{ aspectRatio: w/h }}`
so the document height is correct before any image bytes load. This avoids
a whole class of iOS Safari scroll-anchoring / prepend-shift bugs that a
simple reader would hit. Key invariants:
- `Page.width` / `Page.height` **must** be populated for every row. The
Python scraper writes them on insert; the backfill script covers older
rows. With dims=0, the reader falls back to 3/4 and layout shifts on
image load.
- Inside each placeholder div the `<img>` is `absolute inset-0 w-full
h-full object-contain` — this is load-bearing. Natural-flow (`h-auto`)
lets subpixel aspect mismatch between DB dims and actual file dims
overflow the div, causing overlap with the next page. Absolute-pinned
+ object-contain clamps the img to div dimensions no matter what.
- Initial chapter meta (dims skeleton) is fetched server-side in
`app/manga/[slug]/[chapter]/page.tsx` and passed as a prop. Later
chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`, triggered
by an `IntersectionObserver` with 1200 px margin when user enters the
last `PREFETCH_NEXT_AT` pages of a chapter.
- Image URLs are **not signed** — served direct from the custom domain.
Fetched via `/api/pages` in fixed chunks of `IMAGE_CHUNK_SIZE = 5`.
Trigger runs in a `useEffect` on `[currentPageNum, currentChapterNum,
images, chapterMetas]`: fetches forward chunk when `currentPage +
PREFETCH_LEAD (3) >= maxCached`, backward chunk when `currentPage -
PREFETCH_LEAD <= minCached`, and next chapter's first chunk when
`currentPage + PREFETCH_LEAD >= meta.length`. `forceFetchPage`
(`limit=1`) is a viewport-observer fallback for pages that enter view
without cached URL (onError retry, unhandled gap).
- A separate intersecting-pages `Map` is maintained for the scroll tick
so the topmost visible page can be found in O(k) without walking every
placeholder.
- Continuous multi-chapter reading auto-appends the next chapter when the
user gets within `PREFETCH_NEXT_AT = 3` pages of the end. Only chapters
`>= startChapterNumber` render (scrolling **up** past the start doesn't
load prior chapters — use drawer or double-tap-left). Deferred bidi-
infinite-scroll plan is in the user-memory system.
- Image cache pruning runs on `[currentChapterNum, currentPageNum]`.
`keep` set is `{current, current-1 if in first KEEP_PREV_CHAPTER_PAGES,
current+1 if in last KEEP_PREV_CHAPTER_PAGES}`. Must keep current+1
conditionally so a backward scroll from a just-entered chapter doesn't
immediately drop the images the user is about to re-enter.
- URL syncs to the chapter currently in viewport via
`window.history.replaceState`. `prevChapter` / `nextChapter` for
double-tap nav are derived from `currentChapterNum`, **not** from the URL
or props.
- `window.history.scrollRestoration = "manual"` is set in the resume
`useLayoutEffect`. Without this, refreshing while deep in an auto-
appended chapter has the browser restore a `scrollY` that references a
(now gone) taller document, clamping to `scrollHeight` and landing
near the bottom.
- All Links into the reader use `scroll={false}`, and the double-tap
`router.push(..., { scroll: false })`. This is load-bearing — without it,
App Router's default scroll-to-top clobbers the `useLayoutEffect` that
restores the resume page.
- Reading progress is persisted to `localStorage` under the key
`sunnymh:last-read:<slug>` as JSON `{chapter, page, ratio}` (ratio =
fractional scroll offset inside the current page). `readProgress` in
`components/ReadingProgressButton.tsx` also accepts the **legacy
bare-number format** (just a chapter number string) AND the pre-ratio
`{chapter, page}` shape — preserve both when touching the storage
format.
### Immersive reader route
`components/Header.tsx` and `components/BottomNav.tsx` both return `null`
when the pathname matches `/manga/[slug]/[chapter]` — the reader runs
full-bleed with its own chrome. Anything new that relies on the global
nav being present needs to account for this.
### Data flow
- **Prisma models** (see `prisma/schema.prisma`): `Manga`, `Chapter`, `Page`.
`Page` carries `width` / `height`. `Manga` has `genre` as a
comma-separated string; parse with `lib/genres.ts`.
- **Images live in R2** (S3-compatible), served via the Cloudflare custom
domain in `R2_PUBLIC_URL`. Format is **WebP** (~2535% smaller than
JPEG). URLs stored in `Manga.coverUrl` / `Page.imageUrl` are already
browser-ready — pass them straight through. `lib/r2.ts::keyFromPublicUrl`
reverses a stored URL back to its R2 key (used by the backfill script).
`getPresignedUploadUrl` still exists for `/api/upload` (admin PUTs).
Read-side signing was removed — URL theft protection is handled at the
CDN layer (Cloudflare Hotlink Protection on the custom domain).
- **R2 layout** (populated by the scraper — not this app):
- `manga/<slug>/chapters/<chapter_number>/<page_number>.webp`
- `manga/<slug>/cover.webp`
- **Search**: PostgreSQL `contains` + `mode: 'insensitive'`. Fine up to
~10k titles. If a future scale requires fuzzy/full-text, the planned
swap-in is Meilisearch.
### Key routes
- `app/page.tsx` — homepage (trending carousel + genre tabs).
- `app/manga/[slug]/page.tsx` — detail + chapter list + "continue reading".
- `app/manga/[slug]/[chapter]/page.tsx` — reader. Issues both required
queries (manga+chapters, initial chapter page meta) in parallel via
`Promise.all`.
- `app/api/pages/route.ts` — batched `{number, imageUrl}` (raw custom-
domain URLs, not signed).
- `app/api/chapters/[chapterId]/meta/route.ts` — `[{number, width, height}]`
for lazy next-chapter prefetch.
- `app/api/search/route.ts` — case-insensitive title search.
- `app/api/upload/route.ts` — R2 presigned PUT (admin/scraper only; no DB
write happens here).
- `app/sitemap.ts` — auto-generated `/sitemap.xml`. Each page uses
`generateMetadata()` for per-route title/description.
### Caching strategy
All DB-querying pages are `export const dynamic = "force-dynamic"`
with their Prisma queries wrapped in `unstable_cache`. **Pages do not
page-level ISR / static-prerender** — the Docker production build
runs without `DATABASE_URL` in the build env, so any page Next tries
to prerender at build time would fail on the DB connection. Keeping
everything dynamic sidesteps this while `unstable_cache` around the
query still dedupes DB hits across runtime requests.
Cache keys + TTLs:
- `home-manga-list` — `app/page.tsx` (5 min)
- `genre-manga-list` — `app/genre/page.tsx` (5 min)
- `sitemap-manga-list` — `app/sitemap.ts` (1 hour)
- `reader-manga` — reader's manga+chapters query (5 min)
- `reader-chapter-meta` — reader's page-dim query (1 hour — chapter
contents are immutable)
- `search-manga` — `app/search/page.tsx`, keyed on query string (60 s)
- `app/manga/[slug]/page.tsx` — has `revalidate = 300` directly (it's a
dynamic segment without `generateStaticParams`, so Next caches the
rendered result per-URL at runtime without prerendering at build).
**No revalidateTag hook exists.** The scraper (`../manga-dl/manga.py`)
writes to Postgres directly and has no callback into this app.
Consequence: new chapters / manga appear up to 5 min after they land
in the DB. Accepted trade-off for now; wire a `POST /api/revalidate`
endpoint if that window becomes too long.
- **Images**: no application-layer cache — Cloudflare edge cache handles
it. Bucket is private at the R2 level; only exposed via the custom
domain `images.04080616.xyz` which is fronted by a **WAF Custom Rule**
blocking requests whose `http.referer` is non-empty and doesn't
contain `04080616.xyz` / `localhost` / `10.8.0.`. Hotlinking from
other sites 403s; direct `<img src>` and RSS / no-referer access work.
### Ingestion is a sibling Python repo
New manga and chapters are created by `../manga-dl/manga.py`, not by this
web app. It scrapes, converts to WebP, uploads to R2, and writes
`Manga` / `Chapter` / `Page` rows directly via `psycopg2`. When updating the
Prisma schema, update the sibling's SQL INSERTs too (they're raw, not ORM).
### Branding
- Source SVGs in `logo/qingtian_*.svg`.
- Wired up as: `app/icon.svg` (browser favicon, Next.js convention),
`app/apple-icon.svg` (iOS home-screen), `public/logo.svg` (Header `<img>`).
- Accent color: `#268a52` (green sunflower), defined as `--accent` in
`app/globals.css`. Hover: `#1f7044`.
- Brand name: 晴天漫画 · Sunny MH.
### iOS Safari conventions
- `viewport` in `app/layout.tsx` sets `viewportFit: "cover"` so the app
extends into the notch; any sticky/fixed chrome must use the
`pt-safe` / `pb-safe` utilities from `app/globals.css` (they map to
`env(safe-area-inset-*)`). Header and BottomNav already do.
- Horizontal carousels (`components/TrendingCarousel.tsx`) set
`WebkitOverflowScrolling: "touch"` inline for momentum scrolling.
Preserve this on any new swipe lane.
- `overflow-x: hidden` on `body` (globals.css) prevents the fixed
BottomNav from leaking horizontal scroll on mobile.
## Deployment
- **Host**: Proxmox VM running Docker + Portainer.
- **Flow**: Push to Gitea (`gitea.04080616.xyz/yiekheng/sunnymh-manga-site`)
→ `git pull` on the Docker host → rebuild → restart via Portainer.
- **Dockerfile** at repo root; container listens on 3000, mapped to host
3001. `docker-compose.yml` only brings up the app — **Postgres is
external** (connected via `DATABASE_URL`), not defined in compose.
- **Reverse proxy**: aaPanel Nginx routes `www.04080616.xyz` →
`http://127.0.0.1:3001`. SSL via Let's Encrypt managed in aaPanel. DDNS
already configured — no Cloudflare Tunnel involved.
- **Host ports in use elsewhere**: 3000, 80008002, 8005, 2283, 5432,
5182051821, 9443 — don't collide. Postgres lives on host 5433.
- After schema changes reach prod, run `scripts/backfill-page-dims.ts`
once against prod DB to populate dims for historical rows.
### Minimal Nginx location block (aaPanel)
```nginx
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
```
## Environment Variables
Required in `.env` (container equivalents in `docker-compose.yml`):
- `DATABASE_URL` — PostgreSQL connection string, e.g. `postgresql://manga_user:…@localhost:5433/manga_db`
- `R2_ACCOUNT_ID`, `R2_ACCESS_KEY`, `R2_SECRET_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL` — Cloudflare R2
## Dev gotchas
- **`next.config.ts` has `allowedDevOrigins: ["10.8.0.2"]`** — a VPN-access
whitelist so dev on macOS is reachable from other devices on the tunnel.
Removing or breaking it silently blocks hot-reload from those origins;
update it if the VPN subnet changes.
- **`prisma/seed.ts` is documentation-only.** The real data pipeline is
the sibling Python scraper (`../manga-dl/manga.py`) writing directly via
`psycopg2`. The `npm run seed` script exists for quick local dev but is
never run in production.