mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Fixed Mark as Read Refresh + Auto Feature
This commit is contained in:
@@ -175,6 +175,24 @@
|
|||||||
border: 1px solid var(--accent-muted);
|
border: 1px solid var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unreadBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-1);
|
||||||
|
left: var(--sp-1);
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const MangaCard = memo(function MangaCard({
|
|||||||
{!!manga.downloadCount && (
|
{!!manga.downloadCount && (
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||||
)}
|
)}
|
||||||
|
{!!manga.unreadCount && (
|
||||||
|
<span className={s.unreadBadge}>{manga.unreadCount}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={s.title}>{manga.title}</p>
|
<p className={s.title}>{manga.title}</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +81,16 @@ export default function Library() {
|
|||||||
const addFolder = useStore((state) => state.addFolder);
|
const addFolder = useStore((state) => state.addFolder);
|
||||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||||
|
const activeChapter = useStore((state) => state.activeChapter);
|
||||||
|
|
||||||
|
|
||||||
|
const prevChapterRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = prevChapterRef.current !== null;
|
||||||
|
prevChapterRef.current = activeChapter?.id ?? null;
|
||||||
|
if (!wasOpen || activeChapter) return;
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}, [activeChapter]);
|
||||||
|
|
||||||
const loadData = useCallback((showLoading = false) => {
|
const loadData = useCallback((showLoading = false) => {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ export default function Reader() {
|
|||||||
const visibleChapterRef = useRef<number | null>(null);
|
const visibleChapterRef = useRef<number | null>(null);
|
||||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||||
const pageUrlsRef = useRef<string[]>([]);
|
const pageUrlsRef = useRef<string[]>([]);
|
||||||
|
const activeChapterRef = useRef<typeof activeChapter>(null);
|
||||||
|
const markReadOnNextRef = useRef(true);
|
||||||
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
|
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
|
||||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
||||||
|
|
||||||
@@ -239,10 +241,29 @@ export default function Reader() {
|
|||||||
const style = settings.pageStyle ?? "single";
|
const style = settings.pageStyle ?? "single";
|
||||||
const maxW = settings.maxPageWidth ?? 900;
|
const maxW = settings.maxPageWidth ?? 900;
|
||||||
const autoNext = settings.autoNextChapter ?? false;
|
const autoNext = settings.autoNextChapter ?? false;
|
||||||
|
const markReadOnNext = settings.markReadOnNext ?? true;
|
||||||
|
|
||||||
settingsRef.current = settings;
|
settingsRef.current = settings;
|
||||||
chapterListRef.current = activeChapterList;
|
chapterListRef.current = activeChapterList;
|
||||||
pageUrlsRef.current = pageUrls;
|
pageUrlsRef.current = pageUrls;
|
||||||
|
activeChapterRef.current = activeChapter;
|
||||||
|
markReadOnNextRef.current = markReadOnNext;
|
||||||
|
|
||||||
|
// Mark the current chapter read when the user manually skips to another chapter.
|
||||||
|
// Uses refs only — safe to call from any callback without stale-closure issues.
|
||||||
|
// markReadOnNext gates this; autoNextChapter does NOT block it because a manual
|
||||||
|
// chapter-skip is always intentional regardless of the auto-advance setting.
|
||||||
|
const maybeMarkCurrentRead = useCallback(() => {
|
||||||
|
const ch = activeChapterRef.current;
|
||||||
|
if (!ch) return;
|
||||||
|
if (!markReadOnNextRef.current) return;
|
||||||
|
if (markedReadRef.current.has(ch.id)) return;
|
||||||
|
markedReadRef.current.add(ch.id);
|
||||||
|
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true }).catch((e) => {
|
||||||
|
markedReadRef.current.delete(ch.id);
|
||||||
|
console.error("MARK_CHAPTER_READ (manual next) failed:", e);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── UI autohide ──────────────────────────────────────────────────────────────
|
// ── UI autohide ──────────────────────────────────────────────────────────────
|
||||||
const showUi = useCallback(() => {
|
const showUi = useCallback(() => {
|
||||||
@@ -294,8 +315,9 @@ export default function Reader() {
|
|||||||
fetchPages(targetId, ctrl.signal)
|
fetchPages(targetId, ctrl.signal)
|
||||||
.then(async (urls) => {
|
.then(async (urls) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
if (style !== "longstrip") await decodeImage(urls[0]);
|
// Don't block the render on decoding — set URLs immediately so the
|
||||||
if (ctrl.signal.aborted) return;
|
// browser can start painting the first image without waiting for the
|
||||||
|
// full decode. The img element's own decoding="async" handles the rest.
|
||||||
setPageUrls(urls);
|
setPageUrls(urls);
|
||||||
setPageReady(true);
|
setPageReady(true);
|
||||||
if (style === "longstrip" && autoNext) {
|
if (style === "longstrip" && autoNext) {
|
||||||
@@ -532,6 +554,12 @@ export default function Reader() {
|
|||||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
}, [pageNumber, style]);
|
}, [pageNumber, style]);
|
||||||
|
|
||||||
|
// Always scroll to top when a new chapter opens — even if pageNumber stays at 1
|
||||||
|
// (navigating chapter→chapter while already on page 1 won't trigger the effect above).
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
|
}, [activeChapter?.id]);
|
||||||
|
|
||||||
// ── Preload adjacent pages ───────────────────────────────────────────────────
|
// ── Preload adjacent pages ───────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ahead = settings.preloadPages ?? 3;
|
const ahead = settings.preloadPages ?? 3;
|
||||||
@@ -671,26 +699,39 @@ export default function Reader() {
|
|||||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
||||||
|
|
||||||
const goForward = useCallback(() => {
|
const goForward = useCallback(() => {
|
||||||
if (loading || !pageUrls.length) return;
|
if (loading) return;
|
||||||
|
// Longstrip: bottom arrows always switch chapters, not pages
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
|
if (!pageUrls.length) return;
|
||||||
if (pageNumber < lastPage) {
|
if (pageNumber < lastPage) {
|
||||||
decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1));
|
decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1));
|
||||||
} else if (adjacent.next) {
|
} else if (adjacent.next) {
|
||||||
|
maybeMarkCurrentRead();
|
||||||
setPageNumber(1); openReader(adjacent.next, activeChapterList);
|
setPageNumber(1); openReader(adjacent.next, activeChapterList);
|
||||||
} else {
|
} else {
|
||||||
closeReader();
|
closeReader();
|
||||||
}
|
}
|
||||||
}, [loading, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, style, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup, maybeMarkCurrentRead]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
if (loading || !pageUrls.length) return;
|
if (loading) return;
|
||||||
|
// Longstrip: bottom arrows always switch chapters, not pages
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||||
|
if (!pageUrls.length) return;
|
||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1));
|
decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1));
|
||||||
} else if (adjacent.prev) {
|
} else if (adjacent.prev) {
|
||||||
openReader(adjacent.prev, activeChapterList);
|
openReader(adjacent.prev, activeChapterList);
|
||||||
}
|
}
|
||||||
}, [loading, pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [loading, style, pageNumber, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goNext = rtl ? goBack : goForward;
|
const goNext = rtl ? goBack : goForward;
|
||||||
const goPrev = rtl ? goForward : goBack;
|
const goPrev = rtl ? goForward : goBack;
|
||||||
@@ -752,7 +793,7 @@ export default function Reader() {
|
|||||||
const list = chapterListRef.current;
|
const list = chapterListRef.current;
|
||||||
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
||||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||||||
if (next) openReader(next, list);
|
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||||||
}
|
}
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -768,7 +809,7 @@ export default function Reader() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [zoomOpen, dlOpen, lastPage]);
|
}, [zoomOpen, dlOpen, lastPage, maybeMarkCurrentRead]);
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
function handleTap(e: React.MouseEvent) {
|
function handleTap(e: React.MouseEvent) {
|
||||||
@@ -808,7 +849,7 @@ export default function Reader() {
|
|||||||
{/* ── Topbar ── */}
|
{/* ── Topbar ── */}
|
||||||
<div className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
<div className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button className={s.iconBtn} onClick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
<button className={s.iconBtn} onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)} disabled={!adjacent.prev} title="Previous chapter">
|
<button className={s.iconBtn} onClick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, activeChapterList); } }} disabled={!adjacent.prev} title="Previous chapter">
|
||||||
<CaretLeft size={14} weight="light" />
|
<CaretLeft size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<span className={s.chLabel}>
|
<span className={s.chLabel}>
|
||||||
@@ -817,7 +858,7 @@ export default function Reader() {
|
|||||||
<span>{displayChapter?.name}</span>
|
<span>{displayChapter?.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
||||||
<button className={s.iconBtn} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)} disabled={!adjacent.next} title="Next chapter">
|
<button className={s.iconBtn} onClick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } }} disabled={!adjacent.next} title="Next chapter">
|
||||||
<CaretRight size={14} weight="light" />
|
<CaretRight size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<div className={s.topSep} />
|
<div className={s.topSep} />
|
||||||
@@ -854,6 +895,16 @@ export default function Reader() {
|
|||||||
<span className={s.modeBtnLabel}>Auto</span>
|
<span className={s.modeBtnLabel}>Auto</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!autoNext && (
|
||||||
|
<button
|
||||||
|
className={[s.modeBtn, markReadOnNext ? s.modeBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => updateSettings({ markReadOnNext: !markReadOnNext })}
|
||||||
|
title={markReadOnNext
|
||||||
|
? "Mark chapter read when advancing to next (click to disable)"
|
||||||
|
: "Don't mark chapter read on next (click to enable)"}>
|
||||||
|
<span className={s.modeBtnLabel}>Mk.Read</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
||||||
<Download size={14} weight="light" />
|
<Download size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
@@ -920,10 +971,12 @@ export default function Reader() {
|
|||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
{/* ── Bottom nav ── */}
|
||||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||||
<button className={s.navBtn} onClick={goPrev} disabled={loading || (pageNumber === 1 && !adjacent.prev)}>
|
<button className={s.navBtn} onClick={goPrev}
|
||||||
|
disabled={loading || (style === "longstrip" ? !adjacent.prev : (pageNumber === 1 && !adjacent.prev))}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button className={s.navBtn} onClick={goNext} disabled={loading || (pageNumber === lastPage && !adjacent.next)}>
|
<button className={s.navBtn} onClick={goNext}
|
||||||
|
disabled={loading || (style === "longstrip" ? !adjacent.next : (pageNumber === lastPage && !adjacent.next))}>
|
||||||
<ArrowRight size={13} weight="light" />
|
<ArrowRight size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export default function SeriesDetail() {
|
|||||||
const activeManga = useStore((state) => state.activeManga);
|
const activeManga = useStore((state) => state.activeManga);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const openReader = useStore((state) => state.openReader);
|
const openReader = useStore((state) => state.openReader);
|
||||||
|
const activeChapter = useStore((state) => state.activeChapter);
|
||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const updateSettings = useStore((state) => state.updateSettings);
|
const updateSettings = useStore((state) => state.updateSettings);
|
||||||
const addToast = useStore((state) => state.addToast);
|
const addToast = useStore((state) => state.addToast);
|
||||||
@@ -515,6 +516,13 @@ export default function SeriesDetail() {
|
|||||||
});
|
});
|
||||||
}, [applyChapters]);
|
}, [applyChapters]);
|
||||||
|
|
||||||
|
// Reload chapters whenever the reader is closed so read/unread state is always current.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeChapter || !activeManga) return;
|
||||||
|
reloadChapters(activeManga.id);
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}, [activeChapter, activeManga, reloadChapters]);
|
||||||
|
|
||||||
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||||
|
|||||||
@@ -265,6 +265,12 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
|
|||||||
description="Automatically open the next chapter at the end of a long strip"
|
description="Automatically open the next chapter at the end of a long strip"
|
||||||
checked={settings.autoNextChapter ?? false}
|
checked={settings.autoNextChapter ?? false}
|
||||||
onChange={(v) => update({ autoNextChapter: v })} />
|
onChange={(v) => update({ autoNextChapter: v })} />
|
||||||
|
{!(settings.autoNextChapter ?? false) && (
|
||||||
|
<Toggle label="Mark read when skipping to next chapter"
|
||||||
|
description="When auto-advance is off, mark the current chapter as read if you tap the next chapter button before finishing it"
|
||||||
|
checked={settings.markReadOnNext ?? true}
|
||||||
|
onChange={(v) => update({ markReadOnNext: v })} />
|
||||||
|
)}
|
||||||
<Stepper label="Pages to preload"
|
<Stepper label="Pages to preload"
|
||||||
description="Images loaded ahead of the current page"
|
description="Images loaded ahead of the current page"
|
||||||
value={settings.preloadPages} min={0} max={10}
|
value={settings.preloadPages} min={0} max={10}
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export interface Settings {
|
|||||||
splashCards?: boolean;
|
splashCards?: boolean;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
/**
|
||||||
|
* Mark a chapter as read when the user manually taps the "next chapter"
|
||||||
|
* button/key while autoNextChapter is off. Has no effect when autoNextChapter
|
||||||
|
* is on (the scroll-based mark-as-read logic handles that path).
|
||||||
|
*/
|
||||||
|
markReadOnNext: boolean;
|
||||||
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||||
readerDebounceMs: number;
|
readerDebounceMs: number;
|
||||||
/** UI colour theme. Applied as data-theme on <html>. */
|
/** UI colour theme. Applied as data-theme on <html>. */
|
||||||
@@ -110,6 +116,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
splashCards: true,
|
splashCards: true,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
|
markReadOnNext: true,
|
||||||
readerDebounceMs: 120,
|
readerDebounceMs: 120,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user