mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Continue Again (Bookmarking-based Resume)
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user