First Commit

This commit is contained in:
yiekheng 2026-03-25 22:20:37 +08:00
parent 9bd75381a7
commit f1c649429e
36 changed files with 4148 additions and 36 deletions

8
.gitignore vendored
View File

@ -39,3 +39,11 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# reference files
/reference/
# claude skills/agents
/.agents/
/.claude/
skills-lock.json

View File

@ -3,6 +3,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

37
app/api/manga/route.ts Normal file
View File

@ -0,0 +1,37 @@
import { prisma } from "@/lib/db";
import { NextRequest } from "next/server";
export async function GET() {
const manga = await prisma.manga.findMany({
orderBy: { updatedAt: "desc" },
include: {
_count: { select: { chapters: true } },
},
});
return Response.json(manga);
}
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 });
}

27
app/api/search/route.ts Normal file
View File

@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
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(results);
}

19
app/api/upload/route.ts Normal file
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 });
}

25
app/genre/page.tsx Normal file
View File

@ -0,0 +1,25 @@
import { prisma } from "@/lib/db";
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 genres = [...new Set(manga.map((m) => m.genre))].sort();
return (
<div className="max-w-6xl mx-auto px-4 py-5">
<GenreTabs manga={manga} genres={genres} />
</div>
);
}

View File

@ -1,26 +1,71 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #0f0f14;
--foreground: #e8e6e3;
--surface: #1a1a24;
--surface-hover: #242433;
--border: #2a2a3c;
--accent: #7c5cfc;
--accent-hover: #9478ff;
--muted: #8b8b9e;
--card: #16161f;
}
@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);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
-webkit-tap-highlight-color: transparent;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
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: var(--border);
border-radius: 3px;
}
/* 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);
}
}

View File

@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
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({
@ -7,14 +9,26 @@ const geistSans = Geist({
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "SunnyMH — Free Manga Reader",
template: "%s | SunnyMH",
},
description:
"Read free public domain manga online. Beautiful reader optimized for mobile.",
metadataBase: new URL("https://manga.04080616.xyz"),
openGraph: {
type: "website",
siteName: "SunnyMH",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: "#0f0f14",
};
export default function RootLayout({
@ -23,11 +37,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-background text-foreground">
<Header />
<main className="flex-1 pb-20 sm:pb-0">{children}</main>
<BottomNav />
</body>
</html>
);
}

12
app/loading.tsx Normal file
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,67 @@
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 = await prisma.manga.findUnique({
where: { slug },
include: {
chapters: {
orderBy: { number: "asc" },
include: {
pages: { orderBy: { number: "asc" } },
},
},
},
});
if (!manga) notFound();
const currentChapter = manga.chapters.find((c) => c.number === chapterNum);
if (!currentChapter) notFound();
const chapterIndex = manga.chapters.findIndex(
(c) => c.number === chapterNum
);
const prevChapter =
chapterIndex > 0 ? manga.chapters[chapterIndex - 1].number : null;
const nextChapter =
chapterIndex < manga.chapters.length - 1
? manga.chapters[chapterIndex + 1].number
: null;
return (
<PageReader
pages={currentChapter.pages}
mangaSlug={manga.slug}
mangaTitle={manga.title}
chapterNumber={currentChapter.number}
chapterTitle={currentChapter.title}
prevChapter={prevChapter}
nextChapter={nextChapter}
/>
);
}

91
app/manga/[slug]/page.tsx Normal file
View File

@ -0,0 +1,91 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { ChapterList } from "@/components/ChapterList";
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();
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={manga.coverUrl}
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">
<span className="px-2 py-0.5 text-[11px] font-semibold bg-accent/20 text-accent rounded-full">
{manga.genre}
</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>
{/* Continue reading button */}
{manga.chapters.length > 0 && (
<a
href={`/manga/${manga.slug}/${manga.chapters[0].number}`}
className="block w-full py-3 mb-6 text-center text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
>
Start Reading Ch. {manga.chapters[0].number}
</a>
)}
{/* Chapters */}
<div>
<h2 className="text-lg font-bold mb-3">Chapters</h2>
<ChapterList chapters={manga.chapters} mangaSlug={manga.slug} />
</div>
</div>
);
}

19
app/not-found.tsx Normal file
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>
);
}

View File

@ -1,14 +1,29 @@
export default function Home() {
import { prisma } from "@/lib/db";
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 } } },
});
// Top 10 for trending
const trending = manga.slice(0, 10);
// Extract unique genres
const genres = [...new Set(manga.map((m) => m.genre))].sort();
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
<div className="text-center">
<h1 className="text-4xl font-bold text-black dark:text-white">
SunnyMH Manga
</h1>
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">
Hello World Coming Soon
</p>
</div>
<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={manga} genres={genres} />
</div>
);
}

41
app/search/page.tsx Normal file
View File

@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
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 } } },
})
: [];
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={manga} />
) : (
<p className="text-muted text-center py-12">
Use the search bar above to find manga
</p>
)}
</div>
);
}

28
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://manga.04080616.xyz/manga/${m.slug}`,
lastModified: m.updatedAt,
changeFrequency: "weekly",
priority: 0.8,
}));
return [
{
url: "https://manga.04080616.xyz",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
...mangaEntries,
];
}

73
components/BottomNav.tsx Normal file
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="fixed bottom-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-xl border-t border-border 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,49 @@
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}`}
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>
);
}

100
components/GenreTabs.tsx Normal file
View File

@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import Link from "next/link";
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) => m.genre === 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">
<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" />
{m._count && m._count.chapters > 0 && (
<span className="absolute top-2 right-2 px-1.5 py-0.5 text-[10px] font-bold bg-accent/90 text-white rounded-md">
{m._count.chapters} ch
</span>
)}
<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">{m.genre}</p>
</div>
</div>
</Link>
))}
</div>
) : (
<p className="text-muted text-center py-12 text-sm">
No manga in this genre yet
</p>
)}
</section>
);
}

69
components/Header.tsx Normal file
View File

@ -0,0 +1,69 @@
"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/95 backdrop-blur-xl border-b border-border">
{/* 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">
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
<svg
viewBox="0 0 24 24"
fill="none"
className="w-5 h-5 text-white"
stroke="currentColor"
strokeWidth={2.5}
>
<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>
</div>
<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>
);
}

39
components/MangaCard.tsx Normal file
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">
<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" />
{chapterCount !== undefined && (
<span className="absolute top-2 right-2 px-2 py-0.5 text-[10px] font-semibold bg-accent/90 text-white rounded-full">
{chapterCount} ch
</span>
)}
<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>
);
}

44
components/MangaGrid.tsx Normal file
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>
);
}

140
components/PageReader.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import Link from "next/link";
type PageData = {
number: number;
imageUrl: string;
};
type PageReaderProps = {
pages: PageData[];
mangaSlug: string;
mangaTitle: string;
chapterNumber: number;
chapterTitle: string;
prevChapter: number | null;
nextChapter: number | null;
};
export function PageReader({
pages,
mangaSlug,
mangaTitle,
chapterNumber,
chapterTitle,
prevChapter,
nextChapter,
}: PageReaderProps) {
const [showUI, setShowUI] = useState(true);
return (
<div className="min-h-screen bg-black relative">
{/* Top bar */}
<div
className={`fixed top-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 ${
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}`}
className="text-white/80 hover:text-white transition-colors"
>
<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-white text-sm font-medium truncate">
{mangaTitle}
</p>
<p className="text-white/50 text-xs truncate">
Ch. {chapterNumber} {chapterTitle}
</p>
</div>
</div>
</div>
{/* Pages - vertical scroll (webtoon style, best for mobile) */}
<div
className="max-w-4xl mx-auto"
onClick={() => setShowUI(!showUI)}
>
{pages.map((page) => (
<div key={page.number} className="relative">
<img
src={page.imageUrl}
alt={`Page ${page.number}`}
className="w-full h-auto block"
loading={page.number <= 3 ? "eager" : "lazy"}
/>
</div>
))}
</div>
{/* Bottom navigation */}
<div
className={`fixed bottom-0 left-0 right-0 z-50 bg-black/90 backdrop-blur-sm transition-transform duration-300 pb-safe ${
showUI ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="flex items-center justify-between px-4 h-14 max-w-4xl mx-auto">
{prevChapter ? (
<Link
href={`/manga/${mangaSlug}/${prevChapter}`}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Prev
</Link>
) : (
<div />
)}
<span className="text-white/50 text-xs">
{pages.length} pages
</span>
{nextChapter ? (
<Link
href={`/manga/${mangaSlug}/${nextChapter}`}
className="flex items-center gap-1 text-white/80 hover:text-white text-sm transition-colors"
>
Next
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</Link>
) : (
<Link
href={`/manga/${mangaSlug}`}
className="text-accent text-sm font-medium"
>
Done
</Link>
)}
</div>
</div>
</div>
);
}

116
components/SearchBar.tsx Normal file
View File

@ -0,0 +1,116 @@
"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"
/>
{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,158 @@
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import Link from "next/link";
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">
{m.genre}
</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

@ -17,6 +17,8 @@ services:
manga-db:
image: postgres:16
ports:
- "5433:5432"
volumes:
- manga_pgdata:/var/lib/postgresql/data
environment:

7
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;

24
lib/r2.ts Normal file
View File

@ -0,0 +1,24 @@
import { S3Client, PutObjectCommand } 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}`;
}

View File

@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.r2.dev",
},
{
protocol: "https",
hostname: "*.cloudflarestorage.com",
},
],
},
};
export default nextConfig;

2595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,18 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"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",
"next": "16.2.1",
"prisma": "^6.19.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@ -21,6 +29,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

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 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

47
prisma/schema.prisma Normal file
View File

@ -0,0 +1,47 @@
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
chapter Chapter @relation(fields: [chapterId], references: [id])
@@unique([chapterId, number])
}
enum Status {
PUBLISHED
DRAFT
}

118
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());