Compare commits

..

No commits in common. "f2ef775f7095dc2b107b576cd4053593e89dd887" and "6c750e70ce3f3737bffd6446508d380d15b34f42" have entirely different histories.

3 changed files with 201 additions and 154 deletions

163
AGENTS.md Normal file
View File

@ -0,0 +1,163 @@
# 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, 80008002, 8005, 2283, 5432, 5182051821, 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** (2535% 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

182
CLAUDE.md
View File

@ -2,181 +2,67 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 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 @AGENTS.md
> 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 ## Build & Development Commands
```bash ```bash
npm run dev # Start dev server (hot reload, Turbopack) npm run dev # Start dev server (hot reload)
npm run build # Production build npm run build # Production build
npm start # Start production server npm start # Start production server
npm run lint # ESLint (flat config, v9+) npm run lint # ESLint (flat config, v9+)
npx prisma generate # Regenerate Prisma client after schema changes 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 prisma migrate dev --name <name> # Create and apply a migration
npx tsx scripts/backfill-page-dims.ts # Probe Page.width/height via image-size against R2 npx prisma db push # Push schema changes without migration (dev only)
``` ```
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 ## Architecture
**Stack**: Next.js 16.2.1 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · Prisma · PostgreSQL 16 · Cloudflare R2 **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. **Path alias**: `@/*` maps to project root (configured in tsconfig.json).
### Reader is placeholder-based — read this before touching `PageReader.tsx` ### App Router Structure
`components/PageReader.tsx` is deliberately **not** a naive `pages.map(<img>)`. ```
Every page renders as a `<div>` placeholder with `style={{ aspectRatio: w/h }}` app/
so the document height is correct before any image bytes load. This avoids ├── page.tsx # Homepage (manga grid)
a whole class of iOS Safari scroll-anchoring / prepend-shift bugs that a ├── layout.tsx # Root layout (Geist fonts, metadata)
simple reader would hit. Key invariants: ├── 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
```
- `Page.width` / `Page.height` **must** be populated for every row. The ### Data Flow
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 - **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.
`components/Header.tsx` and `components/BottomNav.tsx` both return `null` ### Key Libraries
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 - `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` — R2 uploads (S3-compatible API)
- `prisma` / `@prisma/client` — ORM and database client
- **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 ## Deployment
- **Host**: Proxmox VM running Docker + Portainer. - **Docker**: `docker build` from root Dockerfile → container exposes port 3000, mapped to host port 3001.
- **Flow**: Push to Gitea (`gitea.04080616.xyz/yiekheng/sunnymh-manga-site`) - **Compose**: `docker-compose.yml` runs both the app and PostgreSQL 16.
`git pull` on the Docker host → rebuild → restart via Portainer. - **Flow**: Push to Gitea → `git pull` on Proxmox Docker host → rebuild → restart via Portainer.
- **Dockerfile** at repo root; container listens on 3000, mapped to host - **Reverse proxy**: aaPanel Nginx routes `manga.04080616.xyz``http://127.0.0.1:3001`.
3001. `docker-compose.yml` only brings up the app — **Postgres is - **Port 3001** (app) and **5433** (Postgres) on the host — many other ports are taken.
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 ## Environment Variables
Required in `.env` (container equivalents in `docker-compose.yml`): Required in `.env` (see docker-compose.yml for container equivalents):
- `DATABASE_URL` — PostgreSQL connection string, e.g. `postgresql://manga_user:…@localhost:5433/manga_db` - `DATABASE_URL` — PostgreSQL connection string
- `R2_ACCOUNT_ID`, `R2_ACCESS_KEY`, `R2_SECRET_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL` — Cloudflare R2 - `R2_ACCOUNT_ID`, `R2_ACCESS_KEY`, `R2_SECRET_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL` — Cloudflare R2
## Dev gotchas ## Content Policy
- **`next.config.ts` has `allowedDevOrigins: ["10.8.0.2"]`** — a VPN-access All manga content must be **public domain or free** (Comic Book Plus, Digital Comic Museum, Internet Archive). No copyrighted material.
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.

View File

@ -373,7 +373,6 @@ export function PageReader({
<div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto"> <div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto">
<Link <Link
href={`/manga/${mangaSlug}`} href={`/manga/${mangaSlug}`}
scroll={false}
className="text-foreground/80 hover:text-foreground transition-colors shrink-0" className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
aria-label="Back to manga" aria-label="Back to manga"
> >
@ -477,7 +476,6 @@ export function PageReader({
<p className="text-base font-semibold">{mangaTitle}</p> <p className="text-base font-semibold">{mangaTitle}</p>
<Link <Link
href="/" href="/"
scroll={false}
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover" className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
> >
Back to Home Back to Home