From 26b620de2f93846e7a5f13e68578dff2d3b513c7 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 12 Apr 2026 10:27:28 +0800 Subject: [PATCH] Add reading-progress resume + multi-genre support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/genre/page.tsx | 3 +- app/manga/[slug]/page.tsx | 27 +++++++----- app/page.tsx | 4 +- components/GenreTabs.tsx | 7 ++- components/PageReader.tsx | 10 ++++- components/ReadingProgressButton.tsx | 64 ++++++++++++++++++++++++++++ components/TrendingCarousel.tsx | 5 ++- lib/genres.ts | 31 ++++++++++++++ 8 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 components/ReadingProgressButton.tsx create mode 100644 lib/genres.ts diff --git a/app/genre/page.tsx b/app/genre/page.tsx index cc6340e..79493d2 100644 --- a/app/genre/page.tsx +++ b/app/genre/page.tsx @@ -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 (
diff --git a/app/manga/[slug]/page.tsx b/app/manga/[slug]/page.tsx index 23cf4dd..941fa05 100644 --- a/app/manga/[slug]/page.tsx +++ b/app/manga/[slug]/page.tsx @@ -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}
- - {manga.genre} - + {parseGenres(manga.genre).map((g) => ( + + {g} + + ))} {manga.status} @@ -74,14 +81,14 @@ export default async function MangaDetailPage({ params }: Props) {
- {/* Continue reading button */} {manga.chapters.length > 0 && ( - - 开始阅读 - + ({ + number: c.number, + title: c.title, + }))} + /> )} {/* Chapters */} diff --git a/app/page.tsx b/app/page.tsx index ccb248b..2276f7b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
diff --git a/components/GenreTabs.tsx b/components/GenreTabs.tsx index 5183968..44ce709 100644 --- a/components/GenreTabs.tsx +++ b/components/GenreTabs.tsx @@ -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 (
@@ -84,7 +85,9 @@ export function GenreTabs({

{m.title}

-

{m.genre}

+

+ {parseGenres(m.genre).join(" · ")} +

diff --git a/components/PageReader.tsx b/components/PageReader.tsx index f0c9a50..6484d1b 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -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({

{mangaTitle}

- Back to Manga + Back to Home )} diff --git a/components/ReadingProgressButton.tsx b/components/ReadingProgressButton.tsx new file mode 100644 index 0000000..eb7abf3 --- /dev/null +++ b/components/ReadingProgressButton.tsx @@ -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(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 ( + + {resumeChapter ? ( + <> + 继续阅读 + · + + #{resumeChapter.number} {resumeChapter.title} + + + ) : ( + "开始阅读" + )} + + ); +} diff --git a/components/TrendingCarousel.tsx b/components/TrendingCarousel.tsx index 20bba87..4004410 100644 --- a/components/TrendingCarousel.tsx +++ b/components/TrendingCarousel.tsx @@ -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[] }) {

{m.title}

-

- {m.genre} +

+ {parseGenres(m.genre).join(" · ")}

diff --git a/lib/genres.ts b/lib/genres.ts new file mode 100644 index 0000000..7a29974 --- /dev/null +++ b/lib/genres.ts @@ -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(); + 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(); + for (const m of mangas) { + for (const g of parseGenres(m.genre)) seen.add(g); + } + return [...seen].sort(); +}