mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Folder State & Tabs
This commit is contained in:
@@ -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 { 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 { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
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 type { Manga, Category, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
let dragInsertIdx: number = $state(-1);
|
let dragInsertIdx: number = $state(-1);
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
let allManga: Manga[] = $state([]);
|
||||||
let categories: Category[] = $state([]);
|
|
||||||
let loading: boolean = $state(true);
|
let loading: boolean = $state(true);
|
||||||
let error: string|null = $state(null);
|
let error: string|null = $state(null);
|
||||||
let retryCount: number = $state(0);
|
let retryCount: number = $state(0);
|
||||||
@@ -51,21 +50,12 @@
|
|||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────────────────
|
// ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Guard flag: true while Library is publishing to CATEGORIES itself, so the
|
/** Fetch categories from server and write to the shared store. */
|
||||||
// subscriber below doesn't re-fire reloadCategories in an infinite loop.
|
|
||||||
let suppressCatSubscriber = false;
|
|
||||||
|
|
||||||
/** Fetch and apply categories directly — never cached, always fresh. */
|
|
||||||
async function reloadCategories() {
|
async function reloadCategories() {
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||||
const cats = await ensureCompletedCategory(d.categories.nodes);
|
const cats = await ensureCompletedCategory(d.categories.nodes);
|
||||||
categories = cats;
|
setCategories(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;
|
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +95,7 @@
|
|||||||
const f = store.libraryFilter;
|
const f = store.libraryFilter;
|
||||||
if (f === "library" || f === "downloaded") return;
|
if (f === "library" || f === "downloaded") return;
|
||||||
const id = Number(f);
|
const id = Number(f);
|
||||||
if (!categories.some(c => c.id === id)) {
|
if (!store.categories.some(c => c.id === id)) {
|
||||||
untrack(() => { store.libraryFilter = "library"; });
|
untrack(() => { store.libraryFilter = "library"; });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -125,7 +115,7 @@
|
|||||||
|
|
||||||
const visibleCategories = $derived((() => {
|
const visibleCategories = $derived((() => {
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||||
return categories
|
return store.categories
|
||||||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Starred folder always first
|
// Starred folder always first
|
||||||
@@ -137,7 +127,7 @@
|
|||||||
|
|
||||||
const categoryMangaMap = $derived((() => {
|
const categoryMangaMap = $derived((() => {
|
||||||
const map = new Map<number, Manga[]>();
|
const map = new Map<number, Manga[]>();
|
||||||
for (const cat of categories) {
|
for (const cat of store.categories) {
|
||||||
const nodes = cat.mangas?.nodes ?? [];
|
const nodes = cat.mangas?.nodes ?? [];
|
||||||
map.set(cat.id, nodes);
|
map.set(cat.id, nodes);
|
||||||
}
|
}
|
||||||
@@ -225,8 +215,8 @@
|
|||||||
dragTabId = null;
|
dragTabId = null;
|
||||||
activeDragKind = null;
|
activeDragKind = null;
|
||||||
|
|
||||||
// Work on `categories` sorted by current .order (server-authoritative)
|
// Work on `store.categories` sorted by current .order (server-authoritative)
|
||||||
const sorted = [...categories]
|
const sorted = [...store.categories]
|
||||||
.filter(c => c.id !== 0)
|
.filter(c => c.id !== 0)
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
@@ -239,7 +229,7 @@
|
|||||||
const [moved] = reordered.splice(fromIdx, 1);
|
const [moved] = reordered.splice(fromIdx, 1);
|
||||||
reordered.splice(toIdx, 0, moved);
|
reordered.splice(toIdx, 0, moved);
|
||||||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
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
|
// Server sync — position is 1-based index of the drop target in sorted list
|
||||||
const newPos = toIdx + 1;
|
const newPos = toIdx + 1;
|
||||||
@@ -248,10 +238,7 @@
|
|||||||
UPDATE_CATEGORY_ORDER,
|
UPDATE_CATEGORY_ORDER,
|
||||||
{ id: dragId, position: newPos },
|
{ id: dragId, position: newPos },
|
||||||
);
|
);
|
||||||
// Publish reordered categories so Settings panel reflects new order.
|
// Server confirmed — no extra publish needed, store.categories is already correct
|
||||||
suppressCatSubscriber = true;
|
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, [...categories]);
|
|
||||||
suppressCatSubscriber = false;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Tab reorder failed:", err);
|
console.error("Tab reorder failed:", err);
|
||||||
await reloadCategories(); // revert to server truth on error
|
await reloadCategories(); // revert to server truth on error
|
||||||
@@ -259,6 +246,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTabDragEnd() {
|
function onTabDragEnd() {
|
||||||
|
if (activeDragKind !== "tab") return;
|
||||||
activeDragKind = null;
|
activeDragKind = null;
|
||||||
dragTabId = null;
|
dragTabId = null;
|
||||||
dragOverTabId = null;
|
dragOverTabId = null;
|
||||||
@@ -398,15 +386,15 @@
|
|||||||
|
|
||||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
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.
|
// 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;
|
if (c.id !== cat.id || !c.mangas) return c;
|
||||||
const nodes = inCat
|
const nodes = inCat
|
||||||
? c.mangas.nodes.filter(m => m.id !== manga.id)
|
? c.mangas.nodes.filter(m => m.id !== manga.id)
|
||||||
: [...c.mangas.nodes, manga];
|
: [...c.mangas.nodes, manga];
|
||||||
return { ...c, mangas: { nodes } };
|
return { ...c, mangas: { nodes } };
|
||||||
});
|
}));
|
||||||
try {
|
try {
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, {
|
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||||
mangaId: manga.id,
|
mangaId: manga.id,
|
||||||
@@ -473,7 +461,7 @@
|
|||||||
// ── Completed auto-assign ─────────────────────────────────────────────────
|
// ── Completed auto-assign ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
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();
|
await reloadCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,14 +470,6 @@
|
|||||||
ro.observe(scrollEl);
|
ro.observe(scrollEl);
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
|
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
|
// One-time: if a default folder is pinned and the user hasn't navigated
|
||||||
// to a specific tab yet, jump straight to it.
|
// to a specific tab yet, jump straight to it.
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId;
|
const defaultId = store.settings.defaultLibraryCategoryId;
|
||||||
@@ -497,7 +477,7 @@
|
|||||||
store.libraryFilter = String(defaultId);
|
store.libraryFilter = String(defaultId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => { ro.disconnect(); unsub(); unsubCats(); };
|
return () => { ro.disconnect(); unsub(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
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 { 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 type { Category } from "../../lib/types";
|
||||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory } from "../../store/state.svelte";
|
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory, setCategories } from "../../store/state.svelte";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||||
import type { Keybinds } from "../../lib/keybinds";
|
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 catsLoading: boolean = $state(false);
|
||||||
let catsError: string|null = $state(null);
|
let catsError: string|null = $state(null);
|
||||||
let newFolderName = $state("");
|
let newFolderName = $state("");
|
||||||
@@ -181,9 +182,16 @@
|
|||||||
catsLoading = true; catsError = null;
|
catsLoading = true; catsError = null;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||||
categories = res.categories.nodes.filter(c => c.id !== 0);
|
// Merge server data onto existing store.categories to preserve mangas.nodes
|
||||||
// Publish so Library picks up order changes via its subscription
|
// that Library loaded — Settings' GET_CATEGORIES query may not include them.
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
// 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) {
|
} catch (e: any) {
|
||||||
catsError = e?.message ?? "Failed to load folders";
|
catsError = e?.message ?? "Failed to load folders";
|
||||||
} finally { catsLoading = false; }
|
} finally { catsLoading = false; }
|
||||||
@@ -194,9 +202,8 @@
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||||
categories = [...categories, res.createCategory.category];
|
setCategories([...store.categories, res.createCategory.category]);
|
||||||
newFolderName = "";
|
newFolderName = "";
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
|
||||||
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
|
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +213,7 @@
|
|||||||
if (editingId !== null && editingName.trim()) {
|
if (editingId !== null && editingName.trim()) {
|
||||||
try {
|
try {
|
||||||
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
|
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
|
||||||
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c);
|
setCategories(store.categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c));
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
|
||||||
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
|
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
|
||||||
}
|
}
|
||||||
editingId = null; editingName = "";
|
editingId = null; editingName = "";
|
||||||
@@ -216,55 +222,51 @@
|
|||||||
async function deleteFolder(id: number) {
|
async function deleteFolder(id: number) {
|
||||||
try {
|
try {
|
||||||
await gql(DELETE_CATEGORY, { id });
|
await gql(DELETE_CATEGORY, { id });
|
||||||
categories = categories.filter(c => c.id !== id);
|
setCategories(store.categories.filter(c => c.id !== id));
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
|
||||||
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
|
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveCategory(id: number, direction: -1 | 1) {
|
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;
|
if (idx < 0) return;
|
||||||
const newPos = idx + 1 + direction;
|
const newPos = idx + 1 + direction; // 1-based server position
|
||||||
if (newPos < 1 || newPos > categories.length) return;
|
if (newPos < 1 || newPos > sortable.length) return;
|
||||||
// Optimistic reorder so the UI moves instantly
|
|
||||||
const reordered = [...categories];
|
// Optimistic reorder — splice within sortable slice, keep mangas.nodes intact
|
||||||
|
const reordered = [...sortable];
|
||||||
const [moved] = reordered.splice(idx, 1);
|
const [moved] = reordered.splice(idx, 1);
|
||||||
reordered.splice(idx + direction, 0, moved);
|
reordered.splice(idx + direction, 0, moved);
|
||||||
categories = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||||
// Publish optimistic order immediately — Library re-fetches on this signal
|
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
|
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);
|
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||||
categories = updated.sort((a, b) => a.order - b.order);
|
setCategories([
|
||||||
// Publish server-authoritative order
|
...zeroCat,
|
||||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
...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) {
|
} catch (e: any) {
|
||||||
catsError = e?.message ?? "Failed to reorder";
|
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
|
// Load categories when the folders tab is first opened and the shared store
|
||||||
// are reflected in the Settings folder list without needing a manual reload.
|
// is empty (e.g. user opened Settings before Library was mounted).
|
||||||
// Library publishes its full category data (with mangas.nodes), so we only
|
$effect(() => { if (tab === "folders" && !store.categories.length && !catsLoading) loadCategories(); });
|
||||||
// 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(); });
|
|
||||||
|
|
||||||
|
|
||||||
let selectOpen: string | null = $state(null);
|
let selectOpen: string | null = $state(null);
|
||||||
@@ -1226,11 +1228,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if catsLoading}
|
{#if catsLoading}
|
||||||
<p class="storage-loading">Loading folders…</p>
|
<p class="storage-loading">Loading folders…</p>
|
||||||
{:else if categories.length === 0}
|
{:else if store.categories.filter(c => c.id !== 0).length === 0}
|
||||||
<p class="storage-loading">No folders yet. Create one above.</p>
|
<p class="storage-loading">No folders yet. Create one above.</p>
|
||||||
{:else}
|
{: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;
|
||||||
|
})}
|
||||||
<div class="folder-list">
|
<div class="folder-list">
|
||||||
{#each categories as cat, i}
|
{#each displayCats as cat, i}
|
||||||
<div class="folder-row">
|
<div class="folder-row">
|
||||||
{#if editingId === cat.id}
|
{#if editingId === cat.id}
|
||||||
<input class="text-input" bind:value={editingName}
|
<input class="text-input" bind:value={editingName}
|
||||||
@@ -1254,7 +1264,7 @@
|
|||||||
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}</button>
|
>{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}</button>
|
||||||
<button class="kb-reset" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
<button class="kb-reset" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
||||||
<button class="kb-reset" onclick={() => moveCategory(cat.id, 1)} disabled={i === categories.length - 1} title="Move down">↓</button>
|
<button class="kb-reset" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down">↓</button>
|
||||||
<button class="kb-reset" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
<button class="kb-reset" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
||||||
<button class="kb-reset folder-delete" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
<button class="kb-reset folder-delete" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -365,6 +365,12 @@ class Store {
|
|||||||
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
|
||||||
isFullscreen: boolean = $state(false);
|
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 ────────────────────────────────────────────────
|
// ── Discover session cache ────────────────────────────────────────────────
|
||||||
// Survives navigation within a session but is never persisted to localStorage.
|
// Survives navigation within a session but is never persisted to localStorage.
|
||||||
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
|
||||||
@@ -520,6 +526,7 @@ class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||||
|
setCategories(cats: Category[]) { this.categories = cats; }
|
||||||
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||||
setNavPage(next: NavPage) { this.navPage = next; }
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = 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 setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||||
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||||
export function dismissToast(id: string) { store.dismissToast(id); }
|
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 setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||||
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||||
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
|
|||||||
Reference in New Issue
Block a user