# 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. - 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`. - Image URLs (signed, short-TTL) come in batches via `/api/pages` triggered by an `IntersectionObserver` with 1200 px margin. 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. - 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. - 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}`. `readProgress` in `components/ReadingProgressButton.tsx` also accepts the **legacy bare-number format** (just a chapter number string) for backward compatibility — preserve this 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). Format is **WebP** (~25–35% smaller than JPEG). The app **never** serves R2 URLs directly — `lib/r2.ts::signUrl` / `signCoverUrls` mint presigned GETs with a 60 s TTL; `keyFromPublicUrl` reverses a public URL to its R2 key. `signCoverUrls` is the standardized pattern for list pages — homepage carousel, genre grid, search results, detail page all route through it. Don't bypass it by handing raw `manga.coverUrl` to the browser. - **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}` with signed URLs. - `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. ### 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.