diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 364642c..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,163 +0,0 @@ -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - -# Manga Website Project Requirements - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| Frontend & Backend | Next.js (App Router, TypeScript, Tailwind CSS) | -| Database | PostgreSQL 16 | -| ORM | Prisma | -| Image Storage | Cloudflare R2 | -| Containerization | Docker + Docker Compose (managed via Portainer) | -| Reverse Proxy | Nginx via aaPanel | -| Domain | `manga.04080616.xyz` | - -## Infrastructure - -- **Server**: Proxmox host, Docker VM/LXC running Portainer -- **Reverse proxy**: aaPanel Nginx routes `manga.04080616.xyz` → `http://127.0.0.1:3001` -- **SSL**: Let's Encrypt via aaPanel -- **DDNS already configured** — no Cloudflare Tunnel needed - -### Port Allocation - -| Port | Service | -|------|---------| -| 3001 | Next.js app (host) → 3000 (container) | -| 5433 | PostgreSQL (host) → 5432 (container) | - -Ports 3000, 8000–8002, 8005, 2283, 5432, 51820–51821, 9443 are already in use. - -## Deployment Workflow - -1. Develop on macOS locally -2. Push code to Gitea (`gitea.04080616.xyz/yiekheng/sunnymh-manga-site`) -3. On Proxmox Docker host: `git pull` → `docker build` → restart container -4. Portainer used for container management (GUI) - -## Image Storage (Cloudflare R2) - -- Images stored in R2 (S3-compatible), **not** on the Proxmox host -- Upload flow: backend generates presigned URL → browser uploads directly to R2 → database records image path -- Format: **WebP** (25–35% smaller than JPEG) -- Multiple resolutions: thumbnail / reading / original - -### R2 Directory Structure - -``` -/manga/{manga_id}/{chapter_id}/{page_number}.webp -/thumbnails/{manga_id}/cover.webp -``` - -## Database Schema (Prisma) - -```prisma -model Manga { - id Int @id @default(autoincrement()) - title String - description String - coverUrl String - slug String @unique - status Status @default(PUBLISHED) - chapters Chapter[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model Chapter { - id Int @id @default(autoincrement()) - mangaId Int - number Int - title String - pages Page[] - manga Manga @relation(fields: [mangaId], references: [id]) -} - -model Page { - id Int @id @default(autoincrement()) - chapterId Int - number Int - imageUrl String - chapter Chapter @relation(fields: [chapterId], references: [id]) -} - -enum Status { - PUBLISHED - DRAFT -} -``` - -## Features - -### Core (build first) -- Manga listing page with cover images -- Manga detail page (title, description, chapter list) -- Chapter reader page (page-by-page image display) -- Internal search by manga title (PostgreSQL `contains` query) - -### SEO -- Next.js SSR/SSG for all public pages -- `generateMetadata()` per page (title, description) -- Auto-generated `/sitemap.xml` via `app/sitemap.ts` -- Submit sitemap to Google Search Console - -### Search -- **Phase 1**: PostgreSQL `contains` with `insensitive` mode (sufficient for < 10k titles) -- **Phase 2** (future): Meilisearch for fuzzy/full-text search if needed - -## URL Structure - -``` -/ Homepage (manga grid) -/manga/[slug] Manga detail + chapter list -/manga/[slug]/[chapter] Chapter reader -/api/search?q= Search API endpoint -/api/manga Manga CRUD (admin) -/api/upload R2 presigned URL generation -``` - -## Environment Variables (.env) - -```env -DATABASE_URL=postgresql://manga_user:yourpassword@localhost:5433/manga_db - -R2_ACCOUNT_ID=your_cloudflare_account_id -R2_ACCESS_KEY=your_r2_access_key -R2_SECRET_KEY=your_r2_secret_key -R2_BUCKET=manga-images -R2_PUBLIC_URL=https://your-r2-public-url.r2.dev -``` - -## Nginx Reverse Proxy Config (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; -} -``` - -## Content Source - -- **Public domain / free manga only** (no copyright issues) -- Sources: Comic Book Plus, Digital Comic Museum, Internet Archive -- Initial: manual download and upload -- Future: automated scraper (Node.js + cheerio) for allowed sources - -## Recommended Build Order - -1. Set up Prisma schema + connect to PostgreSQL -2. Build manga listing and reader pages (static UI first) -3. Wire up database queries via Prisma -4. Add R2 upload + image serving -5. Add search API -6. Add SEO metadata + sitemap -7. Dockerize and deploy to Proxmox via Gitea diff --git a/CLAUDE.md b/CLAUDE.md index 8d039a7..e82f4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,67 +2,181 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -@AGENTS.md +> **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) -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 migrate dev --name # Create and apply a migration -npx prisma db push # Push schema changes without migration (dev only) +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 (configured in tsconfig.json). +**Path alias**: `@/*` maps to project root. -### App Router Structure +### Reader is placeholder-based — read this before touching `PageReader.tsx` -``` -app/ -├── page.tsx # Homepage (manga grid) -├── layout.tsx # Root layout (Geist fonts, metadata) -├── manga/[slug]/page.tsx # Manga detail + chapter list -├── manga/[slug]/[chapter]/page.tsx # Chapter reader -├── api/search/route.ts # Search endpoint (?q=) -├── api/manga/route.ts # Manga CRUD (admin) -├── api/upload/route.ts # R2 presigned URL generation -└── sitemap.ts # Auto-generated sitemap -``` +`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: -### Data Flow +- `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. -- **Database**: Prisma ORM → PostgreSQL. Models: `Manga`, `Chapter`, `Page` (see `prisma/schema.prisma`). -- **Images**: Stored in Cloudflare R2 (S3-compatible). Upload flow: backend generates presigned URL → browser uploads directly to R2 → database records the image path. -- **R2 bucket layout**: `/manga/{manga_id}/{chapter_id}/{page_number}.webp` and `/thumbnails/{manga_id}/cover.webp`. -- **Search**: PostgreSQL `contains` with `insensitive` mode (phase 1). No external search engine needed until >10k titles. +### Immersive reader route -### Key Libraries +`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. -- `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` — R2 uploads (S3-compatible API) -- `prisma` / `@prisma/client` — ORM and database client +### 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 -- **Docker**: `docker build` from root Dockerfile → container exposes port 3000, mapped to host port 3001. -- **Compose**: `docker-compose.yml` runs both the app and PostgreSQL 16. -- **Flow**: Push to Gitea → `git pull` on Proxmox Docker host → rebuild → restart via Portainer. -- **Reverse proxy**: aaPanel Nginx routes `manga.04080616.xyz` → `http://127.0.0.1:3001`. -- **Port 3001** (app) and **5433** (Postgres) on the host — many other ports are taken. +- **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` (see docker-compose.yml for container equivalents): +Required in `.env` (container equivalents in `docker-compose.yml`): -- `DATABASE_URL` — PostgreSQL connection string +- `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 -## Content Policy +## Dev gotchas -All manga content must be **public domain or free** (Comic Book Plus, Digital Comic Museum, Internet Archive). No copyrighted material. +- **`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.