diff --git a/src/components/pages/Library.svelte b/src/components/pages/Library.svelte index 17f8ccc..6ad1e57 100644 --- a/src/components/pages/Library.svelte +++ b/src/components/pages/Library.svelte @@ -5,7 +5,7 @@ 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 { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; - import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "../../store/state.svelte"; + import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories } from "../../store/state.svelte"; import type { Manga, Category, Chapter } from "../../lib/types"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; @@ -28,7 +28,6 @@ let dragInsertIdx: number = $state(-1); let allManga: Manga[] = $state([]); - let categories: Category[] = $state([]); let loading: boolean = $state(true); let error: string|null = $state(null); let retryCount: number = $state(0); @@ -51,21 +50,12 @@ // ── Data loading ────────────────────────────────────────────────────────── - // Guard flag: true while Library is publishing to CATEGORIES itself, so the - // subscriber below doesn't re-fire reloadCategories in an infinite loop. - let suppressCatSubscriber = false; - - /** Fetch and apply categories directly — never cached, always fresh. */ + /** Fetch categories from server and write to the shared store. */ async function reloadCategories() { try { const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES); const cats = await ensureCompletedCategory(d.categories.nodes); - categories = cats; - // Publish full category data (including mangas.nodes) so Settings can - // pick up order changes. Guard prevents re-entrancy. - suppressCatSubscriber = true; - cache.set(CACHE_KEYS.CATEGORIES, cats); - suppressCatSubscriber = false; + setCategories(cats); } catch (e) { console.error(e); } } @@ -105,7 +95,7 @@ const f = store.libraryFilter; if (f === "library" || f === "downloaded") return; const id = Number(f); - if (!categories.some(c => c.id === id)) { + if (!store.categories.some(c => c.id === id)) { untrack(() => { store.libraryFilter = "library"; }); } }); @@ -125,7 +115,7 @@ const visibleCategories = $derived((() => { const defaultId = store.settings.defaultLibraryCategoryId ?? null; - return categories + return store.categories .filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id)) .sort((a, b) => { // Starred folder always first @@ -137,7 +127,7 @@ const categoryMangaMap = $derived((() => { const map = new Map(); - for (const cat of categories) { + for (const cat of store.categories) { const nodes = cat.mangas?.nodes ?? []; map.set(cat.id, nodes); } @@ -225,8 +215,8 @@ dragTabId = null; activeDragKind = null; - // Work on `categories` sorted by current .order (server-authoritative) - const sorted = [...categories] + // Work on `store.categories` sorted by current .order (server-authoritative) + const sorted = [...store.categories] .filter(c => c.id !== 0) .sort((a, b) => a.order - b.order); @@ -239,7 +229,7 @@ const [moved] = reordered.splice(fromIdx, 1); reordered.splice(toIdx, 0, moved); const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 })); - categories = categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c); + setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c)); // Server sync — position is 1-based index of the drop target in sorted list const newPos = toIdx + 1; @@ -248,10 +238,7 @@ UPDATE_CATEGORY_ORDER, { id: dragId, position: newPos }, ); - // Publish reordered categories so Settings panel reflects new order. - suppressCatSubscriber = true; - cache.set(CACHE_KEYS.CATEGORIES, [...categories]); - suppressCatSubscriber = false; + // Server confirmed — no extra publish needed, store.categories is already correct } catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); // revert to server truth on error @@ -259,6 +246,7 @@ } function onTabDragEnd() { + if (activeDragKind !== "tab") return; activeDragKind = null; dragTabId = null; dragOverTabId = null; @@ -398,15 +386,15 @@ async function toggleMangaCategory(manga: Manga, cat: Category) { const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id); - // Optimistic update: patch categories in-place so counts and content + // Optimistic update: patch store.categories in-place so counts and content // update instantly without waiting for the server round-trip. - categories = categories.map(c => { + setCategories(store.categories.map(c => { if (c.id !== cat.id || !c.mangas) return c; const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga]; return { ...c, mangas: { nodes } }; - }); + })); try { await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, @@ -473,7 +461,7 @@ // ── Completed auto-assign ───────────────────────────────────────────────── export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { - await storeCheckAndMarkCompleted(mangaId, chaps, categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); + await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); await reloadCategories(); } @@ -482,14 +470,6 @@ ro.observe(scrollEl); const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData()); - // When Settings reorders folders, re-fetch so we get the correct order - // AND keep mangas.nodes intact. Guard prevents re-entrancy when Library - // itself publishes to this key. - const unsubCats = cache.subscribe(CACHE_KEYS.CATEGORIES, () => { - if (suppressCatSubscriber) return; - reloadCategories(); - }); - // One-time: if a default folder is pinned and the user hasn't navigated // to a specific tab yet, jump straight to it. const defaultId = store.settings.defaultLibraryCategoryId; @@ -497,7 +477,7 @@ store.libraryFilter = String(defaultId); } - return () => { ro.disconnect(); unsub(); unsubCats(); }; + return () => { ro.disconnect(); unsub(); }; }); diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index caa3883..59c45c0 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -9,8 +9,8 @@ import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "../../lib/queries"; import { GET_DOWNLOADS_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 } from "../../lib/types"; - import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory } from "../../store/state.svelte"; - import { cache, CACHE_KEYS } from "../../lib/cache"; + import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte"; + import { cache } from "../../lib/cache"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds"; import type { Settings, FitMode, Theme } from "../../store/state.svelte"; import type { Keybinds } from "../../lib/keybinds"; @@ -170,7 +170,8 @@ } - let categories: Category[] = $state([]); + // catsLoading / catsError are local UI state only. + // The category list itself lives in store.categories (shared with Library). let catsLoading: boolean = $state(false); let catsError: string|null = $state(null); let newFolderName = $state(""); @@ -181,9 +182,16 @@ catsLoading = true; catsError = null; try { const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES); - categories = res.categories.nodes.filter(c => c.id !== 0); - // Publish so Library picks up order changes via its subscription - cache.set(CACHE_KEYS.CATEGORIES, categories); + // Merge server data onto existing store.categories to preserve mangas.nodes + // that Library loaded — Settings' GET_CATEGORIES query may not include them. + // Also preserve any id=0 (Default) entry that Library manages. + const zeroCat = store.categories.filter(c => c.id === 0); + const fresh = res.categories.nodes.filter(c => c.id !== 0); + const merged = fresh.map(f => { + const existing = store.categories.find(c => c.id === f.id); + return existing ? { ...existing, ...f } : f; + }); + setCategories([...zeroCat, ...merged]); } catch (e: any) { catsError = e?.message ?? "Failed to load folders"; } finally { catsLoading = false; } @@ -194,9 +202,8 @@ if (!name) return; try { const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name }); - categories = [...categories, res.createCategory.category]; + setCategories([...store.categories, res.createCategory.category]); newFolderName = ""; - cache.set(CACHE_KEYS.CATEGORIES, categories); } catch (e: any) { catsError = e?.message ?? "Failed to create folder"; } } @@ -206,8 +213,7 @@ if (editingId !== null && editingName.trim()) { try { await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() }); - categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c); - cache.set(CACHE_KEYS.CATEGORIES, categories); + setCategories(store.categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)); } catch (e: any) { catsError = e?.message ?? "Failed to rename"; } } editingId = null; editingName = ""; @@ -216,55 +222,51 @@ async function deleteFolder(id: number) { try { await gql(DELETE_CATEGORY, { id }); - categories = categories.filter(c => c.id !== id); - cache.set(CACHE_KEYS.CATEGORIES, categories); + setCategories(store.categories.filter(c => c.id !== id)); } catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; } } async function moveCategory(id: number, direction: -1 | 1) { - const idx = categories.findIndex(c => c.id === id); + // Work only on the non-default (id !== 0) categories, sorted by order, + // which is what the Settings list renders and what the server expects. + 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 idx = sortable.findIndex(c => c.id === id); if (idx < 0) return; - const newPos = idx + 1 + direction; - if (newPos < 1 || newPos > categories.length) return; - // Optimistic reorder so the UI moves instantly - const reordered = [...categories]; + const newPos = idx + 1 + direction; // 1-based server position + if (newPos < 1 || newPos > sortable.length) return; + + // Optimistic reorder — splice within sortable slice, keep mangas.nodes intact + const reordered = [...sortable]; const [moved] = reordered.splice(idx, 1); reordered.splice(idx + direction, 0, moved); - categories = reordered.map((c, i) => ({ ...c, order: i + 1 })); - // Publish optimistic order immediately — Library re-fetches on this signal - cache.set(CACHE_KEYS.CATEGORIES, categories); + setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]); + try { const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos }); + // Server returns bare order data — merge to preserve mangas.nodes, keep id=0 entry const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0); - categories = updated.sort((a, b) => a.order - b.order); - // Publish server-authoritative order - cache.set(CACHE_KEYS.CATEGORIES, categories); + setCategories([ + ...zeroCat, + ...updated + .sort((a, b) => a.order - b.order) + .map(fresh => { + const existing = store.categories.find(c => c.id === fresh.id); + return existing ? { ...existing, ...fresh } : fresh; + }), + ]); } catch (e: any) { catsError = e?.message ?? "Failed to reorder"; - await loadCategories(); // revert — loadCategories also publishes + await loadCategories(); } } - // Subscribe to the shared categories cache so Library-initiated drag reorders - // are reflected in the Settings folder list without needing a manual reload. - // Library publishes its full category data (with mangas.nodes), so we only - // pull the ordering signal — no content is lost here since Settings never - // renders mangas.nodes, just names and counts. - $effect(() => { - const unsub = cache.subscribe(CACHE_KEYS.CATEGORIES, async () => { - // Don't clobber an in-progress server fetch - if (catsLoading) return; - // Re-read directly from the server so we get mangas.nodes counts too. - // This is a lightweight call and only fires when Library reorders. - try { - const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES); - categories = res.categories.nodes.filter(c => c.id !== 0); - } catch {} - }); - return unsub; - }); - - $effect(() => { if (tab === "folders" && !categories.length && !catsLoading) loadCategories(); }); + // Load categories when the folders tab is first opened and the shared store + // is empty (e.g. user opened Settings before Library was mounted). + $effect(() => { if (tab === "folders" && !store.categories.length && !catsLoading) loadCategories(); }); let selectOpen: string | null = $state(null); @@ -1226,11 +1228,19 @@ {#if catsLoading}

Loading folders…

- {:else if categories.length === 0} + {:else if store.categories.filter(c => c.id !== 0).length === 0}

No folders yet. Create one above.

{:else} + {@const displayCats = store.categories + .filter(c => c.id !== 0) + .sort((a, b) => { + const defaultId = store.settings.defaultLibraryCategoryId ?? null; + if (a.id === defaultId) return -1; + if (b.id === defaultId) return 1; + return a.order - b.order; + })}
- {#each categories as cat, i} + {#each displayCats as cat, i}
{#if editingId === cat.id} {#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}{:else}{/if} - + {/if} diff --git a/src/store/state.svelte.ts b/src/store/state.svelte.ts index 0979115..613ed9c 100644 --- a/src/store/state.svelte.ts +++ b/src/store/state.svelte.ts @@ -365,6 +365,12 @@ class Store { // UI-only: synced from Tauri window events in App.svelte. Not persisted. isFullscreen: boolean = $state(false); + // ── Shared category list ────────────────────────────────────────────────── + // Single source of truth for the category list, shared between Library and + // Settings. Library owns fetching; Settings reads and mutates in-place. + // No pub/sub or guard flags needed — both components share this $state ref. + categories: Category[] = $state([]); + // ── Discover session cache ──────────────────────────────────────────────── // Survives navigation within a session but is never persisted to localStorage. // Key format: "||" or "local|" @@ -520,6 +526,7 @@ class Store { } dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); } + setCategories(cats: Category[]) { this.categories = cats; } setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; } setNavPage(next: NavPage) { this.navPage = next; } setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; } @@ -609,6 +616,7 @@ export function getLinkedMangaIds(mangaId: number) { retur export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); } export function addToast(toast: Omit) { store.addToast(toast); } export function dismissToast(id: string) { store.dismissToast(id); } +export function setCategories(cats: Category[]) { store.setCategories(cats); } export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); } export function setNavPage(next: NavPage) { store.setNavPage(next); } export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }