Reading progress
- New ReadingProgressButton client component that reads/writes
localStorage key "sunnymh:last-read:{slug}"
- PageReader writes the currently-visible chapter as the user scrolls
through continuous chapters
- Manga detail CTA now reads: "开始阅读" on first visit, or
"继续阅读 · #{N} {title}" when a prior chapter is stored (with
spacing and truncation for long titles)
Multiple genres
- lib/genres.ts with parseGenres() and collectGenres() helpers to
split "冒险, 恋爱, 魔幻" into a list and aggregate across a collection
- Manga detail renders one pill per genre
- GenreTabs filters via parseGenres(...).includes(activeGenre) so a
multi-genre manga appears under each of its genres
- Home / Genre pages compute the tab list from collectGenres(signedManga)
- Card captions (GenreTabs + TrendingCarousel) show "冒险 · 恋爱 · 魔幻"
with truncation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
import { notFound } from "next/navigation";
|
|
import { prisma } from "@/lib/db";
|
|
import { signUrl } from "@/lib/r2";
|
|
import { parseGenres } from "@/lib/genres";
|
|
import { ChapterList } from "@/components/ChapterList";
|
|
import { ReadingProgressButton } from "@/components/ReadingProgressButton";
|
|
import type { Metadata } from "next";
|
|
|
|
type Props = {
|
|
params: Promise<{ slug: string }>;
|
|
};
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const manga = await prisma.manga.findUnique({ where: { slug } });
|
|
if (!manga) return { title: "Not Found" };
|
|
return {
|
|
title: manga.title,
|
|
description: manga.description,
|
|
openGraph: {
|
|
title: manga.title,
|
|
description: manga.description,
|
|
images: [manga.coverUrl],
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function MangaDetailPage({ params }: Props) {
|
|
const { slug } = await params;
|
|
|
|
const manga = await prisma.manga.findUnique({
|
|
where: { slug },
|
|
include: {
|
|
chapters: {
|
|
orderBy: { number: "asc" },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!manga) notFound();
|
|
|
|
const signedCoverUrl = await signUrl(manga.coverUrl);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
|
{/* Hero section */}
|
|
<div className="flex gap-4 mb-6">
|
|
<div className="w-28 sm:w-36 shrink-0">
|
|
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
|
|
<img
|
|
src={signedCoverUrl}
|
|
alt={manga.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0 py-1">
|
|
<h1 className="text-xl sm:text-2xl font-bold leading-tight mb-2">
|
|
{manga.title}
|
|
</h1>
|
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
{parseGenres(manga.genre).map((g) => (
|
|
<span
|
|
key={g}
|
|
className="px-2 py-0.5 text-[11px] font-semibold bg-accent/20 text-accent rounded-full"
|
|
>
|
|
{g}
|
|
</span>
|
|
))}
|
|
<span className="px-2 py-0.5 text-[11px] font-semibold bg-surface text-muted rounded-full border border-border">
|
|
{manga.status}
|
|
</span>
|
|
<span className="text-xs text-muted">
|
|
{manga.chapters.length} chapter
|
|
{manga.chapters.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-muted leading-relaxed line-clamp-4 sm:line-clamp-none">
|
|
{manga.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{manga.chapters.length > 0 && (
|
|
<ReadingProgressButton
|
|
mangaSlug={manga.slug}
|
|
chapters={manga.chapters.map((c) => ({
|
|
number: c.number,
|
|
title: c.title,
|
|
}))}
|
|
/>
|
|
)}
|
|
|
|
{/* Chapters */}
|
|
<div>
|
|
<h2 className="text-lg font-bold mb-3">Chapters</h2>
|
|
<ChapterList chapters={manga.chapters} mangaSlug={manga.slug} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|