Remove subtree sub-projects in prep for submodule conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6
manga-dl/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
.env
|
||||
__pycache__/
|
||||
manga-content/
|
||||
.browser-data/
|
||||
cookies.txt
|
||||
.DS_Store
|
||||
@ -1,160 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Single-file interactive toolkit (`manga.py`) that downloads manga from m.happymh.com, stores images in Cloudflare R2 as WebP, and writes metadata to PostgreSQL. Runs as an arrow-key TUI backed by a persistent Chrome session.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt # playwright, boto3, psycopg2-binary, Pillow, python-dotenv, simple-term-menu
|
||||
python manga.py # launch the TUI (no CLI args)
|
||||
```
|
||||
|
||||
No tests, no lint config, no build step. Requires Google Chrome or Chromium installed. The script auto-detects from `CHROME_CANDIDATES` (macOS/Linux/Windows paths). R2 and DB credentials load lazily — see `.env` section below.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Anti-bot: real Chrome + CDP + persistent profile
|
||||
|
||||
Cloudflare fingerprints both the TLS handshake and the browser process. The anti-detection chain matters — changing any link breaks downloads:
|
||||
|
||||
1. **`subprocess.Popen(CHROME_PATH, ...)`** launches the user's real Chrome binary, not Playwright's Chromium. This gives a genuine TLS fingerprint.
|
||||
2. **`connect_over_cdp`** attaches Playwright to Chrome via DevTools Protocol. Playwright never *launches* Chrome — only sends CDP commands to a separately-running process.
|
||||
3. **Persistent `--user-data-dir=.browser-data`** preserves `cf_clearance` cookies between runs. After the user solves Cloudflare once (Setup menu), subsequent runs skip the challenge.
|
||||
4. **Single session (`_session_singleton`)** — Chrome is lazy-started on first operation and reused across all commands in one `python manga.py` run. Closed only on Quit. `with_browser(func)` catches "target closed" / "disconnected" errors, resets the singleton, and retries once.
|
||||
5. **`hide_chrome()`** runs `osascript -e 'tell application "System Events" to set visible of process "Google Chrome" to false'` after launch so the window doesn't steal focus. No-op on non-macOS.
|
||||
|
||||
**Do not switch to headless mode.** Tried — Cloudflare blocks it because the fingerprint differs from real Chrome. **Do not parallelize chapter work across threads** with Playwright's sync API — each thread would need its own event loop and crashes with "no running event loop".
|
||||
|
||||
### Cloudflare handling
|
||||
|
||||
`wait_for_cloudflare(session)` polls `page.title()` and `page.url` for the "Just a moment" / `/challenge` markers. Recovery is manual: the user is shown the browser window and solves CAPTCHA. The Setup menu (`cmd_setup`) is the dedicated flow for this. During sync/check-missing, if the reading API returns 403, the script prints "CF blocked — run Setup" and stops.
|
||||
|
||||
### Navigation: `page.goto` vs JS assignment
|
||||
|
||||
- **Manga listing page** (`/manga/<slug>`) uses `page.goto(..., wait_until="commit")`. Works because Cloudflare on this route is lenient.
|
||||
- **Reader page** (`/mangaread/<slug>/<id>`) uses `page.evaluate("window.location.href = '...'")` — bypasses CF's detection of CDP `Page.navigate` for the stricter reader route.
|
||||
|
||||
### Image pipeline (happymh)
|
||||
|
||||
Per chapter (in `_try_get_chapter_images`):
|
||||
1. Register a response listener that matches `/apis/manga/reading` **AND** `cid=<chapter_id>` in the URL **AND** validates `data.id` in the response body matches. Drops pre-fetched neighbouring chapters.
|
||||
2. Navigate the reader URL via `window.location.href` assignment.
|
||||
3. DOM-count sanity check: `[class*="imgContainer"]` total minus `[class*="imgNext"]` gives the current chapter's actual page count. Trim captured list if it includes next-chapter previews.
|
||||
4. `fetch_image_bytes(page, img)` runs `fetch(url)` via `page.evaluate` inside a `page.expect_response(...)` block. The body is read via CDP (`response.body()`) — zero base64 overhead. Fallback strips the `?q=50` query if the original URL fails.
|
||||
5. `fetch_all_pages(page, images, max_attempts=3)` retries each failed page up to 3 times with 2s backoff between rounds. Returns `{page_num: bytes}` for successful fetches.
|
||||
|
||||
### R2 + DB write ordering
|
||||
|
||||
**Page rows are inserted into the DB only after the R2 upload succeeds.** This prevents orphan DB records pointing to missing R2 objects. Every `INSERT INTO "Page"` includes `width` and `height` read from the JPEG/WebP bytes via PIL (`Image.open(...).width`).
|
||||
|
||||
### Storage layouts
|
||||
|
||||
```
|
||||
# Local (download command)
|
||||
manga-content/<slug>/detail.json # title, author, genres, description, mg-cover URL
|
||||
manga-content/<slug>/cover.jpg # captured from page load traffic
|
||||
manga-content/<slug>/<N> <chapter>/<page>.jpg
|
||||
|
||||
# R2 (upload / sync)
|
||||
manga/<slug>/cover.webp
|
||||
manga/<slug>/chapters/<N>/<page>.webp
|
||||
```
|
||||
|
||||
Chapter order is the API's ascending index (1-based). Chapter names can repeat (announcements, extras) so the DB `Chapter.number` column uses this index, not parsed chapter titles.
|
||||
|
||||
### Menu actions
|
||||
|
||||
- **Setup** (`cmd_setup`) → brings Chrome to front, user solves CF, validates `cf_clearance` cookie.
|
||||
- **Download** (`cmd_download`) → picks URL from `manga.json`, optional chapter multi-select; saves JPGs locally.
|
||||
- **Upload** (`cmd_upload` → `upload_manga_to_r2`) → converts local JPGs → WebP, uploads to R2, writes DB rows.
|
||||
- **Sync** (`cmd_sync`) → combined download+upload via RAM (no local files), refreshes `Manga` row metadata, only inserts chapters missing from DB.
|
||||
- **R2 / DB management** submenu (`tui_r2_manage`):
|
||||
- **Status** — single-pass R2 object count grouped by slug, plus DB row counts
|
||||
- **Edit manga info** (`tui_edit_manga`) — title/description/genre/status/coverUrl
|
||||
- **Delete specific manga** — R2 prefix + cascade DB delete
|
||||
- **Delete specific chapter** (`tui_delete_chapter`) — multi-select or "All chapters"
|
||||
- **Check missing pages** (`tui_check_missing_pages`) — for each chapter: if site page count ≠ R2 count, re-upload **inline** (browser still on that reader page); if counts match but DB `width`/`height` are NULL or 0, fix by reading WebP bytes from R2 (no re-upload)
|
||||
- **Clear ALL (R2 + DB)**
|
||||
- **Recompress manga** (`r2_recompress`) — re-encodes every WebP under `manga/<slug>/` at quality=65, overwrites in place
|
||||
|
||||
### WebP encoding
|
||||
|
||||
`_to_webp_bytes(img, quality=WEBP_QUALITY=75, method=6)` — method=6 is the slowest/smallest preset. Covers use quality 80 via `make_cover` (crops to 400×560 aspect, then resizes). Resize-during-encode was explicitly removed — page originals' dimensions are preserved.
|
||||
|
||||
### ESC to stop
|
||||
|
||||
`EscListener` puts stdin in cbreak mode (POSIX `termios`+`tty`) and runs a daemon thread listening for `\x1b`. Download/Upload/Sync check `esc.stop.is_set()` between chapters and cleanly exit. Restores terminal mode on `__exit__`. No-op on Windows (no termios) and when stdin isn't a TTY.
|
||||
|
||||
### Lazy config loading
|
||||
|
||||
`_ensure_config()` is called at the start of each R2/DB helper. It reads required env vars and constructs the boto3 client on first use. If env vars are missing, it prints the missing list and `sys.exit(1)` — no KeyError traceback on import. `s3`, `BUCKET`, `PUBLIC_URL`, `DATABASE_URL` are module globals set by that call.
|
||||
|
||||
## Environment variables (.env)
|
||||
|
||||
```
|
||||
R2_ACCOUNT_ID= # cloudflare account id
|
||||
R2_ACCESS_KEY=
|
||||
R2_SECRET_KEY=
|
||||
R2_BUCKET=
|
||||
R2_PUBLIC_URL= # e.g. https://pub-xxx.r2.dev (trailing slash stripped)
|
||||
DATABASE_URL= # postgresql://user:pass@host:port/dbname
|
||||
```
|
||||
|
||||
Missing any of these produces a friendly error on first R2/DB operation, not on import.
|
||||
|
||||
## DB schema expectations
|
||||
|
||||
The script reads/writes but does **not** create tables. Create them externally:
|
||||
|
||||
```sql
|
||||
CREATE TABLE "Manga" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
"coverUrl" TEXT,
|
||||
genre TEXT, -- comma-joined list of all genres
|
||||
status TEXT NOT NULL, -- PUBLISHED | DRAFT | HIDDEN
|
||||
"createdAt" TIMESTAMPTZ NOT NULL,
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Chapter" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"mangaId" INTEGER NOT NULL REFERENCES "Manga"(id),
|
||||
number INTEGER NOT NULL, -- 1-based index from the API order
|
||||
title TEXT NOT NULL,
|
||||
UNIQUE ("mangaId", number)
|
||||
);
|
||||
|
||||
CREATE TABLE "Page" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"chapterId" INTEGER NOT NULL REFERENCES "Chapter"(id),
|
||||
number INTEGER NOT NULL, -- 1-based page number
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
UNIQUE ("chapterId", number)
|
||||
);
|
||||
```
|
||||
|
||||
Column identifiers are camelCase with double quotes — matches Prisma default naming.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Task | Location |
|
||||
|---|---|
|
||||
| Add a new site | Extract happymh-specific bits: `fetch_chapters_via_api`, `fetch_chapters_from_dom`, `fetch_metadata`, `_try_get_chapter_images`, the `/mcover/` cover capture in `load_manga_page`, the reader URL shape. Keep Chrome/R2/DB/TUI as common. |
|
||||
| New menu item | Add to `show_menu` list in `main` and dispatch in the `if idx == N:` ladder. For R2/DB ops, add to `tui_r2_manage`. |
|
||||
| Tweak CF detection | `wait_for_cloudflare` / `_wait_for_cf_on_page` — edit the title/URL heuristics carefully, both ops check the same signals. |
|
||||
| Change image quality | `WEBP_QUALITY` at top of file; cover quality is hard-coded 80 in `make_cover`. |
|
||||
| Add a new Page-table column | Update all three `INSERT INTO "Page"` sites (`upload_manga_to_r2`, `cmd_sync`, `tui_check_missing_pages` re-upload branch) and the `SELECT ... FROM "Page"` in the dim-check query. |
|
||||
| Change parallelism | `UPLOAD_WORKERS` for R2 uploads; do **not** introduce chapter-level threading (sync Playwright breaks). |
|
||||
|
||||
## Future: multi-site support
|
||||
|
||||
Current code is happymh-specific (selectors, API paths, URL patterns). To generalise, a site module would implement `fetch_chapters(page, slug)`, `get_chapter_images(page, slug, chapter_id)`, and `fetch_metadata(page)`, keeping the Chrome/R2/DB/TUI layer common.
|
||||
@ -1,6 +0,0 @@
|
||||
[
|
||||
"https://m.happymh.com/manga/fangkainagenvwu",
|
||||
"https://m.happymh.com/manga/jueduijiangan",
|
||||
"https://m.happymh.com/manga/xingjiandashi",
|
||||
"https://m.happymh.com/manga/moutianchengweimoshen"
|
||||
]
|
||||
2062
manga-dl/manga.py
@ -1,6 +0,0 @@
|
||||
playwright
|
||||
boto3
|
||||
psycopg2-binary
|
||||
Pillow
|
||||
python-dotenv
|
||||
simple-term-menu
|
||||
49
manga-site/.gitignore
vendored
@ -1,49 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# reference files
|
||||
/reference/
|
||||
|
||||
# claude skills/agents
|
||||
/.agents/
|
||||
/.claude/
|
||||
skills-lock.json
|
||||
@ -1,182 +0,0 @@
|
||||
# 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** (~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/<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, 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.
|
||||
@ -1,9 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
@ -1,36 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@ -1,19 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
type Params = { params: Promise<{ chapterId: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { chapterId: raw } = await params;
|
||||
const chapterId = parseInt(raw, 10);
|
||||
if (isNaN(chapterId)) {
|
||||
return Response.json({ error: "Invalid chapterId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pages = await prisma.page.findMany({
|
||||
where: { chapterId },
|
||||
orderBy: { number: "asc" },
|
||||
select: { number: true, width: true, height: true },
|
||||
});
|
||||
|
||||
return Response.json(pages);
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const manga = await prisma.manga.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
_count: { select: { chapters: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const signedManga = await signCoverUrls(manga);
|
||||
|
||||
return Response.json(signedManga);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
const { title, description, coverUrl, slug, status } = body;
|
||||
|
||||
if (!title || !description || !coverUrl || !slug) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields: title, description, coverUrl, slug" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const manga = await prisma.manga.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
coverUrl,
|
||||
slug,
|
||||
status: status || "PUBLISHED",
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(manga, { status: 201 });
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signUrl } from "@/lib/r2";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chapterId = parseInt(searchParams.get("chapterId") ?? "", 10);
|
||||
const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0);
|
||||
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1), 20);
|
||||
|
||||
if (isNaN(chapterId)) {
|
||||
return Response.json({ error: "Missing chapterId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pages = await prisma.page.findMany({
|
||||
where: { chapterId },
|
||||
orderBy: { number: "asc" },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
select: { number: true, imageUrl: true },
|
||||
});
|
||||
|
||||
const signedPages = await Promise.all(
|
||||
pages.map(async (p) => ({
|
||||
number: p.number,
|
||||
imageUrl: await signUrl(p.imageUrl),
|
||||
}))
|
||||
);
|
||||
|
||||
return Response.json(signedPages);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q")?.trim();
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
return Response.json([]);
|
||||
}
|
||||
|
||||
const results = await prisma.manga.findMany({
|
||||
where: {
|
||||
status: "PUBLISHED",
|
||||
title: { contains: q, mode: "insensitive" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
coverUrl: true,
|
||||
},
|
||||
take: 8,
|
||||
orderBy: { title: "asc" },
|
||||
});
|
||||
|
||||
return Response.json(await signCoverUrls(results));
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getPresignedUploadUrl, getPublicUrl } from "@/lib/r2";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { key } = body;
|
||||
|
||||
if (!key || typeof key !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing required field: key (e.g. manga/1/1/1.webp)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const uploadUrl = await getPresignedUploadUrl(key);
|
||||
const publicUrl = getPublicUrl(key);
|
||||
|
||||
return Response.json({ uploadUrl, publicUrl });
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="16" fill="#268a52"/>
|
||||
<g transform="translate(50,50)">
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="8" fill="#268a52" stroke="#fff" stroke-width="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1016 B |
@ -1,28 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { collectGenres } from "@/lib/genres";
|
||||
import { GenreTabs } from "@/components/GenreTabs";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Genres",
|
||||
};
|
||||
|
||||
export default async function GenrePage() {
|
||||
const manga = await prisma.manga.findMany({
|
||||
where: { status: "PUBLISHED" },
|
||||
orderBy: { title: "asc" },
|
||||
include: { _count: { select: { chapters: true } } },
|
||||
});
|
||||
|
||||
const signedManga = await signCoverUrls(manga);
|
||||
const genres = collectGenres(signedManga);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-5">
|
||||
<GenreTabs manga={signedManga} genres={genres} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #202124;
|
||||
--surface: #f5f5f5;
|
||||
--surface-hover: #e8e8e8;
|
||||
--border: #e0e0e0;
|
||||
--accent: #268a52;
|
||||
--accent-hover: #1f7044;
|
||||
--muted: #888888;
|
||||
--card: #fafafa;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-border: var(--border);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-muted: var(--muted);
|
||||
--color-card: var(--card);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
/* Smooth scroll for the whole page */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal carousels */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Safe area insets for mobile notches */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(50,50)">
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="10" fill="#268a52" stroke="#fff" stroke-width="2.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 945 B |
@ -1,57 +0,0 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { Header } from "@/components/Header";
|
||||
import { BottomNav } from "@/components/BottomNav";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "晴天漫画 · Sunny MH",
|
||||
template: "%s | 晴天漫画",
|
||||
},
|
||||
description: "晴天漫画 — 精选好漫画,手机阅读更流畅。",
|
||||
metadataBase: new URL("https://www.04080616.xyz"),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "晴天漫画",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "overlays-content",
|
||||
colorScheme: "light",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#ffffff" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} antialiased`}
|
||||
data-scroll-behavior="smooth"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-dvh flex flex-col bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="flex-1 bg-background">{children}</main>
|
||||
<BottomNav />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className="h-7 w-40 bg-surface rounded-lg animate-pulse mb-4" />
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="aspect-[3/4] rounded-xl bg-surface animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
export default function ChapterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// The chapter reader has its own full-screen layout
|
||||
// Override parent padding/nav by rendering children directly
|
||||
return <>{children}</>;
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageReader } from "@/components/PageReader";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string; chapter: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug, chapter } = await params;
|
||||
const chapterNum = parseInt(chapter, 10);
|
||||
if (isNaN(chapterNum)) return { title: "Not Found" };
|
||||
|
||||
const manga = await prisma.manga.findUnique({ where: { slug } });
|
||||
if (!manga) return { title: "Not Found" };
|
||||
|
||||
return {
|
||||
title: `${manga.title} — Ch. ${chapterNum}`,
|
||||
description: `Read chapter ${chapterNum} of ${manga.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ChapterReaderPage({ params }: Props) {
|
||||
const { slug, chapter } = await params;
|
||||
const chapterNum = parseInt(chapter, 10);
|
||||
if (isNaN(chapterNum)) notFound();
|
||||
|
||||
const [manga, initialChapterMeta] = await Promise.all([
|
||||
prisma.manga.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
chapters: {
|
||||
orderBy: { number: "asc" },
|
||||
include: {
|
||||
_count: { select: { pages: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.page.findMany({
|
||||
where: { chapter: { number: chapterNum, manga: { slug } } },
|
||||
orderBy: { number: "asc" },
|
||||
select: { number: true, width: true, height: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!manga) notFound();
|
||||
|
||||
const currentChapter = manga.chapters.find((c) => c.number === chapterNum);
|
||||
if (!currentChapter) notFound();
|
||||
|
||||
const allChapters = manga.chapters.map((c) => ({
|
||||
id: c.id,
|
||||
number: c.number,
|
||||
title: c.title,
|
||||
totalPages: c._count.pages,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PageReader
|
||||
mangaSlug={manga.slug}
|
||||
mangaTitle={manga.title}
|
||||
startChapterNumber={currentChapter.number}
|
||||
chapters={allChapters}
|
||||
initialChapterMeta={initialChapterMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signUrl } from "@/lib/r2";
|
||||
import { parseGenres } from "@/lib/genres";
|
||||
import { ChapterList } from "@/components/ChapterList";
|
||||
import { ReadingProgressButton } from "@/components/ReadingProgressButton";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const manga = await prisma.manga.findUnique({ where: { slug } });
|
||||
if (!manga) return { title: "Not Found" };
|
||||
return {
|
||||
title: manga.title,
|
||||
description: manga.description,
|
||||
openGraph: {
|
||||
title: manga.title,
|
||||
description: manga.description,
|
||||
images: [manga.coverUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MangaDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const manga = await prisma.manga.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
chapters: {
|
||||
orderBy: { number: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!manga) notFound();
|
||||
|
||||
const signedCoverUrl = await signUrl(manga.coverUrl);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||
{/* Hero section */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="w-28 sm:w-36 shrink-0">
|
||||
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||
<img
|
||||
src={signedCoverUrl}
|
||||
alt={manga.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1">
|
||||
<h1 className="text-xl sm:text-2xl font-bold leading-tight mb-2">
|
||||
{manga.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{parseGenres(manga.genre).map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="px-2 py-0.5 text-[11px] font-semibold bg-accent/20 text-accent rounded-full"
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
<span className="px-2 py-0.5 text-[11px] font-semibold bg-surface text-muted rounded-full border border-border">
|
||||
{manga.status}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{manga.chapters.length} chapter
|
||||
{manga.chapters.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted leading-relaxed line-clamp-4 sm:line-clamp-none">
|
||||
{manga.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manga.chapters.length > 0 && (
|
||||
<ReadingProgressButton
|
||||
mangaSlug={manga.slug}
|
||||
chapters={manga.chapters.map((c) => ({
|
||||
number: c.number,
|
||||
title: c.title,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-3">Chapters</h2>
|
||||
<ChapterList chapters={manga.chapters} mangaSlug={manga.slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4 text-center">
|
||||
<div className="text-6xl font-bold text-accent mb-4">404</div>
|
||||
<h1 className="text-xl font-semibold mb-2">Page not found</h1>
|
||||
<p className="text-muted mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { collectGenres } from "@/lib/genres";
|
||||
import { TrendingCarousel } from "@/components/TrendingCarousel";
|
||||
import { GenreTabs } from "@/components/GenreTabs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const manga = await prisma.manga.findMany({
|
||||
where: { status: "PUBLISHED" },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { _count: { select: { chapters: true } } },
|
||||
});
|
||||
|
||||
const signedManga = await signCoverUrls(manga);
|
||||
|
||||
// Top 10 for trending
|
||||
const trending = signedManga.slice(0, 10);
|
||||
|
||||
const genres = collectGenres(signedManga);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
|
||||
{/* Trending section — Webtoon-style ranked carousel */}
|
||||
<TrendingCarousel manga={trending} />
|
||||
|
||||
{/* Genre browsing section — horizontal tabs + filtered grid */}
|
||||
<GenreTabs manga={signedManga} genres={genres} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { MangaGrid } from "@/components/MangaGrid";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Search",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
};
|
||||
|
||||
export default async function SearchPage({ searchParams }: Props) {
|
||||
const { q } = await searchParams;
|
||||
|
||||
const manga = q
|
||||
? await prisma.manga.findMany({
|
||||
where: {
|
||||
status: "PUBLISHED",
|
||||
title: { contains: q, mode: "insensitive" },
|
||||
},
|
||||
orderBy: { title: "asc" },
|
||||
include: { _count: { select: { chapters: true } } },
|
||||
})
|
||||
: [];
|
||||
|
||||
const signedManga = await signCoverUrls(manga);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<h1 className="text-xl font-bold mb-4">
|
||||
{q ? `Results for "${q}"` : "Search"}
|
||||
</h1>
|
||||
{q ? (
|
||||
<MangaGrid manga={signedManga} />
|
||||
) : (
|
||||
<p className="text-muted text-center py-12">
|
||||
Use the search bar above to find manga
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const manga = await prisma.manga.findMany({
|
||||
where: { status: "PUBLISHED" },
|
||||
select: { slug: true, updatedAt: true },
|
||||
});
|
||||
|
||||
const mangaEntries: MetadataRoute.Sitemap = manga.map((m) => ({
|
||||
url: `https://www.04080616.xyz/manga/${m.slug}`,
|
||||
lastModified: m.updatedAt,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
url: "https://www.04080616.xyz",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
},
|
||||
...mangaEntries,
|
||||
];
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Home",
|
||||
icon: (active: boolean) => (
|
||||
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth={active ? 0 : 2} className="w-6 h-6">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
{!active && <polyline points="9 22 9 12 15 12 15 22" />}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/genre",
|
||||
label: "Genres",
|
||||
icon: (active: boolean) => (
|
||||
<svg viewBox="0 0 24 24" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth={active ? 0 : 2} className="w-6 h-6">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: (active: boolean) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} className="w-6 h-6">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Hide bottom nav on chapter reader for immersive reading
|
||||
if (pathname.match(/^\/manga\/[^/]+\/\d+$/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sticky bottom-0 z-50 bg-background text-foreground backdrop-blur-xl shadow-[0_-1px_3px_rgba(0,0,0,0.08)] sm:hidden pb-safe">
|
||||
<div className="flex items-center justify-around h-14">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center gap-0.5 px-5 py-1.5 transition-colors ${
|
||||
isActive ? "text-accent" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{item.icon(isActive)}
|
||||
<span className="text-[10px] font-semibold">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Chapter = {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function ChapterList({
|
||||
chapters,
|
||||
mangaSlug,
|
||||
}: {
|
||||
chapters: Chapter[];
|
||||
mangaSlug: string;
|
||||
}) {
|
||||
if (chapters.length === 0) {
|
||||
return (
|
||||
<p className="text-muted text-center py-8">No chapters available yet</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{chapters.map((ch) => (
|
||||
<Link
|
||||
key={ch.id}
|
||||
href={`/manga/${mangaSlug}/${ch.number}`}
|
||||
scroll={false}
|
||||
className="flex items-center justify-between px-4 py-3 bg-surface rounded-xl hover:bg-surface-hover active:scale-[0.98] transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-accent font-bold text-sm tabular-nums shrink-0">
|
||||
#{ch.number}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{ch.title}</span>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className="w-4 h-4 text-muted shrink-0"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseGenres } from "@/lib/genres";
|
||||
|
||||
type MangaItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
genre: string;
|
||||
_count?: { chapters: number };
|
||||
};
|
||||
|
||||
export function GenreTabs({
|
||||
manga,
|
||||
genres,
|
||||
}: {
|
||||
manga: MangaItem[];
|
||||
genres: string[];
|
||||
}) {
|
||||
const [activeGenre, setActiveGenre] = useState("All");
|
||||
const allGenres = ["All", ...genres];
|
||||
|
||||
const filtered =
|
||||
activeGenre === "All"
|
||||
? manga
|
||||
: manga.filter((m) => parseGenres(m.genre).includes(activeGenre));
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className="w-5 h-5 text-accent"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-bold">Browse by Genre</h2>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scrollable genre tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-4">
|
||||
{allGenres.map((genre) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => setActiveGenre(genre)}
|
||||
className={`shrink-0 px-4 py-2 text-sm font-semibold rounded-full border transition-all ${
|
||||
activeGenre === genre
|
||||
? "bg-accent text-white border-accent"
|
||||
: "bg-surface text-muted border-border hover:text-foreground hover:border-muted"
|
||||
}`}
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filtered manga grid */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
|
||||
{filtered.map((m) => (
|
||||
<Link key={m.slug} href={`/manga/${m.slug}`} className="group block relative">
|
||||
{m._count && m._count.chapters > 0 && (
|
||||
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
|
||||
{m._count.chapters}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||
<img
|
||||
src={m.coverUrl}
|
||||
alt={m.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2.5">
|
||||
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
|
||||
{m.title}
|
||||
</h3>
|
||||
<p className="text-[10px] text-white/50 mt-0.5 truncate">
|
||||
{parseGenres(m.genre).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-center py-12 text-sm">
|
||||
No manga in this genre yet
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/genre", label: "Genres" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Hide header on chapter reader for immersive reading
|
||||
if (pathname.match(/^\/manga\/[^/]+\/\d+$/)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 bg-background text-foreground backdrop-blur-xl shadow-sm pt-safe">
|
||||
{/* Top row: logo + search */}
|
||||
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2.5 shrink-0">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="SunnyMH"
|
||||
className="w-8 h-8 rounded-lg"
|
||||
/>
|
||||
<span className="text-lg font-extrabold tracking-tight">
|
||||
SunnyMH
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-4">
|
||||
{navLinks.map((link) => {
|
||||
const isActive =
|
||||
link.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-muted hover:text-foreground hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
<SearchBar />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type MangaCardProps = {
|
||||
slug: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
chapterCount?: number;
|
||||
};
|
||||
|
||||
export function MangaCard({
|
||||
slug,
|
||||
title,
|
||||
coverUrl,
|
||||
chapterCount,
|
||||
}: MangaCardProps) {
|
||||
return (
|
||||
<Link href={`/manga/${slug}`} className="group block relative">
|
||||
{chapterCount !== undefined && (
|
||||
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
|
||||
{chapterCount}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<h3 className="text-sm font-semibold text-white leading-tight line-clamp-2">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { MangaCard } from "./MangaCard";
|
||||
|
||||
type Manga = {
|
||||
slug: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
_count?: { chapters: number };
|
||||
};
|
||||
|
||||
export function MangaGrid({ manga }: { manga: Manga[] }) {
|
||||
if (manga.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
className="w-16 h-16 text-muted/40 mb-4"
|
||||
>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
|
||||
</svg>
|
||||
<p className="text-muted text-lg font-medium">No manga yet</p>
|
||||
<p className="text-muted/60 text-sm mt-1">
|
||||
Check back soon for new titles
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||
{manga.map((m) => (
|
||||
<MangaCard
|
||||
key={m.slug}
|
||||
slug={m.slug}
|
||||
title={m.title}
|
||||
coverUrl={m.coverUrl}
|
||||
chapterCount={m._count?.chapters}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,560 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
readProgress,
|
||||
writeProgress,
|
||||
} from "@/components/ReadingProgressButton";
|
||||
|
||||
type ChapterMeta = {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type PageMeta = { number: number; width: number; height: number };
|
||||
|
||||
type PageReaderProps = {
|
||||
mangaSlug: string;
|
||||
mangaTitle: string;
|
||||
startChapterNumber: number;
|
||||
chapters: ChapterMeta[];
|
||||
initialChapterMeta: PageMeta[];
|
||||
};
|
||||
|
||||
const PREFETCH_NEXT_AT = 3;
|
||||
const IMAGE_BATCH_RADIUS = 3;
|
||||
const DOUBLE_TAP_MS = 280;
|
||||
|
||||
const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`;
|
||||
|
||||
type IntersectingPage = {
|
||||
chNum: number;
|
||||
pNum: number;
|
||||
el: HTMLDivElement;
|
||||
};
|
||||
|
||||
export function PageReader({
|
||||
mangaSlug,
|
||||
mangaTitle,
|
||||
startChapterNumber,
|
||||
chapters,
|
||||
initialChapterMeta,
|
||||
}: PageReaderProps) {
|
||||
const [showUI, setShowUI] = useState(true);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [chapterMetas, setChapterMetas] = useState<Record<number, PageMeta[]>>({
|
||||
[startChapterNumber]: initialChapterMeta,
|
||||
});
|
||||
const [images, setImages] = useState<Record<string, string>>({});
|
||||
const [currentChapterNum, setCurrentChapterNum] =
|
||||
useState(startChapterNumber);
|
||||
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
||||
if (typeof window === "undefined") return 1;
|
||||
const p = readProgress(mangaSlug);
|
||||
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
||||
return 1;
|
||||
});
|
||||
|
||||
// Observer stays stable across state updates.
|
||||
const imagesRef = useRef(images);
|
||||
const chapterMetasRef = useRef(chapterMetas);
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
useEffect(() => {
|
||||
chapterMetasRef.current = chapterMetas;
|
||||
}, [chapterMetas]);
|
||||
|
||||
const metaInflightRef = useRef<Set<number>>(new Set());
|
||||
const imagesInflightRef = useRef<Set<string>>(new Set());
|
||||
const pageElRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const hiddenByScrollRef = useRef(false);
|
||||
const drawerScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const drawerActiveRef = useRef<HTMLAnchorElement | null>(null);
|
||||
// Pages currently inside the observer's viewport margin. The scroll tick
|
||||
// walks this small set instead of every loaded page.
|
||||
const intersectingPagesRef = useRef<Map<string, IntersectingPage>>(new Map());
|
||||
|
||||
const loadedChapterNumbers = useMemo(() => {
|
||||
return Object.keys(chapterMetas)
|
||||
.map(Number)
|
||||
.filter((n) => n >= startChapterNumber)
|
||||
.sort((a, b) => a - b);
|
||||
}, [chapterMetas, startChapterNumber]);
|
||||
|
||||
const chapterByNumber = useMemo(() => {
|
||||
const m = new Map<number, ChapterMeta>();
|
||||
for (const c of chapters) m.set(c.number, c);
|
||||
return m;
|
||||
}, [chapters]);
|
||||
|
||||
const fetchImagesAround = useCallback(
|
||||
async (chapterNum: number, pageNum: number) => {
|
||||
const meta = chapterMetasRef.current[chapterNum];
|
||||
const chapter = chapterByNumber.get(chapterNum);
|
||||
if (!meta || !chapter) return;
|
||||
const start = Math.max(1, pageNum - IMAGE_BATCH_RADIUS);
|
||||
const end = Math.min(meta.length, pageNum + IMAGE_BATCH_RADIUS);
|
||||
const toFetch: number[] = [];
|
||||
for (let p = start; p <= end; p++) {
|
||||
const k = pageKey(chapterNum, p);
|
||||
if (imagesRef.current[k] || imagesInflightRef.current.has(k)) continue;
|
||||
imagesInflightRef.current.add(k);
|
||||
toFetch.push(p);
|
||||
}
|
||||
if (toFetch.length === 0) return;
|
||||
const minP = toFetch[0];
|
||||
const maxP = toFetch[toFetch.length - 1];
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${
|
||||
maxP - minP + 1
|
||||
}`
|
||||
);
|
||||
const batch: { number: number; imageUrl: string }[] = await res.json();
|
||||
setImages((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of batch) {
|
||||
next[pageKey(chapterNum, item.number)] = item.imageUrl;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// observer will re-trigger on next intersection
|
||||
} finally {
|
||||
for (const p of toFetch)
|
||||
imagesInflightRef.current.delete(pageKey(chapterNum, p));
|
||||
}
|
||||
},
|
||||
[chapters]
|
||||
);
|
||||
|
||||
const prefetchNextChapterMeta = useCallback(
|
||||
async (currentChapterNumArg: number) => {
|
||||
const idx = chapters.findIndex((c) => c.number === currentChapterNumArg);
|
||||
if (idx < 0 || idx >= chapters.length - 1) return;
|
||||
const next = chapters[idx + 1];
|
||||
if (chapterMetasRef.current[next.number]) return;
|
||||
if (metaInflightRef.current.has(next.number)) return;
|
||||
metaInflightRef.current.add(next.number);
|
||||
try {
|
||||
const res = await fetch(`/api/chapters/${next.id}/meta`);
|
||||
const meta: PageMeta[] = await res.json();
|
||||
setChapterMetas((prev) => ({ ...prev, [next.number]: meta }));
|
||||
} catch {
|
||||
// will retry next observer fire
|
||||
} finally {
|
||||
metaInflightRef.current.delete(next.number);
|
||||
}
|
||||
},
|
||||
[chapters]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const el = e.target as HTMLDivElement;
|
||||
const chNum = Number(el.dataset.chapter);
|
||||
const pNum = Number(el.dataset.page);
|
||||
if (!chNum || !pNum) continue;
|
||||
const key = pageKey(chNum, pNum);
|
||||
if (e.isIntersecting) {
|
||||
intersectingPagesRef.current.set(key, { chNum, pNum, el });
|
||||
fetchImagesAround(chNum, pNum);
|
||||
const chapter = chapterByNumber.get(chNum);
|
||||
if (chapter && pNum >= chapter.totalPages - PREFETCH_NEXT_AT) {
|
||||
prefetchNextChapterMeta(chNum);
|
||||
}
|
||||
} else {
|
||||
intersectingPagesRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "1200px" }
|
||||
);
|
||||
for (const el of pageElRef.current.values()) {
|
||||
observerRef.current.observe(el);
|
||||
}
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]);
|
||||
|
||||
const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => {
|
||||
const observer = observerRef.current;
|
||||
const prev = pageElRef.current.get(key);
|
||||
if (prev && observer) observer.unobserve(prev);
|
||||
if (el) {
|
||||
pageElRef.current.set(key, el);
|
||||
if (observer) observer.observe(el);
|
||||
} else {
|
||||
pageElRef.current.delete(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync scroll + rAF re-scroll: defends against browser scroll-restoration
|
||||
// on hard reload (the sync pass handles soft nav where Link scroll={false}).
|
||||
const resumeDoneRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (resumeDoneRef.current) return;
|
||||
resumeDoneRef.current = true;
|
||||
const p = readProgress(mangaSlug);
|
||||
if (!p || p.chapter !== startChapterNumber || p.page <= 1) return;
|
||||
const scrollToResume = () => {
|
||||
const el = pageElRef.current.get(pageKey(startChapterNumber, p.page));
|
||||
if (!el) return;
|
||||
window.scrollTo({
|
||||
top: el.offsetTop,
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
};
|
||||
scrollToResume();
|
||||
requestAnimationFrame(scrollToResume);
|
||||
}, [mangaSlug, startChapterNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId = 0;
|
||||
const tick = () => {
|
||||
rafId = 0;
|
||||
const y = window.scrollY;
|
||||
if (!hiddenByScrollRef.current && y > 50) {
|
||||
hiddenByScrollRef.current = true;
|
||||
setShowUI(false);
|
||||
}
|
||||
// Walk only the pages currently inside the 1200px viewport margin
|
||||
// (maintained by the observer) and pick the one with the greatest
|
||||
// offsetTop still above y+80 — that's the topmost visible page.
|
||||
let bestCh = currentChapterNum;
|
||||
let bestPg = currentPageNum;
|
||||
let bestTop = -1;
|
||||
for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) {
|
||||
const top = el.offsetTop;
|
||||
if (top <= y + 80 && top > bestTop) {
|
||||
bestTop = top;
|
||||
bestCh = chNum;
|
||||
bestPg = pNum;
|
||||
}
|
||||
}
|
||||
if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh);
|
||||
if (bestPg !== currentPageNum) setCurrentPageNum(bestPg);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (rafId) return;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [currentChapterNum, currentPageNum]);
|
||||
|
||||
useEffect(() => {
|
||||
writeProgress(mangaSlug, {
|
||||
chapter: currentChapterNum,
|
||||
page: currentPageNum,
|
||||
});
|
||||
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
||||
|
||||
// Keep URL in sync with the chapter currently in the viewport so browser
|
||||
// back / reload returns to the latest chapter, not the one first opened.
|
||||
useEffect(() => {
|
||||
const url = `/manga/${mangaSlug}/${currentChapterNum}`;
|
||||
if (window.location.pathname === url) return;
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}, [mangaSlug, currentChapterNum]);
|
||||
|
||||
const { prevChapter, nextChapter } = useMemo(() => {
|
||||
const idx = chapters.findIndex((c) => c.number === currentChapterNum);
|
||||
return {
|
||||
prevChapter: idx > 0 ? chapters[idx - 1].number : null,
|
||||
nextChapter:
|
||||
idx >= 0 && idx < chapters.length - 1
|
||||
? chapters[idx + 1].number
|
||||
: null,
|
||||
};
|
||||
}, [chapters, currentChapterNum]);
|
||||
|
||||
const router = useRouter();
|
||||
const touchMovedRef = useRef(false);
|
||||
const singleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastTapAtRef = useRef(0);
|
||||
|
||||
const onTouchStart = useCallback(() => {
|
||||
touchMovedRef.current = false;
|
||||
}, []);
|
||||
const onTouchMove = useCallback(() => {
|
||||
touchMovedRef.current = true;
|
||||
}, []);
|
||||
const onTap = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (touchMovedRef.current) return;
|
||||
const now = Date.now();
|
||||
const isDoubleTap = now - lastTapAtRef.current < DOUBLE_TAP_MS;
|
||||
if (isDoubleTap) {
|
||||
if (singleTapTimerRef.current) {
|
||||
clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
}
|
||||
lastTapAtRef.current = 0;
|
||||
const midX = window.innerWidth / 2;
|
||||
if (e.clientX >= midX) {
|
||||
if (nextChapter)
|
||||
router.push(`/manga/${mangaSlug}/${nextChapter}`, {
|
||||
scroll: false,
|
||||
});
|
||||
} else {
|
||||
if (prevChapter)
|
||||
router.push(`/manga/${mangaSlug}/${prevChapter}`, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastTapAtRef.current = now;
|
||||
singleTapTimerRef.current = setTimeout(() => {
|
||||
setShowUI((v) => !v);
|
||||
singleTapTimerRef.current = null;
|
||||
}, DOUBLE_TAP_MS);
|
||||
},
|
||||
[router, mangaSlug, nextChapter, prevChapter]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (singleTapTimerRef.current) clearTimeout(singleTapTimerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showDrawer) return;
|
||||
const scroll = drawerScrollRef.current;
|
||||
const active = drawerActiveRef.current;
|
||||
if (!scroll || !active) return;
|
||||
const scrollRect = scroll.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
const delta =
|
||||
activeRect.top -
|
||||
scrollRect.top -
|
||||
scroll.clientHeight / 2 +
|
||||
active.clientHeight / 2;
|
||||
scroll.scrollTop = Math.max(0, scroll.scrollTop + delta);
|
||||
}, [showDrawer]);
|
||||
|
||||
const currentChapter =
|
||||
chapters.find((c) => c.number === currentChapterNum) ??
|
||||
chapters.find((c) => c.number === startChapterNumber);
|
||||
|
||||
const lastChapter = chapters[chapters.length - 1];
|
||||
const atEnd =
|
||||
currentChapterNum === lastChapter?.number &&
|
||||
currentPageNum >= (chapterMetas[currentChapterNum]?.length ?? 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<div
|
||||
className={`sticky top-0 z-50 bg-background backdrop-blur-sm shadow-sm transition-transform duration-300 pt-safe ${
|
||||
showUI ? "translate-y-0" : "-translate-y-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 h-12 max-w-4xl mx-auto">
|
||||
<Link
|
||||
href={`/manga/${mangaSlug}`}
|
||||
scroll={false}
|
||||
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
||||
aria-label="Back to manga"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium truncate">
|
||||
{mangaTitle}
|
||||
</p>
|
||||
<p className="text-muted text-xs truncate">
|
||||
Ch. {currentChapter?.number} — {currentChapter?.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDrawer(true)}
|
||||
className="text-foreground/80 hover:text-foreground transition-colors shrink-0"
|
||||
aria-label="Chapter list"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-w-4xl mx-auto leading-[0] select-none"
|
||||
onClick={onTap}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{loadedChapterNumbers.map((chNum, idx) => {
|
||||
const meta = chapterMetas[chNum];
|
||||
const chapter = chapters.find((c) => c.number === chNum);
|
||||
return (
|
||||
<Fragment key={chNum}>
|
||||
{idx > 0 && (
|
||||
<div className="bg-surface py-4 text-center leading-normal">
|
||||
<p className="text-xs uppercase tracking-wider text-muted">
|
||||
Chapter {chNum}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{chapter?.title}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{meta.map((p) => {
|
||||
const key = pageKey(chNum, p.number);
|
||||
const url = images[key];
|
||||
const aspect =
|
||||
p.width > 0 && p.height > 0
|
||||
? `${p.width} / ${p.height}`
|
||||
: "3 / 4";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el) => setPageRef(key, el)}
|
||||
data-chapter={chNum}
|
||||
data-page={p.number}
|
||||
className="relative leading-[0] w-full"
|
||||
style={{ aspectRatio: aspect }}
|
||||
>
|
||||
{url && (
|
||||
<img
|
||||
src={url}
|
||||
alt={`Page ${p.number}`}
|
||||
className="w-full h-auto block [-webkit-touch-callout:none]"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{atEnd && (
|
||||
<div className="min-h-dvh max-w-4xl mx-auto px-4 flex flex-col items-center justify-center text-center leading-normal gap-4">
|
||||
<p className="text-xs uppercase tracking-wider text-muted">
|
||||
End of Manga
|
||||
</p>
|
||||
<p className="text-base font-semibold">{mangaTitle}</p>
|
||||
<Link
|
||||
href="/"
|
||||
scroll={false}
|
||||
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDrawer && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setShowDrawer(false)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 max-h-[75vh] bg-background rounded-t-3xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-background z-10 border-b border-border">
|
||||
<div className="flex justify-center pt-2.5 pb-1.5">
|
||||
<div className="w-10 h-1 rounded-full bg-muted/40" />
|
||||
</div>
|
||||
<div className="px-5 py-3 flex items-center justify-between">
|
||||
<span className="text-foreground text-base font-bold">
|
||||
Chapters
|
||||
</span>
|
||||
<span className="text-xs text-muted tabular-nums">
|
||||
{chapters.length} total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={drawerScrollRef}
|
||||
className="overflow-y-auto max-h-[calc(75vh-4rem)] pb-safe"
|
||||
>
|
||||
{chapters.map((ch) => {
|
||||
const isActive = ch.number === currentChapterNum;
|
||||
return (
|
||||
<Link
|
||||
key={ch.number}
|
||||
ref={isActive ? drawerActiveRef : undefined}
|
||||
href={`/manga/${mangaSlug}/${ch.number}`}
|
||||
scroll={false}
|
||||
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-accent/10"
|
||||
: "hover:bg-surface active:bg-surface-hover"
|
||||
}`}
|
||||
onClick={() => setShowDrawer(false)}
|
||||
>
|
||||
<span
|
||||
className={`font-bold tabular-nums w-10 shrink-0 ${
|
||||
isActive ? "text-accent" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
#{ch.number}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate ${
|
||||
isActive
|
||||
? "text-accent font-semibold"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{ch.title}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto shrink-0 text-[10px] uppercase tracking-wider font-bold text-accent">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type ChapterLite = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mangaSlug: string;
|
||||
chapters: ChapterLite[];
|
||||
};
|
||||
|
||||
export type ReadingProgress = {
|
||||
chapter: number;
|
||||
page: number;
|
||||
};
|
||||
|
||||
function storageKey(slug: string) {
|
||||
return `sunnymh:last-read:${slug}`;
|
||||
}
|
||||
|
||||
export function readProgress(slug: string): ReadingProgress | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage.getItem(storageKey(slug));
|
||||
if (!raw) return null;
|
||||
// New format: JSON { chapter, page }
|
||||
if (raw.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ReadingProgress;
|
||||
if (
|
||||
typeof parsed.chapter === "number" &&
|
||||
typeof parsed.page === "number" &&
|
||||
parsed.chapter > 0 &&
|
||||
parsed.page > 0
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Legacy format: bare chapter number
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
|
||||
}
|
||||
|
||||
export function writeProgress(slug: string, progress: ReadingProgress) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
|
||||
}
|
||||
|
||||
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
||||
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(readProgress(mangaSlug));
|
||||
}, [mangaSlug]);
|
||||
|
||||
if (chapters.length === 0) return null;
|
||||
const first = chapters[0];
|
||||
const resumeChapter =
|
||||
progress !== null
|
||||
? chapters.find((c) => c.number === progress.chapter)
|
||||
: null;
|
||||
const target = resumeChapter ?? first;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/manga/${mangaSlug}/${target.number}`}
|
||||
scroll={false}
|
||||
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
||||
>
|
||||
{resumeChapter ? (
|
||||
<>
|
||||
<span>继续阅读</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<span className="truncate">
|
||||
#{resumeChapter.number} {resumeChapter.title}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"开始阅读"
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type SearchResult = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
coverUrl: string;
|
||||
};
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
function handleSearch(value: string) {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(value.trim())}`
|
||||
);
|
||||
const data = await res.json();
|
||||
setResults(data);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onFocus={() => results.length > 0 && setOpen(true)}
|
||||
placeholder="Search manga..."
|
||||
className="w-full pl-10 pr-4 py-2 text-sm bg-surface border border-border rounded-xl focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-muted transition-colors"
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-surface border border-border rounded-xl shadow-2xl overflow-hidden z-50">
|
||||
{results.map((manga) => (
|
||||
<Link
|
||||
key={manga.id}
|
||||
href={`/manga/${manga.slug}`}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<img
|
||||
src={manga.coverUrl}
|
||||
alt={manga.title}
|
||||
className="w-10 h-14 rounded object-cover bg-card"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{manga.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && query.trim().length >= 2 && results.length === 0 && !loading && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-surface border border-border rounded-xl shadow-2xl p-4 z-50">
|
||||
<p className="text-sm text-muted text-center">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseGenres } from "@/lib/genres";
|
||||
|
||||
type TrendingManga = {
|
||||
slug: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
genre: string;
|
||||
};
|
||||
|
||||
function RankNumber({ rank }: { rank: number }) {
|
||||
// Webtoon-style large rank number
|
||||
const colors =
|
||||
rank === 1
|
||||
? "text-yellow-400"
|
||||
: rank === 2
|
||||
? "text-slate-300"
|
||||
: rank === 3
|
||||
? "text-amber-600"
|
||||
: "text-white/70";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`text-[40px] font-black leading-none ${colors} drop-shadow-[0_2px_8px_rgba(0,0,0,0.8)]`}
|
||||
style={{ fontFamily: "var(--font-sans), system-ui, sans-serif" }}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendingCarousel({ manga }: { manga: TrendingManga[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
checkScroll();
|
||||
el.addEventListener("scroll", checkScroll, { passive: true });
|
||||
window.addEventListener("resize", checkScroll);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", checkScroll);
|
||||
window.removeEventListener("resize", checkScroll);
|
||||
};
|
||||
}, [checkScroll]);
|
||||
|
||||
function scroll(direction: "left" | "right") {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const amount = el.clientWidth * 0.8;
|
||||
el.scrollBy({
|
||||
left: direction === "left" ? -amount : amount,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
if (manga.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5 text-accent"
|
||||
>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-bold">Trending Now</h2>
|
||||
</div>
|
||||
|
||||
{/* Desktop carousel arrows */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => scroll("left")}
|
||||
disabled={!canScrollLeft}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-surface border border-border hover:bg-surface-hover disabled:opacity-25 disabled:cursor-default transition-all"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="w-4 h-4">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll("right")}
|
||||
disabled={!canScrollRight}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-surface border border-border hover:bg-surface-hover disabled:opacity-25 disabled:cursor-default transition-all"
|
||||
aria-label="Next"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="w-4 h-4">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel track */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-3 overflow-x-auto scroll-smooth snap-x snap-mandatory no-scrollbar"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{manga.map((m, i) => (
|
||||
<Link
|
||||
key={m.slug}
|
||||
href={`/manga/${m.slug}`}
|
||||
className="group shrink-0 snap-start"
|
||||
style={{ width: "clamp(150px, 40vw, 185px)" }}
|
||||
>
|
||||
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden bg-card">
|
||||
<img
|
||||
src={m.coverUrl}
|
||||
alt={m.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading={i < 5 ? "eager" : "lazy"}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||
|
||||
{/* Rank number - bottom left, Webtoon style */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<RankNumber rank={i + 1} />
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<h3 className="text-[13px] font-bold text-white leading-tight line-clamp-2 drop-shadow-md">
|
||||
{m.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-white/50 mt-0.5 truncate">
|
||||
{parseGenres(m.genre).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile edge fades */}
|
||||
{canScrollRight && (
|
||||
<div className="absolute right-0 top-12 bottom-0 w-6 bg-gradient-to-l from-background to-transparent pointer-events-none sm:hidden" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
services:
|
||||
manga-app:
|
||||
build: .
|
||||
pull_policy: build
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID}
|
||||
R2_ACCESS_KEY: ${R2_ACCESS_KEY}
|
||||
R2_SECRET_KEY: ${R2_SECRET_KEY}
|
||||
R2_BUCKET: ${R2_BUCKET}
|
||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL}
|
||||
restart: unless-stopped
|
||||
@ -1,18 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@ -1,7 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* A manga's `genre` field may hold multiple comma-separated genres
|
||||
* (e.g. "冒险, 恋爱, 魔幻"). This normalizes the raw string into a
|
||||
* deduped, trimmed list.
|
||||
*/
|
||||
export function parseGenres(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of raw.split(",")) {
|
||||
const g = part.trim();
|
||||
if (!g) continue;
|
||||
if (seen.has(g)) continue;
|
||||
seen.add(g);
|
||||
out.push(g);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten all genres across a collection of manga into a sorted unique list.
|
||||
*/
|
||||
export function collectGenres(
|
||||
mangas: { genre: string }[]
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const m of mangas) {
|
||||
for (const g of parseGenres(m.genre)) seen.add(g);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.R2_SECRET_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
export async function getPresignedUploadUrl(key: string) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: process.env.R2_BUCKET!,
|
||||
Key: key,
|
||||
ContentType: "image/webp",
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: 3600 });
|
||||
}
|
||||
|
||||
export function getPublicUrl(key: string) {
|
||||
return `${process.env.R2_PUBLIC_URL}/${key}`;
|
||||
}
|
||||
|
||||
export async function getPresignedReadUrl(key: string) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.R2_BUCKET!,
|
||||
Key: key,
|
||||
});
|
||||
return getSignedUrl(s3, command, { expiresIn: 60 });
|
||||
}
|
||||
|
||||
export function keyFromPublicUrl(publicUrl: string): string | null {
|
||||
const prefix = process.env.R2_PUBLIC_URL!;
|
||||
if (!publicUrl.startsWith(prefix)) return null;
|
||||
return publicUrl.replace(prefix, "").replace(/^\//, "");
|
||||
}
|
||||
|
||||
export async function signUrl(publicUrl: string) {
|
||||
const key = keyFromPublicUrl(publicUrl);
|
||||
if (key === null) return publicUrl;
|
||||
return getPresignedReadUrl(key);
|
||||
}
|
||||
|
||||
export async function signCoverUrls<T extends { coverUrl: string }>(
|
||||
items: T[]
|
||||
): Promise<T[]> {
|
||||
return Promise.all(
|
||||
items.map(async (item) => ({
|
||||
...item,
|
||||
coverUrl: await signUrl(item.coverUrl),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="16" fill="#268a52"/>
|
||||
<g transform="translate(50,50)">
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="8" fill="#268a52" stroke="#fff" stroke-width="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1016 B |
@ -1,18 +0,0 @@
|
||||
<svg viewBox="0 0 420 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="420" height="100" rx="10" fill="#268a52"/>
|
||||
<!-- Sunflower icon -->
|
||||
<g transform="translate(55,42)">
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-20" rx="6" ry="12" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="7" fill="#268a52" stroke="#fff" stroke-width="2"/>
|
||||
</g>
|
||||
<!-- Brand text -->
|
||||
<text x="110" y="42" font-family="-apple-system, 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="28" font-weight="500" fill="#fff" letter-spacing="4px">晴天漫画</text>
|
||||
<text x="110" y="64" font-family="-apple-system, 'Helvetica Neue', sans-serif" font-size="11" fill="#fff" opacity="0.5" letter-spacing="1.5px" font-style="italic">Sunny MH</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,13 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(50,50)">
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="10" fill="#268a52" stroke="#fff" stroke-width="2.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 945 B |
@ -1,16 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" rx="16" fill="#268a52"/>
|
||||
<g transform="translate(100,80)">
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-28" rx="8" ry="16" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="10" fill="#268a52" stroke="#fff" stroke-width="2.5"/>
|
||||
</g>
|
||||
<text x="100" y="145" text-anchor="middle" font-family="-apple-system, 'Noto Sans SC', 'PingFang SC', sans-serif" font-size="22" font-weight="500" fill="#fff" letter-spacing="6px">晴天漫画</text>
|
||||
<text x="100" y="168" text-anchor="middle" font-family="-apple-system, 'Helvetica Neue', sans-serif" font-size="10" fill="#fff" opacity="0.5" letter-spacing="1.5px" font-style="italic">Sunny MH</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,19 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["10.8.0.2"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.r2.dev",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.cloudflarestorage.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9208
manga-site/package-lock.json
generated
@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "manga-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1015.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"image-size": "^2.0.2",
|
||||
"next": "16.2.1",
|
||||
"prisma": "^6.19.2",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -1,51 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Status" AS ENUM ('PUBLISHED', 'DRAFT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Manga" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"coverUrl" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"status" "Status" NOT NULL DEFAULT 'PUBLISHED',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Manga_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Chapter" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"mangaId" INTEGER NOT NULL,
|
||||
"number" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Chapter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Page" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"chapterId" INTEGER NOT NULL,
|
||||
"number" INTEGER NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Manga_slug_key" ON "Manga"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Chapter_mangaId_number_key" ON "Chapter"("mangaId", "number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Page_chapterId_number_key" ON "Page"("chapterId", "number");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Chapter" ADD CONSTRAINT "Chapter_mangaId_fkey" FOREIGN KEY ("mangaId") REFERENCES "Manga"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Page" ADD CONSTRAINT "Page_chapterId_fkey" FOREIGN KEY ("chapterId") REFERENCES "Chapter"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Manga" ADD COLUMN "genre" TEXT NOT NULL DEFAULT 'Drama';
|
||||
@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;
|
||||
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@ -1,49 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Manga {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String
|
||||
coverUrl String
|
||||
slug String @unique
|
||||
genre String @default("Drama")
|
||||
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])
|
||||
|
||||
@@unique([mangaId, number])
|
||||
}
|
||||
|
||||
model Page {
|
||||
id Int @id @default(autoincrement())
|
||||
chapterId Int
|
||||
number Int
|
||||
imageUrl String
|
||||
width Int @default(0)
|
||||
height Int @default(0)
|
||||
chapter Chapter @relation(fields: [chapterId], references: [id])
|
||||
|
||||
@@unique([chapterId, number])
|
||||
}
|
||||
|
||||
enum Status {
|
||||
PUBLISHED
|
||||
DRAFT
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const mangaData = [
|
||||
{
|
||||
title: "Vice Versa",
|
||||
slug: "vice-versa",
|
||||
description:
|
||||
"A tale of fate and desire intertwined. When two souls swap destinies, they discover the true meaning of living someone else's life.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20260129_96/1769683677999mNYO7_PNG/84__Thumb_Poster.png?type=q90",
|
||||
genre: "Romance",
|
||||
},
|
||||
{
|
||||
title: "Became a Sales Genius",
|
||||
slug: "became-a-sales-genius",
|
||||
description:
|
||||
"Reborn with memories of his past life, a failed salesman returns to dominate the corporate world with knowledge no one else possesses.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20251105_137/1762317547495MfzIC_JPEG/Thumb_Poster.jpg?type=q90",
|
||||
genre: "Drama",
|
||||
},
|
||||
{
|
||||
title: "A Regressor's Tale of Cultivation",
|
||||
slug: "regressors-tale-of-cultivation",
|
||||
description:
|
||||
"After countless regressions, a martial artist finally begins to unravel the deepest mysteries of cultivation and the heavenly dao.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20251114_103/1763091301425CY14H_JPEG/Thumb_Poster.jpg?type=q90",
|
||||
genre: "Martial Arts",
|
||||
},
|
||||
{
|
||||
title: "My Avatar's Path to Greatness",
|
||||
slug: "my-avatars-path-to-greatness",
|
||||
description:
|
||||
"By splitting himself into multiple clones, one man sets out to conquer every path of power simultaneously.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250425_176/1745558711155VySyE_JPEG/Thumb_Poster_7727.jpg?type=q90",
|
||||
genre: "Fantasy",
|
||||
},
|
||||
{
|
||||
title: "Sera",
|
||||
slug: "sera",
|
||||
description:
|
||||
"The explosive school life of Gu Sera, a girl whose temper is as fierce as her sense of justice.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250321_24/1742528056930xEbcJ_JPEG/Thumb_Poster.jpg?type=q90",
|
||||
genre: "School",
|
||||
},
|
||||
{
|
||||
title: "Revenge of the Real One",
|
||||
slug: "revenge-of-the-real-one",
|
||||
description:
|
||||
"She was the true heiress all along. Now that the truth is out, it's time for the fake to pay.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250811_6/1754909221289kEXyb_PNG/480x623.png?type=q90",
|
||||
genre: "Fantasy",
|
||||
},
|
||||
{
|
||||
title: "Kindergarten for Divine Beasts",
|
||||
slug: "kindergarten-for-divine-beasts",
|
||||
description:
|
||||
"Running a daycare is hard enough. Running one for baby dragons, phoenixes, and ancient beasts? That's another story entirely.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250613_186/1749801415006hU2Kf_JPEG/Thumb_Poster_8051.jpg?type=q90",
|
||||
genre: "Fantasy",
|
||||
},
|
||||
{
|
||||
title: "Dr. Kim of London",
|
||||
slug: "dr-kim-of-london",
|
||||
description:
|
||||
"An Eastern medicine prodigy takes on the Western medical establishment in Victorian London.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20251016_11/1760587613431sbps4_JPEG/Thumb_Poster.jpg?type=q90",
|
||||
genre: "Drama",
|
||||
},
|
||||
{
|
||||
title: "The Returned C-Rank Tank Won't Die",
|
||||
slug: "c-rank-tank-wont-die",
|
||||
description:
|
||||
"Everyone thought he was weak. But after returning from a dungeon break, this C-rank tank has become truly unkillable.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250625_252/1750834745151JPG3l_JPEG/Thumb_Poster_8054.jpg?type=q90",
|
||||
genre: "Fantasy",
|
||||
},
|
||||
{
|
||||
title: "Violets Blooming in Garden",
|
||||
slug: "violets-blooming-in-garden",
|
||||
description:
|
||||
"In the imperial court's most secluded garden, a quiet noblewoman hides secrets that could topple an empire.",
|
||||
coverUrl:
|
||||
"https://webtoon-phinf.pstatic.net/20250709_43/1752041143942ixO3h_PNG/垂直略縮圖480x623_Logo.png?type=q90",
|
||||
genre: "Romance",
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
for (const data of mangaData) {
|
||||
const manga = await prisma.manga.upsert({
|
||||
where: { slug: data.slug },
|
||||
update: data,
|
||||
create: data,
|
||||
});
|
||||
console.log(` Upserted: ${manga.title} (id: ${manga.id})`);
|
||||
}
|
||||
|
||||
console.log("Seed complete.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1,14 +0,0 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="16" fill="#268a52"/>
|
||||
<g transform="translate(50,50)">
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(45)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(90)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(135)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(180)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(225)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(270)"/>
|
||||
<ellipse cx="0" cy="-22" rx="6.5" ry="13" fill="#fff" opacity="0.85" transform="rotate(315)"/>
|
||||
<circle cx="0" cy="0" r="8" fill="#268a52" stroke="#fff" stroke-width="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1016 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Backfill `width` and `height` on Page rows by range-fetching the first
|
||||
* 16 KB of each image from R2 and parsing its header with `image-size`.
|
||||
*
|
||||
* Idempotent: only targets rows where width=0 or height=0.
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-page-dims.ts
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { imageSize } from "image-size";
|
||||
import { keyFromPublicUrl } from "@/lib/r2";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const BUCKET = process.env.R2_BUCKET;
|
||||
if (!BUCKET) throw new Error("R2_BUCKET must be set");
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.R2_SECRET_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
const CONCURRENCY = 10;
|
||||
const HEADER_BYTES = 16_384;
|
||||
|
||||
async function fetchHeader(key: string): Promise<Uint8Array> {
|
||||
const res = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Range: `bytes=0-${HEADER_BYTES - 1}`,
|
||||
})
|
||||
);
|
||||
if (!res.Body) throw new Error(`No body for ${key}`);
|
||||
return res.Body.transformToByteArray();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pages = await prisma.page.findMany({
|
||||
where: { OR: [{ width: 0 }, { height: 0 }] },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
console.log(`Probing ${pages.length} pages with dims unset`);
|
||||
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < pages.length; i += CONCURRENCY) {
|
||||
const batch = pages.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (page) => {
|
||||
try {
|
||||
const key = keyFromPublicUrl(page.imageUrl);
|
||||
if (!key) throw new Error(`URL outside R2 prefix: ${page.imageUrl}`);
|
||||
const header = await fetchHeader(key);
|
||||
const { width, height } = imageSize(header);
|
||||
if (!width || !height) {
|
||||
throw new Error("image-size returned no dimensions");
|
||||
}
|
||||
await prisma.page.update({
|
||||
where: { id: page.id },
|
||||
data: { width, height },
|
||||
});
|
||||
done++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(
|
||||
`✗ page ${page.id} (${page.imageUrl}):`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
console.log(`${Math.min(i + CONCURRENCY, pages.length)}/${pages.length}`);
|
||||
}
|
||||
|
||||
console.log(`\nDone. Probed: ${done}, failed: ${failed}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||