Feat: Check for Updates (WIP) & Toaster Design Changes

This commit is contained in:
Youwes09
2026-04-11 09:34:22 -05:00
parent f840ae6413
commit af29cffdff
9 changed files with 355 additions and 81 deletions
+4 -6
View File
@@ -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
+13 -10
View File
@@ -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;
+32 -38
View File
@@ -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; }
+152 -7
View File
@@ -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); }
}
+52 -13
View File
@@ -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 {
+10
View File
@@ -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;
+47
View File
@@ -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
}
}
}
}
`;
+37 -7
View File
@@ -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(); }