[BETA] Integrated Infinite Scroll & Added Chapter Grid View

This commit is contained in:
Youwes09
2026-02-21 23:15:32 -06:00
parent b921b5eb99
commit 7ab3cf3df3
7 changed files with 564 additions and 133 deletions
+227 -48
View File
@@ -10,7 +10,7 @@ import {
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { useStore, type FitMode } from "../../store";
import { matchesKeybind } from "../../lib/keybinds";
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
import s from "./Reader.module.css";
function preloadImage(url: string) {
@@ -126,6 +126,16 @@ function ZoomPopover({
}
// ── Reader ────────────────────────────────────────────────────────────────────
/** One chapter's worth of pages in the infinite strip */
interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
/** Global page index offset for pages in this strip chunk */
startGlobalIdx: number;
}
export default function Reader() {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
@@ -135,6 +145,11 @@ export default function Reader() {
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const uiRef = useRef<HTMLDivElement>(null);
// Track which chapters are being fetched so we don't double-fire
const fetchingRef = useRef<Set<number>>(new Set());
// Whether we've already appended the next chapter into the strip
const appendedRef = useRef<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dlOpen, setDlOpen] = useState(false);
@@ -143,6 +158,18 @@ export default function Reader() {
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
const [pageGroups, setPageGroups] = useState<number[][]>([]);
/**
* The infinite strip: an ordered list of chapter chunks.
* In non-longstrip modes this is unused — only pageUrls matters.
*/
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
/**
* In longstrip autoNext mode, this tracks which chapter the user is
* currently reading (for topbar display) without triggering a full reload.
*/
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
const {
activeManga, activeChapter, activeChapterList,
pageUrls, pageNumber, settings,
@@ -182,19 +209,53 @@ export default function Reader() {
containerRef.current?.focus({ preventScroll: true });
}, [activeChapter?.id]);
// ── Fetch helpers ────────────────────────────────────────────────────────────
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
const cached = pageCache.current.get(chapterId);
if (cached) return cached;
if (fetchingRef.current.has(chapterId)) {
// Poll until another in-flight fetch resolves
return new Promise((resolve) => {
const interval = setInterval(() => {
const c = pageCache.current.get(chapterId);
if (c) { clearInterval(interval); resolve(c); }
}, 50);
});
}
fetchingRef.current.add(chapterId);
const d = await gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId }
);
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(chapterId, urls);
fetchingRef.current.delete(chapterId);
return urls;
}, []);
// ── Load pages ──────────────────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter) return;
setLoading(true); setError(null); setPageGroups([]);
const cached = pageCache.current.get(activeChapter.id);
if (cached) { setPageUrls(cached); setLoading(false); return; }
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id })
.then((d) => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(activeChapter.id, urls);
// Reset strip state for new chapter navigation (non-scroll transitions)
appendedRef.current = new Set();
fetchPages(activeChapter.id)
.then((urls) => {
setPageUrls(urls);
if (style === "longstrip" && autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
setStripChapters([]);
setVisibleChapterId(null);
}
})
.catch((e) => setError(e.message))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [activeChapter?.id]);
@@ -263,13 +324,9 @@ export default function Reader() {
useEffect(() => {
const preload = (id: number) => {
if (pageCache.current.has(id)) return;
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id })
.then((d) => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(id, urls);
urls.slice(0, 2).forEach(preloadImage);
}).catch(() => {});
fetchPages(id)
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
.catch(() => {});
};
if (adjacent.next) preload(adjacent.next.id);
if (adjacent.prev) preload(adjacent.prev.id);
@@ -277,6 +334,33 @@ export default function Reader() {
const lastPage = pageUrls.length;
/**
* In infinite-strip mode, the topbar shows whichever chapter the user is
* currently scrolled into rather than the "root" chapter we opened with.
*/
const displayChapter = useMemo(() => {
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
/**
* In infinite-strip mode, the "last page" shown in the topbar is relative
* to the currently visible chapter chunk.
*/
const visibleChunkLastPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
return chunk ? chunk.urls.length : lastPage;
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
/** Page number within the currently visible chapter chunk (for topbar) */
const visibleChunkPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
if (!chunk) return pageNumber;
return Math.max(1, pageNumber - chunk.startGlobalIdx);
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
// ── Auto-mark read + history ─────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter || !lastPage) return;
@@ -356,27 +440,36 @@ export default function Reader() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
// Escape: close overlays in priority order, then exit reader
if (e.key === "Escape") {
if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; }
if (dlOpen) { e.preventDefault(); setDlOpen(false); return; }
e.preventDefault();
if (zoomOpen) { setZoomOpen(false); return; }
if (dlOpen) { setDlOpen(false); return; }
closeReader();
return;
}
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]);
// ── Longstrip scroll tracker ─────────────────────────────────────────────────
// Tracks current page number and auto-advances to next chapter at end of scroll
// Tracks current page number. In autoNext mode, appends the next chapter's
// pages directly into the strip (no re-render / scroll reset) so the flow
// is one seamless ribbon of images.
useEffect(() => {
const el = containerRef.current;
if (!el || style !== "longstrip") return;
@@ -399,11 +492,63 @@ export default function Reader() {
const n = closest + 1;
if (n !== pageNumRef.current) setPageNumber(n);
// Auto-advance: within 80px of bottom and next chapter exists
if (autoNext && adjacent.next) {
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (nearBottom) openReader(adjacent.next, activeChapterList);
// ── Infinite append ──────────────────────────────────────────────────
if (!autoNext) {
// Classic behavior: jump to next chapter at the very end of scroll
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
return;
}
// Silently update visibleChapterId as we scroll into each chunk
for (const chunk of stripChapters) {
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
if (chunk.chapterId !== visibleChapterId) {
setVisibleChapterId(chunk.chapterId);
// Mark as read when we scroll into a new chapter
if (!markedRead.has(chunk.chapterId) && settings.autoMarkRead) {
const prevChunk = stripChapters[stripChapters.indexOf(chunk) - 1];
if (prevChunk) {
setMarkedRead((r) => new Set(r).add(prevChunk.chapterId));
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
}
}
}
break;
}
}
// Append next chapter 300px before we hit the bottom of the last chunk
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
if (!nearBottom) return;
// What's the last chapter currently in the strip?
const lastChunk = stripChapters[stripChapters.length - 1];
if (!lastChunk) return;
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return;
const nextChEntry = activeChapterList[lastChunkIdx + 1];
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
// Mark immediately so concurrent scroll events don't double-append
appendedRef.current.add(nextChEntry.id);
// Fetch (likely already cached from preload) then append to strip
fetchPages(nextChEntry.id).then((urls) => {
setStripChapters((prev) => {
const lastInPrev = prev[prev.length - 1];
const newStart = lastInPrev
? lastInPrev.startGlobalIdx + lastInPrev.urls.length
: 0;
return [
...prev,
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
];
});
}).catch(console.error);
});
};
@@ -412,17 +557,38 @@ export default function Reader() {
el.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
};
}, [style, autoNext, adjacent.next?.id, activeChapterList]);
}, [style, autoNext, stripChapters, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
// Reset scroll position when switching chapters in non-longstrip modes
useEffect(() => {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [pageNumber, style]);
// When switching to longstrip, reset scroll to top
// When switching to longstrip, reset scroll to top and rebuild strip from current chapter
useEffect(() => {
if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [activeChapter?.id, style]);
if (style === "longstrip" && containerRef.current) {
containerRef.current.scrollTop = 0;
if (activeChapter && pageUrls.length > 0) {
appendedRef.current = new Set();
if (autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls: pageUrls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
// Plain longstrip — no multi-chapter strip
setStripChapters([]);
setVisibleChapterId(null);
}
}
} else if (style !== "longstrip") {
setStripChapters([]);
setVisibleChapterId(null);
}
}, [activeChapter?.id, style, autoNext]);
function handleTap(e: React.MouseEvent) {
if (style === "longstrip") return;
@@ -492,9 +658,9 @@ export default function Reader() {
<span className={s.chLabel}>
<span className={s.chTitle}>{activeManga?.title}</span>
<span className={s.chSep}>/</span>
<span>{activeChapter?.name}</span>
<span>{displayChapter?.name}</span>
</span>
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
<button
className={s.iconBtn}
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
@@ -590,17 +756,30 @@ export default function Reader() {
}}
>
{style === "longstrip" ? (
pageUrls.map((url, i) => (
<img
key={`${activeChapter?.id}-${i}`}
src={url}
alt={`Page ${i + 1}`}
data-page={i + 1}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={i < 3 ? "eager" : "lazy"}
decoding="async"
/>
))
<>
{(autoNext && stripChapters.length > 0 ? stripChapters : [{
chapterId: activeChapter?.id ?? 0,
chapterName: activeChapter?.name ?? "",
urls: pageUrls,
startGlobalIdx: 0,
}]).map((chunk) =>
chunk.urls.map((url, i) => {
const globalIdx = chunk.startGlobalIdx + i;
return (
<img
key={`${chunk.chapterId}-${i}`}
src={url}
alt={`${chunk.chapterName} Page ${i + 1}`}
data-page={globalIdx + 1}
data-chapter={chunk.chapterId}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={globalIdx < 3 ? "eager" : "lazy"}
decoding="async"
/>
);
})
)}
</>
) : (
<img
key={pageNumber}
+121 -1
View File
@@ -573,4 +573,124 @@
color: var(--text-faint); font-size: 10px; background: none;
transition: color var(--t-base), background var(--t-base);
}
.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
/* ── View mode toggle ── */
.viewToggleBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
color: var(--text-faint);
background: none;
cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.viewToggleBtn:hover { color: var(--text-muted); background: var(--bg-raised); border-color: var(--border-dim); }
.viewToggleActive { color: var(--accent-fg) !important; background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
/* ── Chapter grid ── */
.grid {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 5px;
align-content: start;
}
.gridCell {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-raised);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast);
}
.gridCell:hover {
border-color: var(--accent);
background: var(--bg-overlay);
transform: scale(1.04);
z-index: 1;
}
/* Unread — subtle, inviting */
.gridCellNum {
font-family: var(--font-ui);
font-size: var(--text-2xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
color: var(--text-secondary);
line-height: 1;
position: relative;
z-index: 1;
}
/* Read — dimmed, clearly consumed */
.gridCellRead {
background: var(--bg-base);
border-color: var(--border-dim);
}
.gridCellRead .gridCellNum {
color: var(--text-faint);
}
.gridCellRead::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(135deg, transparent 60%, rgba(var(--accent-rgb, 100 130 255) / 0.08) 100%);
pointer-events: none;
}
/* In-progress — accent highlight on bottom edge */
.gridCellInProgress {
border-color: var(--accent-dim);
background: var(--bg-raised);
}
.gridCellInProgress .gridCellNum {
color: var(--accent-fg);
}
.gridCellInProgress::before {
content: "";
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px;
background: var(--accent);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* Read indicator dot (top-right corner) */
.gridCellDot {
position: absolute; top: 3px; right: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--text-faint);
}
/* Bookmark indicator dot */
.gridCellBookmarked { border-color: var(--accent-dim); }
.gridCellBookmarkDot {
position: absolute; top: 3px; left: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--accent);
}
/* Spinner overlay for enqueueing */
.gridCellSpinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3);
color: var(--text-faint);
}
/* Skeleton for grid loading state */
.gridCellSkeleton {
aspect-ratio: 1;
border-radius: var(--radius-sm);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
display: flex; align-items: center; justify-content: center;
padding: var(--sp-2);
}
+77 -21
View File
@@ -3,6 +3,7 @@ import {
ArrowLeft, BookmarkSimple, Download, CheckCircle,
ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
@@ -52,6 +53,7 @@ export default function SeriesDetail() {
const [ctx, setCtx] = useState<CtxState | null>(null);
const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const sortDir = settings.chapterSortDir;
@@ -334,20 +336,33 @@ export default function SeriesDetail() {
{/* ── Chapter list ── */}
<div className={s.listWrap}>
<div className={s.listHeader}>
<button
className={s.sortBtn}
onClick={() => {
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
setChapterPage(1);
}}
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
>
{sortDir === "desc"
? <SortDescending size={14} weight="light" />
: <SortAscending size={14} weight="light" />
}
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
<div style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
<button
className={s.sortBtn}
onClick={() => {
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
setChapterPage(1);
}}
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
>
{sortDir === "desc"
? <SortDescending size={14} weight="light" />
: <SortAscending size={14} weight="light" />
}
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
<button
className={[s.viewToggleBtn, viewMode === "grid" ? s.viewToggleActive : ""].join(" ")}
onClick={() => setViewMode((v) => v === "list" ? "grid" : "list")}
title={viewMode === "list" ? "Switch to grid view" : "Switch to list view"}
>
{viewMode === "list"
? <SquaresFour size={14} weight="light" />
: <List size={14} weight="light" />
}
</button>
</div>
<div className={s.listHeaderRight}>
{/* Jump to chapter */}
@@ -448,14 +463,55 @@ export default function SeriesDetail() {
</div>
</div>
<div className={s.list}>
<div className={viewMode === "grid" ? s.grid : s.list}>
{loadingChapters && chapters.length === 0 ? (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={s.rowSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
</div>
))
viewMode === "grid" ? (
Array.from({ length: 24 }).map((_, i) => (
<div key={i} className={s.gridCellSkeleton}>
<div className="skeleton" style={{ width: "60%", height: 10, borderRadius: 3 }} />
</div>
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={s.rowSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
</div>
))
)
) : viewMode === "grid" ? (
sortedChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
return (
<button
key={ch.id}
className={[
s.gridCell,
ch.isRead ? s.gridCellRead : "",
inProgress ? s.gridCellInProgress : "",
ch.isBookmarked ? s.gridCellBookmarked : "",
].filter(Boolean).join(" ")}
onClick={() => openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
title={ch.name}
>
<span className={s.gridCellNum}>
{ch.chapterNumber % 1 === 0
? ch.chapterNumber.toFixed(0)
: ch.chapterNumber.toString()}
</span>
{ch.isRead && <span className={s.gridCellDot} />}
{inProgress && <span className={s.gridCellProgress} style={{ width: `${Math.min(100, ((ch.lastPageRead ?? 0) / 1) * 100)}%` }} />}
{ch.isBookmarked && <span className={s.gridCellBookmarkDot} />}
{enqueueing.has(ch.id) && (
<span className={s.gridCellSpinner}>
<CircleNotch size={10} weight="light" className="anim-spin" />
</span>
)}
</button>
);
})
) : (
pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
+7 -1
View File
@@ -422,6 +422,12 @@ export default function SettingsModal() {
const updateSettings = useStore((s) => s.updateSettings);
const resetKeybinds = useStore((s) => s.resetKeybinds);
const backdropRef = useRef<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(null);
// Scroll to top on every tab switch
useEffect(() => {
contentBodyRef.current?.scrollTo({ top: 0 });
}, [tab]);
const handleBackdrop = useCallback(
(e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); },
@@ -455,7 +461,7 @@ export default function SettingsModal() {
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
</div>
<div className={s.contentBody}>
<div className={s.contentBody} ref={contentBodyRef}>
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}