Feat: Library-Refresh Overhaul & Settings Re-Wiring

This commit is contained in:
Youwes09
2026-04-30 22:42:59 -05:00
parent c8ec6d6b90
commit 552a11a517
10 changed files with 230 additions and 74 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ export const GET_DOWNLOAD_STATUS = `
downloadStatus { downloadStatus {
state state
queue { queue {
progress state progress state tries
chapter { chapter {
id name pageCount mangaId id name pageCount mangaId
manga { id title thumbnailUrl } manga { id title thumbnailUrl }
@@ -21,6 +21,7 @@
heroEntry, heroEntry,
heroMangaId, heroMangaId,
heroChapters, heroChapters,
heroNewChapter,
loadingHeroChapters, loadingHeroChapters,
resuming, resuming,
onresume, onresume,
@@ -40,6 +41,7 @@
heroEntry: HistoryEntry | null; heroEntry: HistoryEntry | null;
heroMangaId: number | null; heroMangaId: number | null;
heroChapters: Chapter[]; heroChapters: Chapter[];
heroNewChapter: Chapter | null;
loadingHeroChapters: boolean; loadingHeroChapters: boolean;
resuming: boolean; resuming: boolean;
onresume: () => void; onresume: () => void;
@@ -102,6 +104,9 @@
{:else} {:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span> <span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if} {/if}
{#if heroNewChapter && !heroNewChapter.isRead}
<span class="hero-tag hero-tag-new">New ch.{Math.floor(heroNewChapter.chapterNumber)}</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g} {#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button <button
class="hero-tag hero-tag-genre" class="hero-tag hero-tag-genre"
@@ -326,6 +331,7 @@
} }
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); } .hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); } .hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
.hero-tag-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; } .hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); } .hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
+7 -1
View File
@@ -114,6 +114,12 @@
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null); const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null); const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
const heroNewChapter = $derived(
heroManga
? (libraryManga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null
: null
);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; } function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; } function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } } function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
@@ -234,6 +240,7 @@
{heroEntry} {heroEntry}
{heroMangaId} {heroMangaId}
{heroChapters} {heroChapters}
{heroNewChapter}
{loadingHeroChapters} {loadingHeroChapters}
{resuming} {resuming}
onresume={resumeActive} onresume={resumeActive}
@@ -328,7 +335,6 @@
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
/* suppress ActivityFeed's own border-top — mid-row provides it */
.mid-left :global(.section) { border-top: none; } .mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; } .mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right { .mid-right {
+41 -24
View File
@@ -9,9 +9,11 @@ export interface RecommendedManga {
matchedGenres: string[]; matchedGenres: string[];
} }
const TOP_GENRES = 6; const TOP_GENRES = 6;
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const MAX_PAGES = 5; const MAX_PAGES = 10;
const TARGET_PER_GENRE = 20;
const EXCLUDED_STATUSES = ["CANCELLED", "ABANDONED"];
export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] { export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] {
const byId = new Map(libraryManga.map(m => [m.id, m])); const byId = new Map(libraryManga.map(m => [m.id, m]));
@@ -36,7 +38,11 @@ export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): strin
type Result = { mangas: { nodes: Manga[] } }; type Result = { mangas: { nodes: Manga[] } };
async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Manga[]> { async function fetchGenrePages(
genre: string,
globalSeen: Set<number>,
signal?: AbortSignal,
): Promise<Manga[]> {
const filter = { const filter = {
and: [ and: [
buildTagFilter([genre], "OR", []), buildTagFilter([genre], "OR", []),
@@ -44,23 +50,33 @@ async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise<Man
], ],
}; };
const pages = await Promise.all( const localSeen = new Set<number>();
Array.from({ length: MAX_PAGES }, (_, i) =>
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
.then(d => d.mangas.nodes)
.catch(() => [] as Manga[])
)
);
const seen = new Set<number>();
const nodes: Manga[] = []; const nodes: Manga[] = [];
for (const page of pages) {
if (!page.length) break; for (let page = 0; page < MAX_PAGES; page++) {
for (const m of page) { if (signal?.aborted) break;
if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); }
let batch: Manga[];
try {
const d = await gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: page * PAGE_SIZE }, signal);
batch = d.mangas.nodes;
} catch {
break;
} }
if (page.length < PAGE_SIZE) break;
if (!batch.length) break;
for (const m of batch) {
if (localSeen.has(m.id) || globalSeen.has(m.id)) continue;
if (EXCLUDED_STATUSES.includes(m.status ?? "")) continue;
localSeen.add(m.id);
nodes.push(m);
}
if (nodes.length >= TARGET_PER_GENRE) break;
if (batch.length < PAGE_SIZE) break;
} }
return nodes; return nodes;
} }
@@ -74,13 +90,14 @@ export async function fetchRecommendations(
const genres = topGenres(history, libraryManga); const genres = topGenres(history, libraryManga);
if (!genres.length) return []; if (!genres.length) return [];
const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal))); const globalSeen = new Set<number>();
const seen = new Set<number>();
const merged: Manga[] = []; const merged: Manga[] = [];
for (const page of perGenre) {
for (const m of page) { for (const genre of genres) {
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } const results = await fetchGenrePages(genre, globalSeen, signal);
for (const m of results) {
globalSeen.add(m.id);
merged.push(m);
} }
} }
+45 -4
View File
@@ -6,7 +6,7 @@
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS, GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES,
UPDATE_CATEGORY_ORDER, UPDATE_CATEGORY_ORDER, UPDATE_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
} from "@api"; } from "@api";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
@@ -28,7 +28,7 @@
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte"; import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte"; import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
const CARD_MIN_W = 130; const CARD_MIN_W = 130;
const CARD_GAP = 16; const CARD_GAP = 16;
@@ -65,6 +65,9 @@
let refreshDone: boolean = $state(false); let refreshDone: boolean = $state(false);
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null; let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
let refreshingMangaId: number | null = $state(null);
let refreshingCatId: number | null = $state(null);
let activeDragKind: "tab" | null = $state(null); let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: number | null = $state(null);
@@ -285,6 +288,31 @@
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
async function refreshManga(manga: Manga) {
if (refreshingMangaId !== null) return;
refreshingMangaId = manga.id;
try {
await gql(UPDATE_LIBRARY_MANGA, { id: manga.id });
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await loadData();
addToast({ kind: "success", title: "Manga refreshed", body: manga.title, duration: 2500 });
} catch (e) { console.error(e); }
finally { refreshingMangaId = null; }
}
async function refreshCategory(catId: number) {
if (refreshingCatId !== null || refreshing) return;
refreshingCatId = catId;
try {
await gql(UPDATE_CATEGORY_MANGA, { categoryId: catId });
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await loadData();
const cat = store.categories.find(c => c.id === catId);
addToast({ kind: "success", title: "Folder refreshed", body: cat?.name ?? "", duration: 2500 });
} catch (e) { console.error(e); }
finally { refreshingCatId = null; }
}
function bumpCategoryFrecency(catId: number) { function bumpCategoryFrecency(catId: number) {
const prev = (store.settings as any).categoryFrecency ?? {}; const prev = (store.settings as any).categoryFrecency ?? {};
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any); updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
@@ -383,11 +411,12 @@
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) }; return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
}; };
const pinnedEntries = pinned.map(makeCatEntry); const pinnedEntries = pinned.map(makeCatEntry);
const overflowChildren: MenuEntry[] = overflow.map(makeCatEntry); const overflowChildren = overflow.map(makeCatEntry);
return [ return [
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) }, { label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
{ label: refreshingMangaId === m.id ? "Refreshing…" : "Refresh manga", icon: ArrowsClockwise, disabled: refreshingMangaId !== null, onClick: () => refreshManga(m) },
{ label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) }, { label: "Open in file manager", icon: ArrowSquareOut, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
{ label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) }, { label: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
{ separator: true }, { separator: true },
@@ -421,6 +450,15 @@
} }
} }
async function cancelLibraryRefresh() {
if (!refreshing) return;
try { await gql(UPDATE_STOP); } catch (e) { console.error(e); }
cancelUpdate?.();
cancelUpdate = null;
refreshing = false;
refreshProgress = { finished: 0, total: 0 };
}
async function startLibraryRefresh() { async function startLibraryRefresh() {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
@@ -569,6 +607,7 @@
{refreshing} {refreshing}
{refreshProgress} {refreshProgress}
{refreshDone} {refreshDone}
{refreshingCatId}
{activeDragKind} {activeDragKind}
{dragInsertIdx} {dragInsertIdx}
{dragTabId} {dragTabId}
@@ -586,6 +625,8 @@
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }} onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }} onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
onRefresh={startLibraryRefresh} onRefresh={startLibraryRefresh}
onCancelRefresh={cancelLibraryRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder} onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart} onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver} onTabDragOver={onTabDragOver}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X,
} from "phosphor-svelte"; } from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types"; import type { Category } from "@types";
@@ -22,6 +22,7 @@
refreshing: boolean; refreshing: boolean;
refreshProgress: { finished: number; total: number }; refreshProgress: { finished: number; total: number };
refreshDone: boolean; refreshDone: boolean;
refreshingCatId: number | null;
activeDragKind: "tab" | null; activeDragKind: "tab" | null;
dragInsertIdx: number; dragInsertIdx: number;
dragTabId: number | null; dragTabId: number | null;
@@ -29,33 +30,35 @@
sortPanelOpen: boolean; sortPanelOpen: boolean;
filterPanelOpen: boolean; filterPanelOpen: boolean;
tabsEl: HTMLDivElement; tabsEl: HTMLDivElement;
onSearchChange: (v: string) => void; onSearchChange: (v: string) => void;
onTabChange: (f: string) => void; onTabChange: (f: string) => void;
onSortChange: (mode: LibrarySortMode) => void; onSortChange: (mode: LibrarySortMode) => void;
onSortDirToggle: () => void; onSortDirToggle: () => void;
onStatusChange: (s: LibraryStatusFilter) => void; onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void; onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void; onFiltersClear: () => void;
onSortPanelToggle: () => void; onSortPanelToggle: () => void;
onFilterPanelToggle: () => void; onFilterPanelToggle: () => void;
onRefresh: () => void; onRefresh: () => void;
onCancelRefresh: () => void;
onRefreshCategory: (catId: number) => void;
onOpenDownloadsFolder: () => void; onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void; onTabDragStart: (e: DragEvent, cat: Category) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void; onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
onTabDragLeave: () => void; onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void; onTabDrop: (e: DragEvent, cat: Category) => void;
onTabDragEnd: () => void; onTabDragEnd: () => void;
} }
let { let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters, tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, tabIndicator, visibleCategories, counts, search, refreshing, anims, tabIndicator, visibleCategories, counts, search, refreshing,
refreshProgress, refreshDone, activeDragKind, dragInsertIdx, dragTabId, refreshProgress, refreshDone, refreshingCatId, activeDragKind, dragInsertIdx,
dragOverTabId, sortPanelOpen, filterPanelOpen, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(), tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange, onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle, onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onOpenDownloadsFolder, onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd, onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props(); }: Props = $props();
@@ -73,7 +76,9 @@
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded", "az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
]; ];
const activeCatId = $derived(
tab !== "library" && tab !== "downloaded" ? Number(tab) : null
);
</script> </script>
<div class="header"> <div class="header">
@@ -113,6 +118,20 @@
<Folder size={11} weight="bold" /> <Folder size={11} weight="bold" />
{cat.name} {cat.name}
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span> <span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
{#if tab === String(cat.id) && !refreshing}
<span
class="tab-refresh"
role="button"
tabindex="-1"
title="Refresh {cat.name}"
aria-label="Refresh {cat.name}"
class:tab-refresh-spinning={refreshingCatId === cat.id}
onclick={(e) => { e.stopPropagation(); onRefreshCategory(cat.id); }}
onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onRefreshCategory(cat.id); } }}
>
<ArrowsClockwise size={10} weight="bold" class={refreshingCatId === cat.id ? "anim-spin" : ""} />
</span>
{/if}
</button> </button>
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1} {#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tab-insert-bar" aria-hidden="true"></div>
@@ -128,19 +147,27 @@
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} /> <input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
</div> </div>
<button {#if refreshing}
class="icon-btn refresh-btn" <button
class:icon-btn-active={refreshing} class="icon-btn refresh-btn icon-btn-active"
class:refresh-btn-done={refreshDone} title="Cancel update"
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"} onclick={onCancelRefresh}
disabled={refreshing} >
onclick={onRefresh} <X size={15} weight="bold" />
> {#if refreshProgress.total > 0}
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} /> <span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{#if refreshing && refreshProgress.total > 0} {/if}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span> </button>
{/if} {:else}
</button> <button
class="icon-btn refresh-btn"
class:refresh-btn-done={refreshDone}
title={refreshDone ? "Library updated" : "Check for updates"}
onclick={onRefresh}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
{/if}
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}> <button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
<FolderSimple size={15} weight="bold" /> <FolderSimple size={15} weight="bold" />
@@ -214,6 +241,10 @@
.tab-dragging { opacity: 0.4; cursor: grabbing; } .tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; } .tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; } .tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.tab-refresh { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; border-radius: 2px; opacity: 0; color: var(--accent-fg); cursor: pointer; transition: opacity var(--t-base), background var(--t-base); flex-shrink: 0; }
.tab.active:hover .tab-refresh { opacity: 0.6; }
.tab.active:hover .tab-refresh:hover { opacity: 1; background: var(--accent-dim); }
.tab-refresh-spinning { opacity: 1 !important; }
.search-wrap { position: relative; display: flex; align-items: center; } .search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; } .search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); } .search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
@@ -223,7 +254,6 @@
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); } .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); } .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 { 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); } .refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.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; } .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; }
.sort-panel-wrap { position: relative; } .sort-panel-wrap { position: relative; }
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash } from "phosphor-svelte"; import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash, ArrowsClockwise, DownloadSimple } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import { GET_CATEGORIES } from "@api/queries/manga"; import { GET_CATEGORIES } from "@api/queries/manga";
import { CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga"; import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
import type { Category } from "@types"; import type { Category } from "@types";
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte"; import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte";
@@ -57,6 +57,19 @@
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; } } catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
} }
async function toggleCategoryFlag(id: number, flag: "includeInUpdate" | "includeInDownload") {
const cat = store.categories.find(c => c.id === id);
if (!cat) return;
const next = !cat[flag];
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: next } : c));
try {
await gql(UPDATE_CATEGORIES, { ids: [id], patch: { [flag]: next } });
} catch (e: any) {
setCategories(store.categories.map(c => c.id === id ? { ...c, [flag]: !next } : c));
catsError = e?.message ?? "Failed to update folder";
}
}
async function moveCategory(id: number, direction: -1 | 1) { async function moveCategory(id: number, direction: -1 | 1) {
const zeroCat = store.categories.filter(c => c.id === 0); const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
@@ -144,6 +157,20 @@
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}> title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}>
{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if} {#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button> </button>
<button
class="s-btn-icon"
class:accent={cat.includeInUpdate !== false}
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
title={cat.includeInUpdate !== false ? "Exclude from library updates" : "Include in library updates"}>
<ArrowsClockwise size={13} weight={cat.includeInUpdate !== false ? "bold" : "light"} />
</button>
<button
class="s-btn-icon"
class:accent={cat.includeInDownload !== false}
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
title={cat.includeInDownload !== false ? "Exclude from auto-downloads" : "Include in auto-downloads"}>
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
</button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button> <button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button> <button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button>
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button> <button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
@@ -4,6 +4,7 @@
import { gql } from "@api/client"; import { gql } from "@api/client";
import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS } from "@api/queries/manga"; import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS } from "@api/queries/manga";
import { CREATE_BACKUP } from "@api/mutations/manga"; import { CREATE_BACKUP } from "@api/mutations/manga";
import { CLEAR_CACHED_IMAGES } from "@api/mutations/extensions";
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads"; import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { store, updateSettings, addToast } from "@store/state.svelte"; import { store, updateSettings, addToast } from "@store/state.svelte";
@@ -17,8 +18,9 @@
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; } interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
let resetItems = $state<ResetItem[]>([ let resetItems = $state<ResetItem[]>([
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false }, { key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false },
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false }, { key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false },
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true }, { key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true }, { key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true }, { key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
@@ -82,11 +84,15 @@
case "suwayomi-cache": case "suwayomi-cache":
await invoke("clear_suwayomi_cache"); await invoke("clear_suwayomi_cache");
break; break;
case "server-cache":
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
break;
case "reading-history": case "reading-history":
store.clearHistory(); store.clearHistory();
await persistLibrary({ history: [], bookmarks: store.bookmarks, markers: store.markers, readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} }); await persistLibrary({ history: [], bookmarks: store.bookmarks, markers: store.markers, readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} });
break; break;
case "moku-settings": case "moku-settings":
localStorage.clear();
store.hydrate({ settings: DEFAULT_SETTINGS } as any); store.hydrate({ settings: DEFAULT_SETTINGS } as any);
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 }); await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 });
patchReset(key, { state: "done" }); patchReset(key, { state: "done" });
@@ -94,6 +100,7 @@
invoke("exit_app"); invoke("exit_app");
return; return;
case "suwayomi-data": case "suwayomi-data":
localStorage.clear();
await invoke("reset_suwayomi_data"); await invoke("reset_suwayomi_data");
patchReset(key, { state: "done" }); patchReset(key, { state: "done" });
await showExitCountdown(); await showExitCountdown();
@@ -138,13 +138,25 @@
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="s-tracker-logo" /> <img src={thumbUrl(tracker.icon)} alt={tracker.name} class="s-tracker-logo" />
<div class="s-row-info"> <div class="s-row-info">
<span class="s-label">{tracker.name}</span> <span class="s-label">{tracker.name}</span>
<span class="s-pill" class:on={tracker.isLoggedIn}> <div class="s-tracker-status-row">
{tracker.isLoggedIn ? "Connected" : "Not connected"} <span class="s-pill" class:on={tracker.isLoggedIn && !tracker.isTokenExpired}>
</span> {tracker.isLoggedIn ? "Connected" : "Not connected"}
</span>
{#if tracker.isLoggedIn && tracker.isTokenExpired}
<span class="s-pill s-pill-warn">Token expired — reconnect</span>
{/if}
</div>
</div> </div>
</div> </div>
<div class="s-tracker-action"> <div class="s-tracker-action">
{#if tracker.isLoggedIn} {#if tracker.isLoggedIn && tracker.isTokenExpired}
<button class="s-btn s-btn-accent" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
Reconnect
</button>
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if tracker.isLoggedIn}
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}> <button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"} {loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button> </button>
@@ -250,3 +262,8 @@
</div> </div>
</div> </div>
<style>
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
</style>
+9 -4
View File
@@ -125,10 +125,15 @@ export async function syncBackFromTracker(
opts: SyncBackOptions, opts: SyncBackOptions,
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>, gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
): Promise<number[]> { ): Promise<number[]> {
const base = opts.respectScanlatorFilter const eligible = buildChapterList(chapters, {
? buildChapterList(chapters, opts.chapterPrefs) ...opts.chapterPrefs,
: chapters; sortDir: "asc",
const eligible = buildChapterList(base, { ...opts.chapterPrefs, sortDir: "asc" }); ...(opts.respectScanlatorFilter ? {} : {
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
}),
});
const toMarkRead: number[] = []; const toMarkRead: number[] = [];