diff --git a/src/components/pages/Library.module.css b/src/components/pages/Library.module.css
index ebe9ab8..c39e04d 100644
--- a/src/components/pages/Library.module.css
+++ b/src/components/pages/Library.module.css
@@ -175,6 +175,24 @@
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 {
margin-top: var(--sp-2);
font-size: var(--text-sm);
diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx
index ae26ddc..84c4cd3 100644
--- a/src/components/pages/Library.tsx
+++ b/src/components/pages/Library.tsx
@@ -45,6 +45,9 @@ const MangaCard = memo(function MangaCard({
{!!manga.downloadCount && (
{manga.downloadCount}
)}
+ {!!manga.unreadCount && (
+ {manga.unreadCount}
+ )}
{manga.title}
@@ -78,6 +81,16 @@ export default function Library() {
const addFolder = useStore((state) => state.addFolder);
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
+ const activeChapter = useStore((state) => state.activeChapter);
+
+
+ const prevChapterRef = useRef(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) => {
if (showLoading) setLoading(true);
diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx
index ba57230..931aa87 100644
--- a/src/components/pages/Reader.tsx
+++ b/src/components/pages/Reader.tsx
@@ -197,6 +197,8 @@ export default function Reader() {
const visibleChapterRef = useRef(null);
const stripChaptersRef = useRef([]);
const pageUrlsRef = useRef([]);
+ const activeChapterRef = useRef(null);
+ const markReadOnNextRef = useRef(true);
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
@@ -239,10 +241,29 @@ export default function Reader() {
const style = settings.pageStyle ?? "single";
const maxW = settings.maxPageWidth ?? 900;
const autoNext = settings.autoNextChapter ?? false;
+ const markReadOnNext = settings.markReadOnNext ?? true;
- settingsRef.current = settings;
- chapterListRef.current = activeChapterList;
- pageUrlsRef.current = pageUrls;
+ settingsRef.current = settings;
+ chapterListRef.current = activeChapterList;
+ 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 ──────────────────────────────────────────────────────────────
const showUi = useCallback(() => {
@@ -294,8 +315,9 @@ export default function Reader() {
fetchPages(targetId, ctrl.signal)
.then(async (urls) => {
if (ctrl.signal.aborted) return;
- if (style !== "longstrip") await decodeImage(urls[0]);
- if (ctrl.signal.aborted) return;
+ // Don't block the render on decoding — set URLs immediately so the
+ // 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);
setPageReady(true);
if (style === "longstrip" && autoNext) {
@@ -532,6 +554,12 @@ export default function Reader() {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [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 ───────────────────────────────────────────────────
useEffect(() => {
const ahead = settings.preloadPages ?? 3;
@@ -671,26 +699,39 @@ export default function Reader() {
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
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 (!pageUrls.length) return;
if (pageNumber < lastPage) {
decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1));
} else if (adjacent.next) {
+ maybeMarkCurrentRead();
setPageNumber(1); openReader(adjacent.next, activeChapterList);
} else {
closeReader();
}
- }, [loading, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
+ }, [loading, style, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup, maybeMarkCurrentRead]);
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 (!pageUrls.length) return;
if (pageNumber > 1) {
decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1));
} else if (adjacent.prev) {
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 goPrev = rtl ? goForward : goBack;
@@ -752,7 +793,7 @@ export default function Reader() {
const list = chapterListRef.current;
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
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)) {
e.preventDefault();
@@ -768,7 +809,7 @@ export default function Reader() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [zoomOpen, dlOpen, lastPage]);
+ }, [zoomOpen, dlOpen, lastPage, maybeMarkCurrentRead]);
// ── Render ───────────────────────────────────────────────────────────────────
function handleTap(e: React.MouseEvent) {
@@ -808,7 +849,7 @@ export default function Reader() {
{/* ── Topbar ── */}
-