Feat: Continue Again (Bookmarking-based Resume)

This commit is contained in:
Youwes09
2026-04-10 19:53:20 -05:00
parent 6b8d4fc05f
commit f840ae6413
3 changed files with 113 additions and 28 deletions
+50 -16
View File
@@ -6,7 +6,7 @@
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte"; import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga, addBookmark } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte"; import type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
@@ -148,13 +148,30 @@
const continueChapter = $derived((() => { const continueChapter = $derived((() => {
if (!sortedChapters.length) return null; if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder); const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead); const anyRead = asc.some(c => c.isRead);
const bookmark = store.activeManga
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
: null;
if (bookmark) {
const ch = asc.find(c => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
// If bookmarked chapter is the last one and everything is read,
// treat as fully finished — fall through to "reread"
if (!(isLastChapter && allRead)) {
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0); const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const }; if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead); const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const }; if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { chapter: asc[0], type: "reread" as const }; return { chapter: asc[0], type: "reread" as const, resumePage: null };
})()); })());
const jumpChapter = $derived.by(() => { const jumpChapter = $derived.by(() => {
@@ -235,8 +252,11 @@
} }
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); const mangaStatus = manga?.status;
if (chaps.length) { await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
// Never auto-move an ONGOING series into Completed — user must do that manually.
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
const allRead = chaps.every(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); const completed = allCategories.find(c => c.name === "Completed");
if (completed) { if (completed) {
@@ -529,7 +549,7 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
function openReaderWithAhead(ch: Chapter, list: Chapter[]) { function openReaderWithAhead(ch: Chapter, list: Chapter[], type?: "start" | "continue" | "reread", resumePage?: number | null) {
const ahead = getPref("downloadAhead"); const ahead = getPref("downloadAhead");
if (ahead > 0) { if (ahead > 0) {
const idx = list.indexOf(ch); const idx = list.indexOf(ch);
@@ -538,6 +558,19 @@
if (toQueue.length) enqueueMultiple(toQueue); if (toQueue.length) enqueueMultiple(toQueue);
} }
} }
if (type === "continue" && resumePage && resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: store.activeManga!.id,
mangaTitle: store.activeManga!.title,
thumbnailUrl: store.activeManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, list); openReader(ch, list);
} }
@@ -608,11 +641,11 @@
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters)}> <button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, sortedChapters, continueChapter!.type, continueChapter!.resumePage)}>
<Play size={12} weight="fill" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "reread" ? "Read again"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` : continueChapter.type === "start" ? "Start reading"
: continueChapter.type === "reread" ? "Read again" : "Start reading"} : `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ""}`}
</button> </button>
{/if} {/if}
<div class="actions"> <div class="actions">
@@ -888,7 +921,7 @@
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)} {@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)} onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, inProgress ? "continue" : undefined)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
@@ -901,9 +934,10 @@
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)} {@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters)} onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, chInProgress ? "continue" : undefined)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters))} onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, sortedChapters, chInProgress ? "continue" : undefined))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select"> <button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if} {#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
+58 -11
View File
@@ -6,7 +6,7 @@
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte"; import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
@@ -87,11 +87,38 @@
const continueChapter = $derived.by(() => { const continueChapter = $derived.by(() => {
if (!chapters.length) return null; if (!chapters.length) return null;
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); const asc = [...chapters]; // already sorted by sourceOrder from load()
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` }; const anyRead = asc.some(c => c.isRead);
const firstUnread = chapters.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` }; const bookmark = displayManga
return { ch: chapters[0], label: "Read again" }; ? store.bookmarks.find(b => b.mangaId === displayManga!.id)
: null;
if (bookmark) {
const ch = asc.find(c => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
// If bookmarked chapter is the last one and everything is read,
// treat as fully finished — fall through to "reread"
if (!(isLastChapter && allRead)) {
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
const continueLabel = $derived.by(() => {
if (!continueChapter) return "";
const { ch, type, resumePage } = continueChapter;
if (type === "reread") return "Read again";
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
}); });
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } }); $effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
@@ -187,9 +214,12 @@
} }
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); const mangaStatus = (manga ?? displayManga)?.status;
// Sync local mangaCategories state after the mutation await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
if (chaps.length) { // Sync local mangaCategories state after the mutation.
// Never auto-move an ONGOING series into Completed — user must do that manually.
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
const allRead = chaps.every(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); const completed = allCategories.find(c => c.name === "Completed");
if (completed) { if (completed) {
@@ -357,8 +387,25 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters, displayManga); close(); }}> <button class="read-btn" onclick={() => {
<Play size={12} weight="fill" />{continueChapter.label} const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, chapters, displayManga);
close();
}}>
<Play size={12} weight="fill" />{continueLabel}
</button> </button>
{/if} {/if}
{:else if !loadingDetail} {:else if !loadingDetail}
+5 -1
View File
@@ -643,8 +643,11 @@ class Store {
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>, gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string, UPDATE_MANGA?: string,
mangaStatus?: string,
): Promise<void> { ): Promise<void> {
if (!chaps.length) return; if (!chaps.length) return;
// Never auto-complete an ongoing series — user must set Completed manually.
if (mangaStatus === "ONGOING") return;
const allRead = chaps.every(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const completed = categories.find(c => c.name === "Completed"); const completed = categories.find(c => c.name === "Completed");
if (!completed) return; if (!completed) return;
@@ -722,6 +725,7 @@ export async function checkAndMarkCompleted(
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>, gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
UPDATE_MANGA_CATEGORIES: string, UPDATE_MANGA_CATEGORIES: string,
UPDATE_MANGA?: string, UPDATE_MANGA?: string,
mangaStatus?: string,
): Promise<void> { ): Promise<void> {
return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); return store.checkAndMarkCompleted(mangaId, chaps, categories, gqlFn, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
} }