# 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(
)`.
Every page renders as a `
` 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 `
![]()
` 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:
` 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** (~25–35% 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//chapters//.webp`
- `manga//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 `
` 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 `
`).
- 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, 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.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.