sunnymh-manga-site/components/ReadingProgressButton.tsx
yiekheng 43a2a6d3f8 Rewrite reader around known-dimension page placeholders
Replaces the prepend/flushSync/scrollBy gymnastics with placeholder divs
sized by each page's width/height. Document height is correct from the
first paint, so resume + backward scroll just work — no scroll
compensation, no gesture fights, no forced aspect ratio distorting images.

- New /api/chapters/[id]/meta returns the dim skeleton for any chapter.
- Chapter page pre-fetches the starting chapter's meta server-side and
  parallelizes the two Prisma queries via Promise.all.
- Reader renders placeholders with aspectRatio: w/h, lazy-loads image
  URLs in batches via IntersectionObserver, and prefetches the next
  chapter's meta ~3 pages from the end.
- Scroll tracker walks only the intersecting-pages set (~3–5 elements)
  instead of every loaded page per rAF.
- scroll={false} on all Links into the reader + { scroll: false } on
  double-tap router.push, plus a belt-and-suspenders rAF re-scroll, so
  resume survives soft navigation and browser scroll-restoration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:01:41 +08:00

91 lines
2.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type ChapterLite = {
number: number;
title: string;
};
type Props = {
mangaSlug: string;
chapters: ChapterLite[];
};
export type ReadingProgress = {
chapter: number;
page: number;
};
function storageKey(slug: string) {
return `sunnymh:last-read:${slug}`;
}
export function readProgress(slug: string): ReadingProgress | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(storageKey(slug));
if (!raw) return null;
// New format: JSON { chapter, page }
if (raw.startsWith("{")) {
try {
const parsed = JSON.parse(raw) as ReadingProgress;
if (
typeof parsed.chapter === "number" &&
typeof parsed.page === "number" &&
parsed.chapter > 0 &&
parsed.page > 0
) {
return parsed;
}
} catch {
return null;
}
return null;
}
// Legacy format: bare chapter number
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
}
export function writeProgress(slug: string, progress: ReadingProgress) {
if (typeof window === "undefined") return;
window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
}
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
const [progress, setProgress] = useState<ReadingProgress | null>(null);
useEffect(() => {
setProgress(readProgress(mangaSlug));
}, [mangaSlug]);
if (chapters.length === 0) return null;
const first = chapters[0];
const resumeChapter =
progress !== null
? chapters.find((c) => c.number === progress.chapter)
: null;
const target = resumeChapter ?? first;
return (
<Link
href={`/manga/${mangaSlug}/${target.number}`}
scroll={false}
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>
);
}