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

12 KiB
Raw Permalink Blame History

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

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-listapp/page.tsx (5 min)
  • genre-manga-listapp/genre/page.tsx (5 min)
  • sitemap-manga-listapp/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-mangaapp/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.xyzhttp://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)

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.