Merge commit '4f5d74e1c8be08ee6c43fffb44ecc226bf7b9bb7' as 'manga-site'

This commit is contained in:
yiekheng 2026-04-12 18:47:51 +08:00
commit 200ad22529
59 changed files with 12047 additions and 0 deletions

49
manga-site/.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# 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

182
manga-site/CLAUDE.md Normal file
View File

@ -0,0 +1,182 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **This is NOT the Next.js you know.** Next 16 / App Router has breaking
> changes from older training data — APIs, conventions, and file structure
> may differ. Check `node_modules/next/dist/docs/` before writing routing
> or metadata code, and heed deprecation notices.
## Build & Development Commands
```bash
npm run dev # Start dev server (hot reload, Turbopack)
npm run build # Production build
npm start # Start production server
npm run lint # ESLint (flat config, v9+)
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma db push # Sync schema to DB (this repo uses db push, not migrate)
npx tsx scripts/backfill-page-dims.ts # Probe Page.width/height via image-size against R2
```
The DB is managed via `prisma db push`, not `migrate dev`. `prisma/migrations/`
holds SQL files kept for reference only; `migrate dev` won't work (no shadow
DB permission on this Postgres).
## Architecture
**Stack**: Next.js 16.2.1 (App Router) · React 19 · TypeScript · Tailwind CSS v4 · Prisma · PostgreSQL 16 · Cloudflare R2
**Path alias**: `@/*` maps to project root.
### Reader is placeholder-based — read this before touching `PageReader.tsx`
`components/PageReader.tsx` is deliberately **not** a naive `pages.map(<img>)`.
Every page renders as a `<div>` placeholder with `style={{ aspectRatio: w/h }}`
so the document height is correct before any image bytes load. This avoids
a whole class of iOS Safari scroll-anchoring / prepend-shift bugs that a
simple reader would hit. Key invariants:
- `Page.width` / `Page.height` **must** be populated for every row. The
Python scraper writes them on insert; the backfill script covers older
rows. With dims=0, the reader falls back to 3/4 and layout shifts on
image load.
- Initial chapter meta (dims skeleton) is fetched server-side in
`app/manga/[slug]/[chapter]/page.tsx` and passed as a prop. Later
chapters' meta is lazy-fetched via `/api/chapters/[id]/meta`.
- Image URLs (signed, short-TTL) come in batches via `/api/pages` triggered
by an `IntersectionObserver` with 1200 px margin. A separate
intersecting-pages `Map` is maintained for the scroll tick so the topmost
visible page can be found in O(k) without walking every placeholder.
- Continuous multi-chapter reading auto-appends the next chapter when the
user gets within `PREFETCH_NEXT_AT = 3` pages of the end.
- URL syncs to the chapter currently in viewport via
`window.history.replaceState`. `prevChapter` / `nextChapter` for
double-tap nav are derived from `currentChapterNum`, **not** from the URL
or props.
- All Links into the reader use `scroll={false}`, and the double-tap
`router.push(..., { scroll: false })`. This is load-bearing — without it,
App Router's default scroll-to-top clobbers the `useLayoutEffect` that
restores the resume page.
- Reading progress is persisted to `localStorage` under the key
`sunnymh:last-read:<slug>` as JSON `{chapter, page}`. `readProgress`
in `components/ReadingProgressButton.tsx` also accepts the **legacy
bare-number format** (just a chapter number string) for backward
compatibility — preserve this when touching the storage format.
### Immersive reader route
`components/Header.tsx` and `components/BottomNav.tsx` both return `null`
when the pathname matches `/manga/[slug]/[chapter]` — the reader runs
full-bleed with its own chrome. Anything new that relies on the global
nav being present needs to account for this.
### Data flow
- **Prisma models** (see `prisma/schema.prisma`): `Manga`, `Chapter`, `Page`.
`Page` carries `width` / `height`. `Manga` has `genre` as a
comma-separated string; parse with `lib/genres.ts`.
- **Images live in R2** (S3-compatible). Format is **WebP** (~2535%
smaller than JPEG). The app **never** serves R2 URLs directly —
`lib/r2.ts::signUrl` / `signCoverUrls` mint presigned GETs with a 60 s
TTL; `keyFromPublicUrl` reverses a public URL to its R2 key.
`signCoverUrls` is the standardized pattern for list pages — homepage
carousel, genre grid, search results, detail page all route through it.
Don't bypass it by handing raw `manga.coverUrl` to the browser.
- **R2 layout** (populated by the scraper — not this app):
- `manga/<slug>/chapters/<chapter_number>/<page_number>.webp`
- `manga/<slug>/cover.webp`
- **Search**: PostgreSQL `contains` + `mode: 'insensitive'`. Fine up to
~10k titles. If a future scale requires fuzzy/full-text, the planned
swap-in is Meilisearch.
### Key routes
- `app/page.tsx` — homepage (trending carousel + genre tabs).
- `app/manga/[slug]/page.tsx` — detail + chapter list + "continue reading".
- `app/manga/[slug]/[chapter]/page.tsx` — reader. Issues both required
queries (manga+chapters, initial chapter page meta) in parallel via
`Promise.all`.
- `app/api/pages/route.ts` — batched `{number, imageUrl}` with signed URLs.
- `app/api/chapters/[chapterId]/meta/route.ts``[{number, width, height}]`
for lazy next-chapter prefetch.
- `app/api/search/route.ts` — case-insensitive title search.
- `app/api/upload/route.ts` — R2 presigned PUT (admin/scraper only; no DB
write happens here).
- `app/sitemap.ts` — auto-generated `/sitemap.xml`. Each page uses
`generateMetadata()` for per-route title/description.
### Ingestion is a sibling Python repo
New manga and chapters are created by `../manga-dl/manga.py`, not by this
web app. It scrapes, converts to WebP, uploads to R2, and writes
`Manga` / `Chapter` / `Page` rows directly via `psycopg2`. When updating the
Prisma schema, update the sibling's SQL INSERTs too (they're raw, not ORM).
### Branding
- Source SVGs in `logo/qingtian_*.svg`.
- Wired up as: `app/icon.svg` (browser favicon, Next.js convention),
`app/apple-icon.svg` (iOS home-screen), `public/logo.svg` (Header `<img>`).
- Accent color: `#268a52` (green sunflower), defined as `--accent` in
`app/globals.css`. Hover: `#1f7044`.
- Brand name: 晴天漫画 · Sunny MH.
### iOS Safari conventions
- `viewport` in `app/layout.tsx` sets `viewportFit: "cover"` so the app
extends into the notch; any sticky/fixed chrome must use the
`pt-safe` / `pb-safe` utilities from `app/globals.css` (they map to
`env(safe-area-inset-*)`). Header and BottomNav already do.
- Horizontal carousels (`components/TrendingCarousel.tsx`) set
`WebkitOverflowScrolling: "touch"` inline for momentum scrolling.
Preserve this on any new swipe lane.
- `overflow-x: hidden` on `body` (globals.css) prevents the fixed
BottomNav from leaking horizontal scroll on mobile.
## Deployment
- **Host**: Proxmox VM running Docker + Portainer.
- **Flow**: Push to Gitea (`gitea.04080616.xyz/yiekheng/sunnymh-manga-site`)
`git pull` on the Docker host → rebuild → restart via Portainer.
- **Dockerfile** at repo root; container listens on 3000, mapped to host
3001. `docker-compose.yml` only brings up the app — **Postgres is
external** (connected via `DATABASE_URL`), not defined in compose.
- **Reverse proxy**: aaPanel Nginx routes `www.04080616.xyz`
`http://127.0.0.1:3001`. SSL via Let's Encrypt managed in aaPanel. DDNS
already configured — no Cloudflare Tunnel involved.
- **Host ports in use elsewhere**: 3000, 80008002, 8005, 2283, 5432,
5182051821, 9443 — don't collide. Postgres lives on host 5433.
- After schema changes reach prod, run `scripts/backfill-page-dims.ts`
once against prod DB to populate dims for historical rows.
### Minimal Nginx location block (aaPanel)
```nginx
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
```
## Environment Variables
Required in `.env` (container equivalents in `docker-compose.yml`):
- `DATABASE_URL` — PostgreSQL connection string, e.g. `postgresql://manga_user:…@localhost:5433/manga_db`
- `R2_ACCOUNT_ID`, `R2_ACCESS_KEY`, `R2_SECRET_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL` — Cloudflare R2
## Dev gotchas
- **`next.config.ts` has `allowedDevOrigins: ["10.8.0.2"]`** — a VPN-access
whitelist so dev on macOS is reachable from other devices on the tunnel.
Removing or breaking it silently blocks hot-reload from those origins;
update it if the VPN subnet changes.
- **`prisma/seed.ts` is documentation-only.** The real data pipeline is
the sibling Python scraper (`../manga-dl/manga.py`) writing directly via
`psycopg2`. The `npm run seed` script exists for quick local dev but is
never run in production.

9
manga-site/Dockerfile Normal file
View File

@ -0,0 +1,9 @@
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"]

36
manga-site/README.md Normal file
View File

@ -0,0 +1,36 @@
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.

View File

@ -0,0 +1,19 @@
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);
}

View File

@ -0,0 +1,41 @@
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 });
}

View File

@ -0,0 +1,30 @@
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);
}

View File

@ -0,0 +1,28 @@
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));
}

View File

@ -0,0 +1,19 @@
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 });
}

View File

@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 1016 B

View File

@ -0,0 +1,28 @@
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>
);
}

View File

@ -0,0 +1,82 @@
@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);
}
}

13
manga-site/app/icon.svg Normal file
View File

@ -0,0 +1,13 @@
<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>

After

Width:  |  Height:  |  Size: 945 B

57
manga-site/app/layout.tsx Normal file
View File

@ -0,0 +1,57 @@
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>
);
}

View File

@ -0,0 +1,12 @@
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>
);
}

View File

@ -0,0 +1,9 @@
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}</>;
}

View File

@ -0,0 +1,69 @@
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}
/>
);
}

View File

@ -0,0 +1,101 @@
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>
);
}

View File

@ -0,0 +1,19 @@
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&apos;re looking for doesn&apos;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>
);
}

32
manga-site/app/page.tsx Normal file
View File

@ -0,0 +1,32 @@
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>
);
}

View File

@ -0,0 +1,44 @@
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>
);
}

28
manga-site/app/sitemap.ts Normal file
View File

@ -0,0 +1,28 @@
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,
];
}

View File

@ -0,0 +1,73 @@
"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>
);
}

View File

@ -0,0 +1,50 @@
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>
);
}

View File

@ -0,0 +1,103 @@
"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>
);
}

View File

@ -0,0 +1,63 @@
"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>
);
}

View File

@ -0,0 +1,39 @@
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>
);
}

View File

@ -0,0 +1,44 @@
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>
);
}

View File

@ -0,0 +1,560 @@
"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>
);
}

View File

@ -0,0 +1,90 @@
"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>
);
}

View File

@ -0,0 +1,117 @@
"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>
);
}

View File

@ -0,0 +1,159 @@
"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>
);
}

View File

@ -0,0 +1,14 @@
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

View File

@ -0,0 +1,18 @@
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;

7
manga-site/lib/db.ts Normal file
View File

@ -0,0 +1,7 @@
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;

31
manga-site/lib/genres.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* 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();
}

59
manga-site/lib/r2.ts Normal file
View File

@ -0,0 +1,59 @@
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),
}))
);
}

View File

@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 1016 B

View File

@ -0,0 +1,18 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,13 @@
<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>

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1,16 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

19
manga-site/next.config.ts Normal file
View File

@ -0,0 +1,19 @@
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 Normal file

File diff suppressed because it is too large Load Diff

36
manga-site/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"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"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,51 @@
-- 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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Manga" ADD COLUMN "genre" TEXT NOT NULL DEFAULT 'Drama';

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -0,0 +1,49 @@
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
}

118
manga-site/prisma/seed.ts Normal file
View File

@ -0,0 +1,118 @@
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());

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 1016 B

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,89 @@
/**
* 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);
});

34
manga-site/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"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"]
}