import type { Manga } from "$lib/types"; import type { MangaStatus } from "$lib/server-adapters/types"; import type { Category } from "$lib/types"; import { settingsState, updateSettings } from "$lib/state/settings.svelte"; export type LibrarySortOption = | "az" | "unreadCount" | "lastRead" | "dateAdded" | "totalChapters" | "latestFetched" | "latestUploaded"; export type LibrarySortDir = "asc" | "desc"; export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked"; export type LibraryStatusFilter = | "ALL" | "ONGOING" | "COMPLETED" | "ON_HIATUS" | "CANCELLED" | "PUBLISHING_FINISHED"; class LibraryState { items = $state([]); categories = $state([]); loading = $state(false); error = $state(null); refreshing = $state(false); tab = $state("library"); tabSort = $state>({}); tabStatus = $state>({}); tabFilters = $state>>>({}); hiddenTabs = $state>(new Set()); pinnedTabOrder = $state([]); defaultCategoryId = $state(null); showAllInSaved = $state(true); hideCompletedInSaved = $state(false); categoryFrecency = $state>({}); filter = $state({ query: "" }); selected = $state(new Set()); selectMode = $state(false); refreshProgress = $state({ finished: 0, total: 0 }); refreshDone = $state(false); refreshingMangaId = $state(null); refreshingCatId = $state(null); readonly COMPLETED_NAME = "Completed"; get completedCatId(): number | null { return this.categories.find(c => c.name === this.COMPLETED_NAME && c.id !== 0)?.id ?? null; } get categoryMangaMap(): Map { const map = new Map(); for (const cat of this.categories) { map.set(cat.id, (cat as any).mangas?.nodes ?? []); } return map; } get allTabIds(): string[] { const catIds = this.categories.filter(c => c.id !== 0).map(c => String(c.id)); const BUILTIN = ["library", "downloaded"]; const known = new Set([...BUILTIN, ...catIds]); const ordered: string[] = []; const inOrder = new Set(); for (const id of this.pinnedTabOrder) { if (known.has(id) && !inOrder.has(id)) { ordered.push(id); inOrder.add(id); } } for (const id of [...BUILTIN, ...catIds]) { if (!inOrder.has(id)) { ordered.push(id); inOrder.add(id); } } return ordered; } get visibleTabIds(): string[] { return this.allTabIds.filter(id => !this.hiddenTabs.has(id)); } get visibleCategories(): Category[] { const pinned = this.pinnedTabOrder; const defId = this.defaultCategoryId; const cats = this.categories.filter(c => c.id !== 0 && !this.hiddenTabs.has(String(c.id))); const pinOrder = (id: number) => { const i = pinned.indexOf(String(id)); return i === -1 ? Infinity : i; }; return [...cats].sort((a, b) => { if (a.id === defId) return -1; if (b.id === defId) return 1; const pd = pinOrder(a.id) - pinOrder(b.id); return pd !== 0 ? pd : (a as any).order - (b as any).order; }); } get counts(): Record { const m: Record = { library: this.showAllInSaved ? this.items.filter(x => x.inLibrary).length : (this.categoryMangaMap.get(0) ?? []).length, downloaded: this.items.filter(x => (x.downloadCount ?? 0) > 0).length, }; for (const cat of this.visibleCategories) { m[String(cat.id)] = (this.categoryMangaMap.get(cat.id) ?? []).length; } return m; } filteredItems = $derived.by(() => { const tab = this.tab; let items: Manga[]; if (tab === "library") { items = this.showAllInSaved ? this.items.filter(m => m.inLibrary) : (this.categoryMangaMap.get(0) ?? []); if (this.showAllInSaved && this.hideCompletedInSaved) { const completedCat = this.categories.find(c => c.name === this.COMPLETED_NAME); if (completedCat) { const completedIds = new Set((this.categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id)); items = items.filter(m => !completedIds.has(m.id)); } } } else if (tab === "downloaded") { items = this.items.filter(m => (m.downloadCount ?? 0) > 0); } else { items = this.categoryMangaMap.get(Number(tab)) ?? []; } const q = this.filter.query.trim().toLowerCase(); if (q) items = items.filter(m => m.title.toLowerCase().includes(q)); const status = this.tabStatus[tab] ?? "ALL"; if (status !== "ALL") { items = items.filter(m => { const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN"; return s === status; }); } const f = this.tabFilters[tab] ?? {}; if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0); if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.totalChapters ?? 0) > (m.unreadCount ?? 0)); if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0); if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0); const { mode, dir } = this.tabSort[tab] ?? { mode: "az" as LibrarySortOption, dir: "asc" as LibrarySortDir }; const sorted = [...items].sort((a, b) => { switch (mode) { case "unreadCount": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0); case "lastRead": return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0); case "dateAdded": return (b.addedAt ?? 0) - (a.addedAt ?? 0); case "totalChapters": return (b.chapters?.totalCount ?? 0) - (a.chapters?.totalCount ?? 0); case "latestFetched": return Number(b.latestFetchedChapter?.uploadDate ?? 0) - Number(a.latestFetchedChapter?.uploadDate ?? 0); case "latestUploaded": return Number(b.latestUploadedChapter?.uploadDate ?? 0) - Number(a.latestUploadedChapter?.uploadDate ?? 0); default: return a.title.localeCompare(b.title); } }); return dir === "desc" ? sorted.reverse() : sorted; }); get hasActiveFilters(): boolean { const tab = this.tab; const status = this.tabStatus[tab] ?? "ALL"; const filters = this.tabFilters[tab] ?? {}; return status !== "ALL" || Object.values(filters).some(Boolean); } setTabSort(tab: string, mode: LibrarySortOption, dir?: LibrarySortDir) { const prev = this.tabSort[tab]; const newDir = dir ?? prev?.dir ?? "asc"; this.tabSort = { ...this.tabSort, [tab]: { mode, dir: newDir } }; } toggleTabSortDir(tab: string) { const prev = this.tabSort[tab]; const mode = prev?.mode ?? "az"; const dir = prev?.dir === "asc" ? "desc" : "asc"; this.setTabSort(tab, mode, dir); } setTabStatus(tab: string, status: LibraryStatusFilter) { this.tabStatus = { ...this.tabStatus, [tab]: status }; } toggleTabFilter(tab: string, filter: LibraryContentFilter) { const current = this.tabFilters[tab] ?? {}; this.tabFilters = { ...this.tabFilters, [tab]: { ...current, [filter]: !current[filter] } }; } clearTabFilters(tab: string) { this.tabStatus = { ...this.tabStatus, [tab]: "ALL" }; this.tabFilters = { ...this.tabFilters, [tab]: {} }; } syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null; libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean; }) { if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs); if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder; if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null; if (s.libraryShowAllInSaved !== undefined) this.showAllInSaved = s.libraryShowAllInSaved; if (s.libraryHideCompletedInSaved !== undefined) this.hideCompletedInSaved = s.libraryHideCompletedInSaved; } setCategories(cats: Category[]) { this.categories = cats; } bumpCategoryFrecency(catId: number) { this.categoryFrecency = { ...this.categoryFrecency, [catId]: (this.categoryFrecency[catId] ?? 0) + 1 }; } enterSelect(id?: number) { this.selectMode = true; if (id !== undefined) this.selected = new Set([id]); } exitSelect() { this.selectMode = false; this.selected = new Set(); } toggleSelect(id: number) { const next = new Set(this.selected); if (next.has(id)) next.delete(id); else next.add(id); this.selected = next; if (next.size === 0) this.exitSelect(); } selectAll(ids: number[]) { this.selected = new Set(ids); } guardTab() { if (this.tab === "library" || this.tab === "downloaded") return; const id = Number(this.tab); if (!this.categories.some(c => c.id === id)) this.tab = "library"; } } export const libraryState = new LibraryState();