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>
This commit is contained in:
parent
06dcf0a649
commit
26b620de2f
@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { collectGenres } from "@/lib/genres";
|
||||
import { GenreTabs } from "@/components/GenreTabs";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
@ -17,7 +18,7 @@ export default async function GenrePage() {
|
||||
});
|
||||
|
||||
const signedManga = await signCoverUrls(manga);
|
||||
const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
|
||||
const genres = collectGenres(signedManga);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-5">
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
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 = {
|
||||
@ -57,9 +59,14 @@ export default async function MangaDetailPage({ params }: Props) {
|
||||
{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>
|
||||
{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>
|
||||
@ -74,14 +81,14 @@ export default async function MangaDetailPage({ params }: Props) {
|
||||
</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]"
|
||||
>
|
||||
开始阅读
|
||||
</a>
|
||||
<ReadingProgressButton
|
||||
mangaSlug={manga.slug}
|
||||
chapters={manga.chapters.map((c) => ({
|
||||
number: c.number,
|
||||
title: c.title,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { signCoverUrls } from "@/lib/r2";
|
||||
import { collectGenres } from "@/lib/genres";
|
||||
import { TrendingCarousel } from "@/components/TrendingCarousel";
|
||||
import { GenreTabs } from "@/components/GenreTabs";
|
||||
|
||||
@ -17,8 +18,7 @@ export default async function Home() {
|
||||
// Top 10 for trending
|
||||
const trending = signedManga.slice(0, 10);
|
||||
|
||||
// Extract unique genres
|
||||
const genres = [...new Set(signedManga.map((m) => m.genre))].sort();
|
||||
const genres = collectGenres(signedManga);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseGenres } from "@/lib/genres";
|
||||
|
||||
type MangaItem = {
|
||||
slug: string;
|
||||
@ -24,7 +25,7 @@ export function GenreTabs({
|
||||
const filtered =
|
||||
activeGenre === "All"
|
||||
? manga
|
||||
: manga.filter((m) => m.genre === activeGenre);
|
||||
: manga.filter((m) => parseGenres(m.genre).includes(activeGenre));
|
||||
|
||||
return (
|
||||
<section>
|
||||
@ -84,7 +85,9 @@ export function GenreTabs({
|
||||
<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>
|
||||
<p className="text-[10px] text-white/50 mt-0.5 truncate">
|
||||
{parseGenres(m.genre).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { writeLastReadChapter } from "@/components/ReadingProgressButton";
|
||||
|
||||
type ChapterMeta = {
|
||||
id: number;
|
||||
@ -251,6 +252,11 @@ export function PageReader({
|
||||
};
|
||||
}, [pages, startChapterNumber]);
|
||||
|
||||
// Persist reading progress whenever the visible chapter changes
|
||||
useEffect(() => {
|
||||
writeLastReadChapter(mangaSlug, currentChapterNum);
|
||||
}, [mangaSlug, currentChapterNum]);
|
||||
|
||||
const currentChapter =
|
||||
chapters.find((c) => c.number === currentChapterNum) ??
|
||||
chapters.find((c) => c.number === startChapterNumber);
|
||||
@ -354,10 +360,10 @@ export function PageReader({
|
||||
</p>
|
||||
<p className="text-base font-semibold">{mangaTitle}</p>
|
||||
<Link
|
||||
href={`/manga/${mangaSlug}`}
|
||||
href="/"
|
||||
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-semibold transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Back to Manga
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
64
components/ReadingProgressButton.tsx
Normal file
64
components/ReadingProgressButton.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type ChapterLite = {
|
||||
number: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mangaSlug: string;
|
||||
chapters: ChapterLite[];
|
||||
};
|
||||
|
||||
function storageKey(slug: string) {
|
||||
return `sunnymh:last-read:${slug}`;
|
||||
}
|
||||
|
||||
export function readLastReadChapter(slug: string): number | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage.getItem(storageKey(slug));
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
export function writeLastReadChapter(slug: string, chapter: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(storageKey(slug), String(chapter));
|
||||
}
|
||||
|
||||
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
||||
const [lastRead, setLastRead] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLastRead(readLastReadChapter(mangaSlug));
|
||||
}, [mangaSlug]);
|
||||
|
||||
if (chapters.length === 0) return null;
|
||||
const first = chapters[0];
|
||||
const resumeChapter =
|
||||
lastRead !== null ? chapters.find((c) => c.number === lastRead) : null;
|
||||
const target = resumeChapter ?? first;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/manga/${mangaSlug}/${target.number}`}
|
||||
className="flex items-center justify-center gap-3 w-full py-3 mb-6 px-4 text-sm font-semibold bg-accent hover:bg-accent-hover text-white rounded-xl transition-colors active:scale-[0.98]"
|
||||
>
|
||||
{resumeChapter ? (
|
||||
<>
|
||||
<span>继续阅读</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<span className="truncate">
|
||||
#{resumeChapter.number} {resumeChapter.title}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"开始阅读"
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseGenres } from "@/lib/genres";
|
||||
|
||||
type TrendingManga = {
|
||||
slug: string;
|
||||
@ -138,8 +139,8 @@ export function TrendingCarousel({ manga }: { manga: TrendingManga[] }) {
|
||||
<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 className="text-[11px] text-white/50 mt-0.5 truncate">
|
||||
{parseGenres(m.genre).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
31
lib/genres.ts
Normal file
31
lib/genres.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A manga's `genre` field may hold multiple comma-separated genres
|
||||
* (e.g. "冒险, 恋爱, 魔幻"). This normalizes the raw string into a
|
||||
* deduped, trimmed list.
|
||||
*/
|
||||
export function parseGenres(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of raw.split(",")) {
|
||||
const g = part.trim();
|
||||
if (!g) continue;
|
||||
if (seen.has(g)) continue;
|
||||
seen.add(g);
|
||||
out.push(g);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten all genres across a collection of manga into a sorted unique list.
|
||||
*/
|
||||
export function collectGenres(
|
||||
mangas: { genre: string }[]
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const m of mangas) {
|
||||
for (const g of parseGenres(m.genre)) seen.add(g);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user