mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: Check for Updates (WIP) & Toaster Design Changes
This commit is contained in:
@@ -12,13 +12,11 @@ Minor Revisions:
|
||||
- Patch Color-Picker to Work Properly
|
||||
- Integrate Download Directory Changes (Settings)
|
||||
|
||||
|
||||
|
||||
Priority Bugs:
|
||||
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||
- Fix Library Build not Updating
|
||||
- Check Auth System (Only Supports Basic-Auth)
|
||||
- Loading Buffer for Pictures (Due to Auth Lag)
|
||||
- Create Option for Saved in Library to Respect Default Constraints
|
||||
- Additionally, Added Folders are Displayed in Suwayomi, but not registered in Suwayomi
|
||||
|
||||
|
||||
General/Misc Bugs:
|
||||
@@ -32,8 +30,8 @@ General/Misc Bugs:
|
||||
In-Progress:
|
||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||
|
||||
- Add Scanlator Filtering -> Pass to Reader
|
||||
- Automations need to work in Reader (Auto-Delete does not)
|
||||
- Saved needs Reading Frecency-Based Display too.
|
||||
- Check-For-Updates needs Visual Display of Activation
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</span>
|
||||
<div class="body">
|
||||
<p class="title">{t.title}</p>
|
||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||
<p class="sub">{t.body ?? '\u00a0'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -78,22 +78,21 @@
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 10px var(--sp-3) 10px 0;
|
||||
gap: 10px;
|
||||
padding: 12px var(--sp-3) 12px 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||
pointer-events: all;
|
||||
min-width: 200px;
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@@ -126,7 +125,7 @@
|
||||
@keyframes slideOut {
|
||||
0% { opacity: 1; transform: translateX(0) scale(1); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||
40% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: var(--exit-h, 80px); margin-bottom: 0; }
|
||||
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -6px; }
|
||||
100% { opacity: 0; transform: translateX(14px) scale(0.96); max-height: 0; margin-bottom: -5px; }
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
@@ -134,7 +133,7 @@
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
margin-right: 2px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.toast-success .accent-bar { background: var(--accent-fg); }
|
||||
@@ -159,7 +158,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -168,7 +167,10 @@
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1.3;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub {
|
||||
@@ -176,6 +178,7 @@
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets, Bell } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { getBlobUrl } from "../../lib/imageCache";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter, clearLibraryUpdates } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import { buildReaderChapterList } from "../../lib/chapterList";
|
||||
@@ -36,26 +36,16 @@
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
let completedCategory: Category | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
function loadLibrary() {
|
||||
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||
);
|
||||
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
|
||||
.catch(() => null);
|
||||
|
||||
Promise.all([libraryP, categoriesP])
|
||||
.then(([m, completed]) => {
|
||||
libraryManga = m;
|
||||
completedCategory = completed;
|
||||
fetchExtraCompleted(m, completed);
|
||||
})
|
||||
)
|
||||
.then(m => { libraryManga = m; })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
@@ -79,15 +69,6 @@
|
||||
untrack(() => resetAndReload());
|
||||
});
|
||||
|
||||
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
|
||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||
if (!missingIds.length) return;
|
||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
||||
if (valid.length) extraManga = valid;
|
||||
}
|
||||
|
||||
const continueReading = $derived((() => {
|
||||
const seen = new Set<number>();
|
||||
const out: HistoryEntry[] = [];
|
||||
@@ -254,11 +235,20 @@
|
||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||
|
||||
const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
|
||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 7) : []);
|
||||
const recentHistory = $derived(store.history.slice(0, 6));
|
||||
const stats = $derived(store.readingStats);
|
||||
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
|
||||
const lastRefresh = $derived(store.lastLibraryRefresh);
|
||||
|
||||
function timeAgoRefresh(ts: number): string {
|
||||
if (!ts) return "";
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function handleRowWheel(e: WheelEvent) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
@@ -450,28 +440,31 @@
|
||||
<div class="bottom-row">
|
||||
<div class="bottom-col">
|
||||
<div class="bottom-section-hd">
|
||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||
{#if completedManga.length > 0}
|
||||
<button class="see-all" onclick={() => { if (completedCategory) setLibraryFilter(String(completedCategory.id)); store.navPage = "library"; }}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||
<span class="section-title"><Bell size={10} weight="bold" /> Updates
|
||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
||||
</span>
|
||||
{#if libraryUpdates.length > 0}
|
||||
<button class="see-all" onclick={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}>Clear <ArrowRight size={9} weight="bold" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if completedManga.length > 0}
|
||||
{#if libraryUpdates.length > 0}
|
||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||
{#each completedManga as m (m.id)}
|
||||
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||
{#each libraryUpdates as u (u.mangaId)}
|
||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
||||
<button class="mini-card" onclick={() => { if (m) store.previewManga = m; }}>
|
||||
<div class="mini-cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
|
||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="mini-cover" />
|
||||
<div class="mini-gradient"></div>
|
||||
<div class="mini-footer">
|
||||
<p class="mini-card-title">{m.title}</p>
|
||||
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
||||
<p class="mini-card-title">{u.mangaTitle}</p>
|
||||
<p class="mini-card-source">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
||||
<p class="bottom-empty">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -486,7 +479,7 @@
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalChaptersRead}</span><span class="stat-label">Chapters read</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span><span class="stat-label">Read time</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.totalMangaRead}</span><span class="stat-label">Series started</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{completedIds.length}</span><span class="stat-label">Completed</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-green"><Bell size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{libraryUpdates.length}</span><span class="stat-label">New updates</span></div></div>
|
||||
<div class="stat-card"><div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div><div class="stat-body"><span class="stat-val">{stats.longestStreakDays}d</span><span class="stat-label">Best streak</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,6 +612,7 @@
|
||||
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||
.refresh-age { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||
.mini-row { display: flex; flex-direction: row; gap: var(--sp-3); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
||||
.mini-row::-webkit-scrollbar { display: none; }
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER, UPDATE_LIBRARY, LIBRARY_UPDATE_STATUS } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "../../store/state.svelte";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories, setLibraryUpdates, addToast } from "../../store/state.svelte";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||
@@ -275,6 +275,7 @@
|
||||
}));
|
||||
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
|
||||
error = null;
|
||||
await migrateCategorizedToLibrary();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
@@ -282,6 +283,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateCategorizedToLibrary() {
|
||||
const allCatManga = store.categories.flatMap(c => c.mangas?.nodes ?? []);
|
||||
const orphanIds = [...new Set(allCatManga.filter(m => !m.inLibrary).map(m => m.id))];
|
||||
if (!orphanIds.length) return;
|
||||
await gql(UPDATE_MANGAS, { ids: orphanIds, inLibrary: true }).catch(console.error);
|
||||
allManga = allManga.map(m => orphanIds.includes(m.id) ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
retryCount;
|
||||
loading = true; error = null;
|
||||
@@ -344,7 +354,13 @@
|
||||
// 1. Pick the right base list for this tab
|
||||
let items: Manga[];
|
||||
if (store.libraryFilter === "library") {
|
||||
items = store.settings.savedIsDefaultCategory ? (categoryMangaMap.get(0) ?? []) : allManga;
|
||||
// "Saved" shows all in-library manga so that manga in folders are still visible here.
|
||||
// If the user prefers the old behaviour (only uncategorised), they can toggle it off in settings.
|
||||
if (store.settings.libraryShowAllInSaved ?? true) {
|
||||
items = allManga.filter(m => m.inLibrary);
|
||||
} else {
|
||||
items = categoryMangaMap.get(0) ?? [];
|
||||
}
|
||||
} else if (store.libraryFilter === "downloaded") {
|
||||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
} else {
|
||||
@@ -424,7 +440,9 @@
|
||||
|
||||
const counts = $derived((() => {
|
||||
const m: Record<string, number> = {
|
||||
library: store.settings.savedIsDefaultCategory ? (categoryMangaMap.get(0) ?? []).length : allManga.length,
|
||||
library: (store.settings.libraryShowAllInSaved ?? true)
|
||||
? allManga.filter(x => x.inLibrary).length
|
||||
: (categoryMangaMap.get(0) ?? []).length,
|
||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
for (const cat of visibleCategories) {
|
||||
@@ -542,6 +560,11 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
if (!inCat && !manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -556,6 +579,11 @@
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!manga.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
|
||||
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
}
|
||||
await reloadCategories();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -610,6 +638,92 @@
|
||||
await reloadCategories();
|
||||
}
|
||||
|
||||
let refreshing: boolean = $state(false);
|
||||
let refreshProgress = $state({ finished: 0, total: 0 });
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshDone: boolean = $state(false); // brief "done" flash on button
|
||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function showToast(newChapters: number, totalUpdated: number) {
|
||||
if (newChapters > 0) {
|
||||
addToast({ kind: "success", title: "Library updated", body: `${newChapters} new chapter${newChapters !== 1 ? "s" : ""} across ${totalUpdated} series` });
|
||||
} else {
|
||||
addToast({ kind: "info", title: "Already up to date", body: "No new chapters found" });
|
||||
}
|
||||
}
|
||||
|
||||
async function startLibraryRefresh() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
refreshProgress = { finished: 0, total: 0 };
|
||||
|
||||
const prevCounts = new Map(allManga.map(m => [m.id, m.unreadCount ?? 0]));
|
||||
|
||||
let seenWork = false;
|
||||
try {
|
||||
const updateRes = await gql<{ updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } } }>(UPDATE_LIBRARY, {});
|
||||
seenWork = updateRes.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
|
||||
} catch {
|
||||
refreshing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(function poll() {
|
||||
gql<{ libraryUpdateStatus: {
|
||||
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
|
||||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[];
|
||||
} }>(LIBRARY_UPDATE_STATUS, {})
|
||||
.then(d => {
|
||||
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
|
||||
refreshProgress = { finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs };
|
||||
|
||||
if (jobsInfo.totalJobs > 0) seenWork = true;
|
||||
|
||||
if (!jobsInfo.isRunning && seenWork) {
|
||||
refreshing = false;
|
||||
pollTimer = null;
|
||||
|
||||
const entries: LibraryUpdateEntry[] = mangaUpdates
|
||||
.filter(u => u.status === "FINISHED")
|
||||
.reduce<LibraryUpdateEntry[]>((acc, u) => {
|
||||
const prev = prevCounts.get(u.manga.id) ?? 0;
|
||||
const newChapters = Math.max(0, (u.manga.unreadCount ?? 0) - prev);
|
||||
if (newChapters > 0) {
|
||||
acc.push({
|
||||
mangaId: u.manga.id,
|
||||
mangaTitle: u.manga.title,
|
||||
thumbnailUrl: u.manga.thumbnailUrl,
|
||||
newChapters,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setLibraryUpdates(entries);
|
||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||
loadData();
|
||||
|
||||
// Done flash on button
|
||||
refreshDone = true;
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
||||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||||
|
||||
// Toast summary
|
||||
const totalNew = entries.reduce((s, e) => s + e.newChapters, 0);
|
||||
showToast(totalNew, entries.length);
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
refreshing = false;
|
||||
pollTimer = null;
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||
ro.observe(scrollEl);
|
||||
@@ -644,6 +758,7 @@
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsub();
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("mousedown", onDocMouseDown, true);
|
||||
};
|
||||
@@ -739,7 +854,19 @@
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
|
||||
<!-- Sort panel -->
|
||||
<button
|
||||
class="icon-btn refresh-btn"
|
||||
class:icon-btn-active={refreshing}
|
||||
class:refresh-btn-done={refreshDone}
|
||||
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"}
|
||||
disabled={refreshing}
|
||||
onclick={startLibraryRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} />
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="sort-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
@@ -843,6 +970,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Refresh progress bar ──────────────────────────────────────────────── -->
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
{@const pct = Math.round((refreshProgress.finished / refreshProgress.total) * 100)}
|
||||
<div class="refresh-bar-wrap" aria-hidden="true">
|
||||
<div class="refresh-bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Selection toolbar ───────────────────────────────────────────────── -->
|
||||
{#if selectMode}
|
||||
<div class="select-bar">
|
||||
@@ -991,6 +1126,9 @@
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||
.refresh-btn:disabled { cursor: default; }
|
||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
|
||||
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
||||
.sort-panel-wrap,
|
||||
@@ -1067,5 +1205,12 @@
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.retry-btn { margin-top: var(--sp-3); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
/* ── Refresh progress bar ───────────────────────────────────────────────── */
|
||||
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
|
||||
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
|
||||
|
||||
/* Done flash on button */
|
||||
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -530,6 +530,10 @@
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!manga?.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
}
|
||||
allCategories = [...allCategories, cat];
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
@@ -545,6 +549,10 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
if (!inCat && !manga?.inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
}
|
||||
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER, GET_SOURCES } from "../../lib/queries";
|
||||
import { GET_DOWNLOADS_PATH, SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
|
||||
import type { Category, Source } from "../../lib/types";
|
||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
|
||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories, addToast } from "../../store/state.svelte";
|
||||
import { authSession } from "../../lib/auth";
|
||||
import { cache } from "../../lib/cache";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||
@@ -325,7 +325,7 @@
|
||||
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
|
||||
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
|
||||
}
|
||||
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
|
||||
$effect(() => { if (tab === "performance" || tab === "devtools") refreshPerfMetrics(); });
|
||||
function fmtAge(ts: number | null): string {
|
||||
if (ts === null) return "—";
|
||||
const secs = Math.floor((Date.now() - ts) / 1000);
|
||||
@@ -1210,8 +1210,11 @@
|
||||
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Saved shows default folder</span><span class="toggle-desc">Saved tab shows only manga in Suwayomi's uncategorized folder</span></div>
|
||||
<button role="switch" aria-checked={store.settings.savedIsDefaultCategory} aria-label="Saved shows default folder" class="toggle" class:on={store.settings.savedIsDefaultCategory} onclick={() => updateSettings({ savedIsDefaultCategory: !store.settings.savedIsDefaultCategory })}><span class="toggle-thumb"></span></button>
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Show all in Saved tab</span>
|
||||
<span class="toggle-desc">Include manga that are in folders — lets you see your whole library in one place</span>
|
||||
</div>
|
||||
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
@@ -2181,19 +2184,42 @@
|
||||
{:else if tab === "devtools"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Splash Screen</p>
|
||||
<p class="section-title">Toasts</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
|
||||
<button class="danger-btn" onclick={triggerSplash}
|
||||
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
|
||||
Show idle
|
||||
</button>
|
||||
<div class="toggle-info"><span class="toggle-label">Fire test toast</span><span class="toggle-desc">Triggers each kind with realistic content</span></div>
|
||||
<div class="dev-pill-group">
|
||||
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]}
|
||||
<button class="dev-pill dev-pill-{kind}" onclick={() => addToast({
|
||||
kind,
|
||||
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
|
||||
body: kind === "success" ? "3 new chapters across 2 series" : kind === "error" ? "Connection refused on port 4567" : kind === "info" ? "No new chapters found" : "Berserk · Ch. 372 ready to read",
|
||||
})}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Build Info</p>
|
||||
<div class="about-block">
|
||||
<p class="about-line" style="font-family:monospace;font-size:11px;color:var(--text-faint)">Mode: {import.meta.env.MODE}</p>
|
||||
<p class="section-title">Previews</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Idle splash</span><span class="toggle-desc">Dismiss with any click or key</span></div>
|
||||
<button class="dev-btn" onclick={triggerSplash} style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg)" : ""}>Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Runtime</p>
|
||||
<div class="dev-grid">
|
||||
<span class="dev-key">Filter</span>
|
||||
<span class="dev-val">{store.libraryFilter}</span>
|
||||
<span class="dev-key">Folders</span>
|
||||
<span class="dev-val">{store.categories.filter(c => c.id !== 0).map(c => c.name).join(", ") || "none"}</span>
|
||||
<span class="dev-key">History</span>
|
||||
<span class="dev-val">{store.history.length} entries</span>
|
||||
<span class="dev-key">Cache</span>
|
||||
<span class="dev-val">{perfSnapshot?.cacheEntries ?? "—"} entries</span>
|
||||
<span class="dev-key">Toasts</span>
|
||||
<span class="dev-val">{store.toasts.length} queued</span>
|
||||
<span class="dev-key">Version</span>
|
||||
<span class="dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2255,6 +2281,19 @@
|
||||
.danger-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--color-error); background: none; color: var(--color-error); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); }
|
||||
.danger-btn:hover:not(:disabled) { background: var(--color-error-bg); }
|
||||
.danger-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.dev-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
|
||||
.dev-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.dev-mono { font-family: monospace; font-size: 11px; color: var(--text-faint); flex-shrink: 0; }
|
||||
.dev-pill-group { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.dev-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); font-weight: var(--weight-medium); width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); display: flex; align-items: center; justify-content: center; }
|
||||
.dev-pill:hover { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.dev-pill-success:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.dev-pill-error:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg); }
|
||||
.dev-pill-info:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.dev-pill-download:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.dev-grid { display: grid; grid-template-columns: 64px 1fr; gap: 1px 12px; padding: var(--sp-2) var(--sp-3) var(--sp-3); }
|
||||
.dev-key { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 0; display: flex; align-items: center; }
|
||||
.dev-val { font-family: monospace; font-size: 11px; color: var(--text-secondary); padding: 4px 0; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
||||
.scale-slider { flex: 1; }
|
||||
.scale-val-input {
|
||||
|
||||
@@ -239,6 +239,11 @@
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
}).catch(console.error);
|
||||
if (!inCat && !inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
@@ -252,6 +257,11 @@
|
||||
const cat = res.createCategory.category;
|
||||
allCategories = [...allCategories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
if (!inLibrary) {
|
||||
await gql(UPDATE_MANGA, { id: store.previewManga.id, inLibrary: true }).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
|
||||
@@ -127,6 +127,17 @@ export const UPDATE_MANGA = `
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_MANGAS = `
|
||||
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||
mangas {
|
||||
id
|
||||
inLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
@@ -905,3 +916,39 @@ export const REFRESH_TOKEN = `
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus {
|
||||
jobsInfo {
|
||||
isRunning
|
||||
finishedJobs
|
||||
totalJobs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LIBRARY_UPDATE_STATUS = `
|
||||
query LibraryUpdateStatus {
|
||||
libraryUpdateStatus {
|
||||
jobsInfo {
|
||||
isRunning
|
||||
finishedJobs
|
||||
totalJobs
|
||||
skippedMangasCount
|
||||
}
|
||||
mangaUpdates {
|
||||
status
|
||||
manga {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -154,6 +154,14 @@ export interface ReadingStats {
|
||||
lastStreakDate: string;
|
||||
}
|
||||
|
||||
export interface LibraryUpdateEntry {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
newChapters: number;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const AVG_MIN_PER_CHAPTER = 5;
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
@@ -438,18 +446,23 @@ class Store {
|
||||
discoverCache: Map<string, any> = $state(new Map());
|
||||
discoverLibraryIds: Set<number> = $state(new Set());
|
||||
discoverSrcOffset: number = $state(0);
|
||||
readerSessionId: number = $state(0);
|
||||
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||
|
||||
constructor() {
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
persist({
|
||||
settings: this.settings,
|
||||
history: this.history,
|
||||
bookmarks: this.bookmarks,
|
||||
markers: this.markers,
|
||||
readLog: this.readLog,
|
||||
readingStats: this.readingStats,
|
||||
storeVersion: STORE_VERSION,
|
||||
settings: this.settings,
|
||||
history: this.history,
|
||||
bookmarks: this.bookmarks,
|
||||
markers: this.markers,
|
||||
readLog: this.readLog,
|
||||
readingStats: this.readingStats,
|
||||
libraryUpdates: this.libraryUpdates,
|
||||
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||
storeVersion: STORE_VERSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -672,6 +685,20 @@ class Store {
|
||||
this.discoverLibraryIds = new Set();
|
||||
this.discoverSrcOffset++;
|
||||
}
|
||||
|
||||
setLibraryUpdates(entries: LibraryUpdateEntry[]) {
|
||||
this.libraryUpdates = entries;
|
||||
this.lastLibraryRefresh = Date.now();
|
||||
}
|
||||
|
||||
clearLibraryUpdates() {
|
||||
this.libraryUpdates = [];
|
||||
this.lastLibraryRefresh = 0;
|
||||
}
|
||||
|
||||
bumpReaderSession() {
|
||||
this.readerSessionId++;
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
@@ -704,6 +731,9 @@ export function setSettingsOpen(next: boolean) { sto
|
||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||
export function resetKeybinds() { store.resetKeybinds(); }
|
||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
||||
export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); }
|
||||
export function clearLibraryUpdates() { store.clearLibraryUpdates(); }
|
||||
export function bumpReaderSession() { store.bumpReaderSession(); }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
|
||||
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
|
||||
export function clearBookmarks() { store.clearBookmarks(); }
|
||||
|
||||
Reference in New Issue
Block a user