yiekheng 26b620de2f Add reading-progress resume + multi-genre support
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>
2026-04-12 10:27:28 +08:00

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