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

104 lines
3.5 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { parseGenres } from "@/lib/genres";
type MangaItem = {
slug: string;
title: string;
coverUrl: string;
genre: string;
_count?: { chapters: number };
};
export function GenreTabs({
manga,
genres,
}: {
manga: MangaItem[];
genres: string[];
}) {
const [activeGenre, setActiveGenre] = useState("All");
const allGenres = ["All", ...genres];
const filtered =
activeGenre === "All"
? manga
: manga.filter((m) => parseGenres(m.genre).includes(activeGenre));
return (
<section>
{/* Section header */}
<div className="flex items-center gap-2.5 mb-4">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="w-5 h-5 text-accent"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<h2 className="text-lg font-bold">Browse by Genre</h2>
</div>
{/* Horizontal scrollable genre tabs */}
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-4">
{allGenres.map((genre) => (
<button
key={genre}
onClick={() => setActiveGenre(genre)}
className={`shrink-0 px-4 py-2 text-sm font-semibold rounded-full border transition-all ${
activeGenre === genre
? "bg-accent text-white border-accent"
: "bg-surface text-muted border-border hover:text-foreground hover:border-muted"
}`}
>
{genre}
</button>
))}
</div>
{/* Filtered manga grid */}
{filtered.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3 sm:gap-4">
{filtered.map((m) => (
<Link key={m.slug} href={`/manga/${m.slug}`} className="group block relative">
{m._count && m._count.chapters > 0 && (
<span className="absolute -top-2 -right-2 z-10 min-w-[30px] h-[30px] flex items-center justify-center px-1.5 text-sm font-bold bg-accent text-white rounded-lg shadow-[2px_4px_12px_rgba(0,0,0,0.5),0_1px_3px_rgba(0,0,0,0.3)] border border-white/20">
{m._count.chapters}
</span>
)}
<div className="relative aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img
src={m.coverUrl}
alt={m.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-2.5">
<h3 className="text-[12px] sm:text-[13px] font-semibold text-white leading-tight line-clamp-2">
{m.title}
</h3>
<p className="text-[10px] text-white/50 mt-0.5 truncate">
{parseGenres(m.genre).join(" · ")}
</p>
</div>
</div>
</Link>
))}
</div>
) : (
<p className="text-muted text-center py-12 text-sm">
No manga in this genre yet
</p>
)}
</section>
);
}