First Commit
This commit is contained in:
parent
9bd75381a7
commit
f1c649429e
8
.gitignore
vendored
8
.gitignore
vendored
@ -39,3 +39,11 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# reference files
|
||||
/reference/
|
||||
|
||||
# claude skills/agents
|
||||
/.agents/
|
||||
/.claude/
|
||||
skills-lock.json
|
||||
|
||||
@ -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
37
app/api/manga/route.ts
Normal 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
27
app/api/search/route.ts
Normal 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
19
app/api/upload/route.ts
Normal 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
25
app/genre/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
12
app/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
app/manga/[slug]/[chapter]/layout.tsx
Normal file
9
app/manga/[slug]/[chapter]/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
67
app/manga/[slug]/[chapter]/page.tsx
Normal file
67
app/manga/[slug]/[chapter]/page.tsx
Normal 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
91
app/manga/[slug]/page.tsx
Normal 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
19
app/not-found.tsx
Normal 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're looking for doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
app/page.tsx
35
app/page.tsx
@ -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
41
app/search/page.tsx
Normal 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
28
app/sitemap.ts
Normal 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
73
components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
components/ChapterList.tsx
Normal file
49
components/ChapterList.tsx
Normal 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
100
components/GenreTabs.tsx
Normal 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
69
components/Header.tsx
Normal 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
39
components/MangaCard.tsx
Normal 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
44
components/MangaGrid.tsx
Normal 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
140
components/PageReader.tsx
Normal 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
116
components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
components/TrendingCarousel.tsx
Normal file
158
components/TrendingCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
7
lib/db.ts
Normal 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
24
lib/r2.ts
Normal 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}`;
|
||||
}
|
||||
@ -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
2595
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
51
prisma/migrations/20260324150031_init/migration.sql
Normal file
51
prisma/migrations/20260324150031_init/migration.sql
Normal 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;
|
||||
2
prisma/migrations/20260325004730_add_genre/migration.sql
Normal file
2
prisma/migrations/20260325004730_add_genre/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Manga" ADD COLUMN "genre" TEXT NOT NULL DEFAULT 'Drama';
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
47
prisma/schema.prisma
Normal 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
118
prisma/seed.ts
Normal 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());
|
||||
Loading…
x
Reference in New Issue
Block a user