Compare commits
No commits in common. "3745f1f3166347cccc1f0221001ca2290980915a" and "26b620de2f93846e7a5f13e68578dff2d3b513c7" have entirely different histories.
3745f1f316
...
26b620de2f
@ -1,12 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { writeLastReadChapter } from "@/components/ReadingProgressButton";
|
||||||
readProgress,
|
|
||||||
writeProgress,
|
|
||||||
} from "@/components/ReadingProgressButton";
|
|
||||||
|
|
||||||
type ChapterMeta = {
|
type ChapterMeta = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -51,30 +48,12 @@ export function PageReader({
|
|||||||
const [pages, setPages] = useState<LoadedPage[]>([]);
|
const [pages, setPages] = useState<LoadedPage[]>([]);
|
||||||
const [currentChapterNum, setCurrentChapterNum] =
|
const [currentChapterNum, setCurrentChapterNum] =
|
||||||
useState(startChapterNumber);
|
useState(startChapterNumber);
|
||||||
const [currentPageNum, setCurrentPageNum] = useState(() => {
|
|
||||||
if (typeof window === "undefined") return 1;
|
|
||||||
const p = readProgress(mangaSlug);
|
|
||||||
if (p && p.chapter === startChapterNumber && p.page > 1) return p.page;
|
|
||||||
return 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hiddenByScrollRef = useRef(false);
|
const hiddenByScrollRef = useRef(false);
|
||||||
const fetchChapterIdxRef = useRef(
|
const fetchChapterIdxRef = useRef(
|
||||||
chapters.findIndex((c) => c.number === startChapterNumber)
|
chapters.findIndex((c) => c.number === startChapterNumber)
|
||||||
);
|
);
|
||||||
// Initialize offset from saved progress so the first fetch starts AT the
|
|
||||||
// user's last-read page — previous pages are skipped entirely
|
|
||||||
const offsetRef = useRef(0);
|
const offsetRef = useRef(0);
|
||||||
const initialPageRef = useRef(1);
|
|
||||||
const offsetInitedRef = useRef(false);
|
|
||||||
if (!offsetInitedRef.current && typeof window !== "undefined") {
|
|
||||||
offsetInitedRef.current = true;
|
|
||||||
const p = readProgress(mangaSlug);
|
|
||||||
if (p && p.chapter === startChapterNumber && p.page > 1) {
|
|
||||||
offsetRef.current = p.page - 1;
|
|
||||||
initialPageRef.current = p.page;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const doneRef = useRef(false);
|
const doneRef = useRef(false);
|
||||||
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
|
// Count of pages already loaded — tracked via ref so fetchBatch stays stable
|
||||||
@ -84,13 +63,6 @@ export function PageReader({
|
|||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
const pageRefsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
// Reverse-fetch cursor: the offset of the first loaded page of the
|
|
||||||
// starting chapter. Used to prepend previous pages when the user scrolls
|
|
||||||
// up from a resumed mid-chapter position.
|
|
||||||
const prependOffsetRef = useRef(offsetRef.current);
|
|
||||||
const prependLoadingRef = useRef(false);
|
|
||||||
const prependExhaustedRef = useRef(offsetRef.current === 0);
|
|
||||||
|
|
||||||
const advanceChapterOrFinish = useCallback(() => {
|
const advanceChapterOrFinish = useCallback(() => {
|
||||||
if (fetchChapterIdxRef.current + 1 < chapters.length) {
|
if (fetchChapterIdxRef.current + 1 < chapters.length) {
|
||||||
fetchChapterIdxRef.current += 1;
|
fetchChapterIdxRef.current += 1;
|
||||||
@ -100,71 +72,6 @@ export function PageReader({
|
|||||||
}
|
}
|
||||||
}, [chapters.length]);
|
}, [chapters.length]);
|
||||||
|
|
||||||
const startChapter = useMemo(
|
|
||||||
() => chapters.find((c) => c.number === startChapterNumber),
|
|
||||||
[chapters, startChapterNumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
const prependBatch = useCallback(async () => {
|
|
||||||
if (
|
|
||||||
prependLoadingRef.current ||
|
|
||||||
prependExhaustedRef.current ||
|
|
||||||
!startChapter
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const currentOffset = prependOffsetRef.current;
|
|
||||||
const newOffset = Math.max(0, currentOffset - BATCH_SIZE);
|
|
||||||
const limit = currentOffset - newOffset;
|
|
||||||
if (limit <= 0) {
|
|
||||||
prependExhaustedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prependLoadingRef.current = true;
|
|
||||||
const beforeHeight = document.documentElement.scrollHeight;
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/pages?chapterId=${startChapter.id}&offset=${newOffset}&limit=${limit}`
|
|
||||||
);
|
|
||||||
const batch: RawPage[] = await res.json();
|
|
||||||
if (batch.length === 0) {
|
|
||||||
prependExhaustedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prependOffsetRef.current = newOffset;
|
|
||||||
if (newOffset === 0) prependExhaustedRef.current = true;
|
|
||||||
|
|
||||||
// Shift forward-fetch trigger indices + total count since every
|
|
||||||
// already-loaded page has moved right by batch.length
|
|
||||||
const shift = batch.length;
|
|
||||||
const shifted = new Set<number>();
|
|
||||||
for (const t of triggerIndicesRef.current) shifted.add(t + shift);
|
|
||||||
triggerIndicesRef.current = shifted;
|
|
||||||
loadedCountRef.current += shift;
|
|
||||||
|
|
||||||
setPages((prev) => [
|
|
||||||
...batch.map((p) => ({
|
|
||||||
chapterNumber: startChapter.number,
|
|
||||||
pageNumber: p.number,
|
|
||||||
imageUrl: p.imageUrl,
|
|
||||||
})),
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// aspect-[3/4] on the prepended <img>s reserves height before their
|
|
||||||
// bytes arrive, so scrollHeight is accurate immediately after React
|
|
||||||
// commits. One scrollBy keeps the previously-visible page anchored.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const afterHeight = document.documentElement.scrollHeight;
|
|
||||||
const delta = afterHeight - beforeHeight;
|
|
||||||
if (delta > 0) window.scrollBy({ top: delta });
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore — user can scroll-up again to retry
|
|
||||||
} finally {
|
|
||||||
prependLoadingRef.current = false;
|
|
||||||
}
|
|
||||||
}, [startChapter]);
|
|
||||||
|
|
||||||
const fetchBatch = useCallback(async () => {
|
const fetchBatch = useCallback(async () => {
|
||||||
if (loadingRef.current || doneRef.current) return;
|
if (loadingRef.current || doneRef.current) return;
|
||||||
const chapter = chapters[fetchChapterIdxRef.current];
|
const chapter = chapters[fetchChapterIdxRef.current];
|
||||||
@ -325,29 +232,14 @@ export function PageReader({
|
|||||||
hiddenByScrollRef.current = true;
|
hiddenByScrollRef.current = true;
|
||||||
setShowUI(false);
|
setShowUI(false);
|
||||||
}
|
}
|
||||||
// Trigger backward prepend when user approaches the top — fire early
|
let current = startChapterNumber;
|
||||||
// so the DOM/pics are already spawned by the time the user scrolls there
|
|
||||||
if (
|
|
||||||
y < 2500 &&
|
|
||||||
!prependLoadingRef.current &&
|
|
||||||
!prependExhaustedRef.current
|
|
||||||
) {
|
|
||||||
prependBatch();
|
|
||||||
}
|
|
||||||
// Nothing loaded yet — don't overwrite the resumed-page state
|
|
||||||
if (pages.length === 0) return;
|
|
||||||
let chapter = pages[0].chapterNumber;
|
|
||||||
let page = pages[0].pageNumber;
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
for (let i = 0; i < pages.length; i++) {
|
||||||
const el = pageRefsRef.current.get(i);
|
const el = pageRefsRef.current.get(i);
|
||||||
if (!el) continue;
|
if (!el) continue;
|
||||||
if (el.offsetTop <= y + 80) {
|
if (el.offsetTop <= y + 80) current = pages[i].chapterNumber;
|
||||||
chapter = pages[i].chapterNumber;
|
else break;
|
||||||
page = pages[i].pageNumber;
|
|
||||||
} else break;
|
|
||||||
}
|
}
|
||||||
setCurrentChapterNum(chapter);
|
setCurrentChapterNum(current);
|
||||||
setCurrentPageNum(page);
|
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId) return;
|
if (rafId) return;
|
||||||
@ -358,17 +250,12 @@ export function PageReader({
|
|||||||
window.removeEventListener("scroll", onScroll);
|
window.removeEventListener("scroll", onScroll);
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [pages, startChapterNumber, prependBatch]);
|
}, [pages, startChapterNumber]);
|
||||||
|
|
||||||
// Persist progress as user scrolls. offsetRef has been pre-seeded above
|
// Persist reading progress whenever the visible chapter changes
|
||||||
// so the first fetched page IS the resumed page — no scroll restoration
|
|
||||||
// needed.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeProgress(mangaSlug, {
|
writeLastReadChapter(mangaSlug, currentChapterNum);
|
||||||
chapter: currentChapterNum,
|
}, [mangaSlug, currentChapterNum]);
|
||||||
page: currentPageNum,
|
|
||||||
});
|
|
||||||
}, [mangaSlug, currentChapterNum, currentPageNum]);
|
|
||||||
|
|
||||||
const currentChapter =
|
const currentChapter =
|
||||||
chapters.find((c) => c.number === currentChapterNum) ??
|
chapters.find((c) => c.number === currentChapterNum) ??
|
||||||
@ -458,7 +345,7 @@ export function PageReader({
|
|||||||
<img
|
<img
|
||||||
src={page.imageUrl}
|
src={page.imageUrl}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
className="w-full h-auto block align-bottom -mb-px aspect-[3/4] [-webkit-touch-callout:none]"
|
className="w-full h-auto block align-bottom -mb-px [-webkit-touch-callout:none]"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,59 +13,34 @@ type Props = {
|
|||||||
chapters: ChapterLite[];
|
chapters: ChapterLite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReadingProgress = {
|
|
||||||
chapter: number;
|
|
||||||
page: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function storageKey(slug: string) {
|
function storageKey(slug: string) {
|
||||||
return `sunnymh:last-read:${slug}`;
|
return `sunnymh:last-read:${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readProgress(slug: string): ReadingProgress | null {
|
export function readLastReadChapter(slug: string): number | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
const raw = window.localStorage.getItem(storageKey(slug));
|
const raw = window.localStorage.getItem(storageKey(slug));
|
||||||
if (!raw) return null;
|
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);
|
const n = Number(raw);
|
||||||
return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null;
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeProgress(slug: string, progress: ReadingProgress) {
|
export function writeLastReadChapter(slug: string, chapter: number) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(progress));
|
window.localStorage.setItem(storageKey(slug), String(chapter));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
export function ReadingProgressButton({ mangaSlug, chapters }: Props) {
|
||||||
const [progress, setProgress] = useState<ReadingProgress | null>(null);
|
const [lastRead, setLastRead] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProgress(readProgress(mangaSlug));
|
setLastRead(readLastReadChapter(mangaSlug));
|
||||||
}, [mangaSlug]);
|
}, [mangaSlug]);
|
||||||
|
|
||||||
if (chapters.length === 0) return null;
|
if (chapters.length === 0) return null;
|
||||||
const first = chapters[0];
|
const first = chapters[0];
|
||||||
const resumeChapter =
|
const resumeChapter =
|
||||||
progress !== null
|
lastRead !== null ? chapters.find((c) => c.number === lastRead) : null;
|
||||||
? chapters.find((c) => c.number === progress.chapter)
|
|
||||||
: null;
|
|
||||||
const target = resumeChapter ?? first;
|
const target = resumeChapter ?? first;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user