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>
12 KiB
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.heightmust 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>isabsolute 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.tsxand passed as a prop. Later chapters' meta is lazy-fetched via/api/chapters/[id]/meta, triggered by anIntersectionObserverwith 1200 px margin when user enters the lastPREFETCH_NEXT_ATpages of a chapter. - Image URLs are not signed — served direct from the custom domain.
Fetched via
/api/pagesin fixed chunks ofIMAGE_CHUNK_SIZE = 5. Trigger runs in auseEffecton[currentPageNum, currentChapterNum, images, chapterMetas]: fetches forward chunk whencurrentPage + PREFETCH_LEAD (3) >= maxCached, backward chunk whencurrentPage - PREFETCH_LEAD <= minCached, and next chapter's first chunk whencurrentPage + 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
Mapis 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 = 3pages of the end. Only chapters>= startChapterNumberrender (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].keepset 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/nextChapterfor double-tap nav are derived fromcurrentChapterNum, not from the URL or props. window.history.scrollRestoration = "manual"is set in the resumeuseLayoutEffect. Without this, refreshing while deep in an auto- appended chapter has the browser restore ascrollYthat references a (now gone) taller document, clamping toscrollHeightand landing near the bottom.- All Links into the reader use
scroll={false}, and the double-taprouter.push(..., { scroll: false }). This is load-bearing — without it, App Router's default scroll-to-top clobbers theuseLayoutEffectthat restores the resume page. - Reading progress is persisted to
localStorageunder the keysunnymh:last-read:<slug>as JSON{chapter, page, ratio}(ratio = fractional scroll offset inside the current page).readProgressincomponents/ReadingProgressButton.tsxalso 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.Pagecarrieswidth/height.Mangahasgenreas a comma-separated string; parse withlib/genres.ts. - Images live in R2 (S3-compatible), served via the Cloudflare custom
domain in
R2_PUBLIC_URL. Format is WebP (~25–35% smaller than JPEG). URLs stored inManga.coverUrl/Page.imageUrlare already browser-ready — pass them straight through.lib/r2.ts::keyFromPublicUrlreverses a stored URL back to its R2 key (used by the backfill script).getPresignedUploadUrlstill 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>.webpmanga/<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 viaPromise.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 usesgenerateMetadata()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— hasrevalidate = 300directly (it's a dynamic segment withoutgenerateStaticParams, 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.xyzwhich is fronted by a WAF Custom Rule blocking requests whosehttp.refereris non-empty and doesn't contain04080616.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--accentinapp/globals.css. Hover:#1f7044. - Brand name: 晴天漫画 · Sunny MH.
iOS Safari conventions
viewportinapp/layout.tsxsetsviewportFit: "cover"so the app extends into the notch; any sticky/fixed chrome must use thept-safe/pb-safeutilities fromapp/globals.css(they map toenv(safe-area-inset-*)). Header and BottomNav already do.- Horizontal carousels (
components/TrendingCarousel.tsx) setWebkitOverflowScrolling: "touch"inline for momentum scrolling. Preserve this on any new swipe lane. overflow-x: hiddenonbody(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 pullon the Docker host → rebuild → restart via Portainer. - Dockerfile at repo root; container listens on 3000, mapped to host
3001.
docker-compose.ymlonly brings up the app — Postgres is external (connected viaDATABASE_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, 8000–8002, 8005, 2283, 5432, 51820–51821, 9443 — don't collide. Postgres lives on host 5433.
- After schema changes reach prod, run
scripts/backfill-page-dims.tsonce 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_dbR2_ACCOUNT_ID,R2_ACCESS_KEY,R2_SECRET_KEY,R2_BUCKET,R2_PUBLIC_URL— Cloudflare R2
Dev gotchas
next.config.tshasallowedDevOrigins: ["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.tsis documentation-only. The real data pipeline is the sibling Python scraper (../manga-dl/manga.py) writing directly viapsycopg2. Thenpm run seedscript exists for quick local dev but is never run in production.