sunnymh-manga-site/CLAUDE.md
yiekheng f2ef775f70 Consolidate project docs into CLAUDE.md, remove AGENTS.md
CLAUDE.md is now self-contained: absorbed the still-accurate parts of
AGENTS.md (Next.js-is-different warning, port allocation, Nginx block,
SEO notes, Meilisearch as phase-2 plan) and dropped the stale bits
(pre-width/height schema, "public domain only" content policy, old
manga.04080616.xyz domain, aspirational build order).

Also added findings surfaced by auditing CLAUDE.md against the actual
code:

- Placeholder-based reader architecture and its invariants (must have
  Page.width/height, scroll={false} on Links, history.replaceState for
  URL sync, prev/next derived from currentChapterNum).
- docker-compose.yml only brings up the app; Postgres is external.
- localStorage key format sunnymh:last-read:<slug> + legacy bare-number
  fallback that must be preserved.
- Immersive reader route: Header/BottomNav return null on /manga/[slug]/[n].
- signCoverUrls is the single source of truth for signed cover URLs.
- iOS Safari conventions: viewportFit cover, pt-safe/pb-safe, WebKit
  momentum scrolling, overflow-x:hidden.
- Dev gotchas: allowedDevOrigins VPN whitelist, prisma/seed.ts is
  docs-only, scraper is the real data pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:25:10 +08:00

183 lines
8.6 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.
- 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:<slug>` 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** (~2535%
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/<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}` 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 `<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.