Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
@@ -0,0 +1,753 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { gql } from "@api/client";
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_STOP, UPDATE_LIBRARY_MANGA, UPDATE_CATEGORY_MANGA,
} from "@api";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
import { sortLibrary } from "../lib/librarySort";
import { startLibraryUpdate } from "../lib/libraryUpdater";
import { createPaginator } from "@core/algorithms/paginate";
import { longPress } from "@core/ui/touchscreen";
import {
store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte";
import { saveScroll, getScroll } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "@store/state.svelte";
import LibraryToolbar from "./LibraryToolbar.svelte";
import LibraryGrid from "./LibraryGrid.svelte";
import LibraryFilters from "./LibraryFilters.svelte";
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut, ArrowsClockwise } from "phosphor-svelte";
const CARD_MIN_W = 130;
const CARD_GAP = 16;
const COMPLETED_NAME = "Completed";
const CTX_FOLDER_CAP = 4;
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
let allManga: Manga[] = $state([]);
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
let scrollEl: HTMLDivElement;
let tabsEl = $state<HTMLDivElement>(null!);
let containerWidth: number = $state(800);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let emptyCtx: { x: number; y: number } | null = $state(null);
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
let selectedIds: Set<number> = $state(new Set());
let selectMode: boolean = $state(false);
let bulkWorking: boolean = $state(false);
let bulkAutomateOpen: boolean = $state(false);
let sortPanelOpen: boolean = $state(false);
let filterPanelOpen: boolean = $state(false);
let refreshing: boolean = $state(false);
let refreshProgress = $state({ finished: 0, total: 0 });
let cancelUpdate: (() => void) | null = null;
let refreshDone: boolean = $state(false);
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 dragInsertIdx: number = $state(-1);
let dragTabId: string | null = $state(null);
let dragOverTabId: string | null = $state(null);
const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true);
const tab = $derived(store.libraryFilter);
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
const tabFilters = $derived(store.settings.libraryTabFilters?.[tab] ?? {} as Partial<Record<LibraryContentFilter, boolean>>);
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
const BUILTIN_TABS = ["library", "downloaded"] as const;
const completedCatId = $derived(
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
);
const allTabIds = $derived((() => {
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const pinned = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set([...BUILTIN_TABS, ...catIds]);
const eligible = pinned.filter(id => known.has(id));
const ordered = [...eligible];
const inOrder = new Set(ordered);
for (const id of [...BUILTIN_TABS, ...catIds]) {
if (!inOrder.has(id)) ordered.push(id);
}
return ordered;
})());
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
const virtualTabIds = $derived(visibleTabIds.filter(id =>
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
));
const folderTabIds = $derived(visibleTabIds.filter(id =>
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
));
const visibleCategories = $derived((() => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
const pinned = store.settings.libraryPinnedTabOrder ?? [];
const cats = store.categories.filter(c => c.id !== 0 && !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 === defaultId) return -1;
if (b.id === defaultId) return 1;
const pd = pinOrder(a.id) - pinOrder(b.id);
return pd !== 0 ? pd : a.order - b.order;
});
})());
const categoryMangaMap = $derived((() => {
const map = new Map<number, Manga[]>();
for (const cat of store.categories) {
map.set(cat.id, cat.mangas?.nodes ?? []);
}
return map;
})());
const filtered = $derived((() => {
let items: Manga[];
if (tab === "library") {
items = (store.settings.libraryShowAllInSaved ?? true)
? allManga.filter(m => m.inLibrary)
: (categoryMangaMap.get(0) ?? []);
if ((store.settings.libraryShowAllInSaved ?? true) && (store.settings.libraryHideCompletedInSaved ?? false)) {
const completedCat = store.categories.find(c => c.name === COMPLETED_NAME);
if (completedCat) {
const completedIds = new Set((categoryMangaMap.get(completedCat.id) ?? []).map(m => m.id));
items = items.filter(m => !completedIds.has(m.id));
}
}
} else if (tab === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else {
items = categoryMangaMap.get(Number(tab)) ?? [];
}
items = items.filter(m => !shouldHideNsfw(m, store.settings));
const q = search.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
if (tabStatus !== "ALL") {
items = items.filter(m => {
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
return s === tabStatus;
});
}
const f = store.settings.libraryTabFilters?.[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 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 recentlyReadMap = new Map<number, number>();
if (tabSortMode === "recentlyRead") {
for (const h of store.history) {
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
}
}
return sortLibrary(items, tabSortMode, tabSortDir, recentlyReadMap.size ? recentlyReadMap : undefined);
})());
const { items: visibleManga, hasMore, remaining: remainingCount } = $derived(
paginator.slice(filtered, renderVisible)
);
const counts = $derived((() => {
const m: Record<string, number> = {
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) {
m[String(cat.id)] = (categoryMangaMap.get(cat.id) ?? []).length;
}
return m;
})());
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
let prevTab = $state(tab);
$effect(() => {
const nextTab = tab;
if (scrollEl && nextTab !== prevTab) {
saveScroll(`library:${prevTab}`, scrollEl.scrollTop);
const saved = getScroll(`library:${nextTab}`);
untrack(() => { scrollEl.scrollTo({ top: saved }); });
prevTab = nextTab;
} else if (scrollEl && nextTab === prevTab) {
scrollEl.scrollTo({ top: 0 });
}
});
$effect(() => {
const f = tab;
if (f === "library" || f === "downloaded") return;
const id = Number(f);
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
});
$effect(() => { tab; untrack(() => exitSelectMode()); });
$effect(() => { tab; counts; });
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
});
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
let cardLongPressFired = false;
function rootLongPressAction(node: HTMLElement) {
return longPress(node, {
onLongPress(e) {
if ((e.target as HTMLElement).closest("button, .card")) return;
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
},
});
}
function cardLongPress(node: HTMLElement, m: Manga) {
return longPress(node, {
onLongPress(e) {
cardLongPressFired = true;
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
},
});
}
function onCardClick(e: MouseEvent, m: Manga) {
if (cardLongPressFired) { cardLongPressFired = false; return; }
if (selectMode) { toggleSelect(m.id); return; }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
store.activeManga = m;
}
async function ensureCompletedCategory(cats: Category[]): Promise<Category[]> {
if (cats.some(c => c.name === COMPLETED_NAME)) return cats;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: COMPLETED_NAME });
return [...cats, res.createCategory.category];
} catch { return cats; }
}
async function reloadCategories() {
try {
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
const cats = await ensureCompletedCategory(d.categories.nodes);
setCategories(cats);
} catch (e) { console.error(e); }
}
async function loadData() {
try {
const [nodes] = await Promise.all([
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
reloadCategories(),
]);
const mapped = nodes.map((m: any) => ({ ...m }));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null;
await migrateCategorizedToLibrary();
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
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);
}
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
allManga = allManga.filter(m => m.id !== manga.id);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await reloadCategories();
}
async function deleteAllDownloads(manga: Manga) {
try {
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
if (!ids.length) return;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
} 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) {
const prev = (store.settings as any).categoryFrecency ?? {};
updateSettings({ categoryFrecency: { ...prev, [catId]: (prev[catId] ?? 0) + 1 } } as any);
}
async function toggleMangaCategory(manga: Manga, cat: Category) {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
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 } };
}));
if (!inCat) bumpCategoryFrecency(cat.id);
try {
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, 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); await reloadCategories(); }
}
async function createAndAssign(manga: Manga) {
const name = prompt("Folder name:");
if (!name?.trim()) return;
try {
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: [] });
bumpCategoryFrecency(cat.id);
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); }
}
async function bulkMoveToCategory(cat: Category) {
bulkWorking = true;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); }
}
async function bulkRemoveFromLibrary() {
bulkWorking = true;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? removeFromLibrary(m) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); }
}
function bulkAutomate() {
if (selectedIds.size === 0) return;
bulkAutomateOpen = true;
}
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
async function openMangaFolder(m: Manga) {
let base = store.settings.serverDownloadsPath?.trim();
if (!base) { try { base = await invoke<string>("get_default_downloads_path"); } catch {} }
if (!base) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
const source = m.source?.displayName ?? m.source?.name ?? "";
const path = source ? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}` : `${base}/mangas/${sanitize(m.title)}`;
try { await invoke("open_path", { path }); }
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
}
async function openDownloadsFolder() {
let path = store.settings.serverDownloadsPath?.trim();
if (!path) { try { path = await invoke<string>("get_default_downloads_path"); } catch {} }
if (!path) { addToast({ kind: "error", title: "No downloads path set", body: "Configure it in Settings → Storage" }); return; }
try { await invoke("open_path", { path }); }
catch (e: any) { addToast({ kind: "error", title: "Could not open folder", body: e?.toString?.() ?? path }); }
}
const SIDEBAR_W = 52;
const TITLEBAR_H = 36;
function openCtx(e: MouseEvent, m: Manga) {
if (selectMode) { toggleSelect(m.id); return; }
e.preventDefault();
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
}
function buildCtxItems(m: Manga): MenuEntry[] {
const frecency: Record<number, number> = (store.settings as any).categoryFrecency ?? {};
const sorted = [...visibleCategories].sort((a, b) => (frecency[b.id] ?? 0) - (frecency[a.id] ?? 0));
const pinned = sorted.slice(0, CTX_FOLDER_CAP);
const overflow = sorted.slice(CTX_FOLDER_CAP);
const makeCatEntry = (cat: Category): MenuEntry => {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
};
const pinnedEntries = pinned.map(makeCatEntry);
const overflowChildren = overflow.map(makeCatEntry);
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: 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: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
{ separator: true },
{ label: "Select", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
...(pinnedEntries.length ? [{ separator: true } as MenuEntry, ...pinnedEntries] : []),
...(overflowChildren.length ? [{ label: `More folders (${overflowChildren.length})`, icon: FolderSimple, onClick: () => {}, children: overflowChildren } as MenuEntry] : []),
{ separator: true },
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
];
}
function buildEmptyCtx(): MenuEntry[] {
return [{ label: "New folder", icon: FolderSimplePlus, onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
try { await gql(CREATE_CATEGORY, { name: name.trim() }); await reloadCategories(); }
catch (e) { console.error(e); }
}}];
}
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
await reloadCategories();
}
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 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() {
if (refreshing) return;
refreshing = true;
refreshProgress = { finished: 0, total: 0 };
cancelUpdate = startLibraryUpdate({
onProgress(p) { refreshProgress = p; },
async onDone({ entries, totalUpdated, newChapters }) {
refreshing = false; cancelUpdate = null;
setLibraryUpdates(entries);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await loadData();
refreshDone = true;
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
showToast(newChapters, totalUpdated);
},
onError() { refreshing = false; cancelUpdate = null; },
});
}
function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = "tab"; dragTabId = id;
e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData(DT_TAB, id);
e.dataTransfer!.setData("text/plain", `tab:${id}`);
}
function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
}
function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx;
dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
const tabs = [...allTabIds];
const fromIdx = tabs.indexOf(dragStrId);
const dropIdx = tabs.indexOf(dropId);
if (fromIdx < 0 || dropIdx < 0) return;
const visibleDrop = visibleTabIds[insertAt] ?? null;
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
tabs.splice(fromIdx, 1);
const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
tabs.splice(adjustedDest, 0, dragStrId);
updateSettings({ libraryPinnedTabOrder: tabs });
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
const zeroCat = store.categories.filter(c => c.id === 0);
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
setCategories([...zeroCat, ...reordered]);
const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
if (!dragIsBuiltin) {
const serverPos = catIds.indexOf(dragStrId) + 1;
try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
}
}
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
onMount(() => {
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
ro.observe(scrollEl);
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
const defaultId = store.settings.defaultLibraryCategoryId;
if (defaultId && store.libraryFilter === "library") store.libraryFilter = String(defaultId);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) { sortPanelOpen = false; filterPanelOpen = false; return; }
if (e.key === "Escape" && selectMode) exitSelectMode();
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) { e.preventDefault(); selectAll(); }
}
function onDocMouseDown(e: MouseEvent) {
const t = e.target as HTMLElement;
if (sortPanelOpen && !t.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
if (filterPanelOpen && !t.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
}
window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true);
return () => {
ro.disconnect(); unsub();
cancelUpdate?.();
window.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onDocMouseDown, true);
};
});
</script>
<div
class="root"
role="presentation"
bind:this={scrollEl}
use:rootLongPressAction
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
}}
>
{#if store.settings.libraryBranches ?? true}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
<path d="M270 220 C255 190 230 175 210 150"/>
<path d="M270 220 C290 195 310 185 330 165"/>
<path d="M310 400 C290 375 265 368 245 350"/>
<path d="M310 400 C330 370 355 362 370 340"/>
<path d="M210 150 C195 128 185 108 175 80"/>
<path d="M210 150 C225 130 240 122 258 105"/>
<path d="M245 350 C228 330 215 315 205 290"/>
<path d="M175 80 C168 60 162 42 158 20"/>
<path d="M175 80 C185 62 195 50 208 35"/>
<path d="M205 290 C196 268 190 250 186 225"/>
<path d="M258 105 C268 88 278 72 292 52"/>
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
</g>
</svg>
{/if}
{#if error}
<div class="center">
<p class="error-msg">Could not reach Suwayomi</p>
<p class="error-detail">Make sure the server is running, then retry.</p>
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
</div>
{:else}
<LibraryToolbar
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
{tab}
{tabSortMode}
{tabSortDir}
{tabStatus}
{tabFilters}
{hasActiveFilters}
{anims}
{visibleCategories}
{visibleTabIds}
{virtualTabIds}
{folderTabIds}
{completedCatId}
{counts}
{search}
{refreshing}
{refreshProgress}
{refreshDone}
{refreshingCatId}
{activeDragKind}
{dragInsertIdx}
{dragTabId}
{dragOverTabId}
{sortPanelOpen}
{filterPanelOpen}
bind:tabsEl
onSearchChange={(v) => search = v}
onTabChange={(f) => store.libraryFilter = f}
onSortChange={(mode) => { setTabSort(tab, mode); sortPanelOpen = false; }}
onSortDirToggle={() => toggleTabSortDir(tab)}
onStatusChange={(s) => setTabStatus(tab, s)}
onFilterToggle={(f) => toggleTabFilter(tab, f)}
onFiltersClear={() => clearTabFilters(tab)}
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
onRefresh={startLibraryRefresh}
onCancelRefresh={cancelLibraryRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver}
onTabDragLeave={onTabDragLeave}
onTabDrop={onTabDrop}
onTabDragEnd={onTabDragEnd}
/>
{#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}
<LibraryGrid
{visibleManga}
{filtered}
{loading}
{cols}
{anims}
{selectMode}
{selectedIds}
{hasMore}
{remainingCount}
renderLimit={store.settings.renderLimit ?? 48}
cropCovers={store.settings.libraryCropCovers}
statsAlways={store.settings.libraryStatsAlways ?? false}
libraryFilter={tab}
onCardClick={onCardClick}
onCardContextMenu={openCtx}
onCardLongPress={cardLongPress}
onLoadMore={loadMore}
onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode}
onSelectAll={selectAll}
onBulkMove={bulkMoveToCategory}
onBulkRemove={bulkRemoveFromLibrary}
onBulkAutomate={bulkAutomate}
{bulkWorking}
{visibleCategories}
/>
{/if}
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
{#if emptyCtx}
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
{/if}
{#if bulkAutomateOpen}
<BulkAutomationPanel
ids={selectedIds}
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
/>
{/if}
<style>
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
.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-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; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
</style>
@@ -0,0 +1,113 @@
<script lang="ts">
import { Check, Funnel } from "phosphor-svelte";
import type { LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
interface Props {
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
filterPanelOpen: boolean;
onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void;
onFilterPanelToggle: () => void;
}
let {
tabStatus, tabFilters, hasActiveFilters, filterPanelOpen,
onStatusChange, onFilterToggle, onFiltersClear, onFilterPanelToggle,
}: Props = $props();
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
ALL: "All statuses", ONGOING: "Ongoing", COMPLETED: "Completed",
CANCELLED: "Cancelled", HIATUS: "Hiatus", UNKNOWN: "Unknown",
};
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
];
const CONTENT_FILTERS: [LibraryContentFilter, string][] = [
["unread", "Unread"],
["started", "Started"],
["downloaded", "Downloaded"],
["bookmarked", "Bookmarked"],
];
</script>
<div class="filter-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={hasActiveFilters}
title="Filter"
onclick={onFilterPanelToggle}
>
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterPanelOpen}
<div class="dropdown-panel filter-panel" role="menu">
<div class="panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={onFiltersClear}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each CONTENT_FILTERS as [f, label]}
<button
class="panel-item panel-item-check"
class:panel-item-active={tabFilters[f]}
role="menuitem"
onclick={() => onFilterToggle(f)}
>
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
<div class="panel-divider"></div>
<p class="panel-label">Status</p>
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
<button
class="panel-item panel-item-check"
class:panel-item-active={tabStatus === s}
role="menuitem"
onclick={() => onStatusChange(tabStatus === s ? "ALL" : s)}
>
<span class="panel-check" class:panel-check-on={tabStatus === s}>
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
</span>
{STATUS_LABELS[s]}
</button>
{/each}
</div>
{/if}
</div>
<style>
.filter-panel-wrap { position: relative; }
.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); }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.panel-clear-btn:hover { color: var(--color-error); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: flex-start; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-item-check { justify-content: flex-start; }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,219 @@
<script lang="ts">
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { longPress } from "@core/ui/touchscreen";
import type { Manga, Category } from "@types";
interface Props {
visibleManga: Manga[];
filtered: Manga[];
loading: boolean;
cols: number;
anims: boolean;
selectMode: boolean;
selectedIds: Set<number>;
hasMore: boolean;
remainingCount: number;
renderLimit: number;
cropCovers: boolean;
statsAlways: boolean;
libraryFilter: string;
bulkWorking: boolean;
visibleCategories: Category[];
onCardClick: (e: MouseEvent, m: Manga) => void;
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
onLoadMore: () => void;
onRetry: () => void;
onExitSelectMode: () => void;
onSelectAll: () => void;
onBulkMove: (cat: Category) => void;
onBulkRemove: () => void;
onBulkAutomate: () => void;
}
let {
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
bulkWorking, visibleCategories,
onCardClick, onCardContextMenu, onCardLongPress,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
}: Props = $props();
let bulkMoveOpen: boolean = $state(false);
$effect(() => {
if (!bulkMoveOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".bulk-move-wrap")) bulkMoveOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
$effect(() => { if (!selectMode) bulkMoveOpen = false; });
</script>
{#if selectMode}
<div class="select-bar">
<span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
<div class="select-bar-right">
{#if visibleCategories.length}
<div class="bulk-move-wrap">
<button
class="sel-action-btn"
disabled={selectedIds.size === 0 || bulkWorking}
onclick={() => bulkMoveOpen = !bulkMoveOpen}
>
<Folder size={13} weight="bold" />
Move
</button>
{#if bulkMoveOpen}
<div class="bulk-folder-list">
{#each visibleCategories as cat}
<button class="bulk-folder-item" onclick={() => { onBulkMove(cat); bulkMoveOpen = false; }}>
<Folder size={11} weight="bold" />
{cat.name}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
<Robot size={13} weight="bold" />
Automate
</button>
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
<Trash size={13} weight="bold" />
Remove
</button>
</div>
</div>
{/if}
<div class="content" role="presentation" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
{#if loading}
<div class="grid">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="center">
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: libraryFilter === "downloaded" ? "No downloaded manga."
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
</div>
{:else}
<div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)}
{@const isSelected = selectedIds.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
class:anims={anims}
use:onCardLongPress={m}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => onCardContextMenu(e, m)}
>
<div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
<div class="overlay-badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
{#if selectMode}
<div class="select-overlay" aria-hidden="true">
<div class="select-check" class:checked={isSelected}>
{#if isSelected}
<CheckSquare size={20} weight="fill" />
{:else}
<div class="select-check-empty"></div>
{/if}
</div>
</div>
{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{#if hasMore}
<div class="load-more-row">
<button class="load-more-btn" onclick={onLoadMore}>
Show {Math.min(remainingCount, renderLimit)} more
<span class="load-more-count">({remainingCount} remaining)</span>
</button>
</div>
{/if}
{/if}
</div>
<style>
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
.select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
.sel-text-btn:hover { color: var(--text-primary); }
.sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
.bulk-move-wrap { position: relative; }
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card.anims:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
.card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); will-change: transform; }
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
.card-info-overlay.anim { transition: opacity 0.18s ease; }
.card-info-overlay.instant { transition: none; }
.card-info-overlay.always { opacity: 1; }
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; }
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.select-overlay { position: absolute; inset: 0; z-index: 3; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; }
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
.card.anims .title { transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
.load-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,257 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
} from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
interface Props {
tab: string;
tabSortMode: LibrarySortMode;
tabSortDir: LibrarySortDir;
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
anims: boolean;
visibleCategories: Category[];
visibleTabIds: string[];
virtualTabIds: string[];
folderTabIds: string[];
completedCatId: number | null;
counts: Record<string, number>;
search: string;
activeDragKind: "tab" | null;
dragInsertIdx: number;
dragTabId: string | null;
dragOverTabId: string | null;
sortPanelOpen: boolean;
filterPanelOpen: boolean;
tabsEl: HTMLDivElement;
onSearchChange: (v: string) => void;
onTabChange: (f: string) => void;
onSortChange: (mode: LibrarySortMode) => void;
onSortDirToggle: () => void;
onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void;
onSortPanelToggle: () => void;
onFilterPanelToggle: () => void;
onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, id: string) => void;
onTabDragEnd: () => void;
}
let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props();
function onTabsWheel(e: WheelEvent) {
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
const idx = ids.indexOf(tab);
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
}
$effect(() => {
tab;
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
const pl = tabsEl.scrollLeft;
const cw = tabsEl.clientWidth;
const ol = active.offsetLeft;
const ow = active.offsetWidth;
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
});
const SORT_LABELS: Record<LibrarySortMode, string> = {
az: "AZ",
unreadCount: "Unread chapters",
totalChapters: "Total chapters",
recentlyAdded: "Recently added",
recentlyRead: "Recently read",
latestFetched: "Latest fetched chapter",
latestUploaded: "Latest uploaded chapter",
};
const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
];
</script>
<div class="header">
<span class="heading">Library</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
{#each visibleTabIds as id, idx}
{@const cat = visibleCategories.find(c => String(c.id) === id)}
{#if id === "library" || id === "downloaded" || cat}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = cat && id === String(completedCatId)}
{@const isDraggable = true}
{#if activeDragKind === "tab" && dragInsertIdx === idx}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
<button
class="tab"
class:active={tab === id}
class:tab-dragging={isDraggable && dragTabId === id}
draggable={isDraggable}
onclick={() => onTabChange(id)}
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
ondragleave={isDraggable ? onTabDragLeave : undefined}
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
ondragend={isDraggable ? onTabDragEnd : undefined}
>
{#if id === "library"}<Books size={11} weight="bold" />
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
{:else if cat}<Folder size={11} weight="bold" />
{/if}
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
<span class="tab-count">{counts[id] ?? 0}</span>
</button>
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
{/if}
{/each}
</div>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
</div>
{#if refreshing}
<button
class="icon-btn refresh-btn icon-btn-active"
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
onclick={onRefresh}
>
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
{#if refreshProgress.total > 0}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{/if}
</button>
{:else}
<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}>
<FolderSimple size={15} weight="bold" />
</button>
<div class="sort-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
title="Sort"
onclick={onSortPanelToggle}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortPanelOpen}
<div class="dropdown-panel sort-panel anim-fade-in" role="menu">
<div class="panel-header">
<span class="panel-heading">Sort</span>
</div>
<div class="panel-divider"></div>
<p class="panel-label">Order by</p>
{#each ALL_SORT_MODES as m}
<button
class="panel-item"
class:panel-item-active={tabSortMode === m}
role="menuitem"
onclick={() => onSortChange(m)}
>
{SORT_LABELS[m]}
{#if tabSortMode === m}
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" class="sort-caret" />
{:else}<CaretDown size={11} weight="bold" class="sort-caret" />{/if}
{/if}
</button>
{/each}
<button class="panel-item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
{tabSortDir === "asc" ? "Ascending" : "Descending"}
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" />
{:else}<CaretDown size={11} weight="bold" />{/if}
</button>
</div>
{/if}
</div>
<LibraryFilters
{tabStatus}
{tabFilters}
{hasActiveFilters}
{filterPanelOpen}
{onStatusChange}
{onFilterToggle}
{onFiltersClear}
{onFilterPanelToggle}
/>
</div>
</div>
<style>
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
.tabs::-webkit-scrollbar { display: none; }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.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-count { font-size: var(--text-2xs); opacity: 0.6; }
.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 { 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::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.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); }
.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; }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
:global(.sort-caret) { flex-shrink: 0; }
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as Library } from "./components/Library.svelte";
export { sortLibrary, librarySorter } from "./lib/librarySort";
export * from "./store/libraryState.svelte";
+52
View File
@@ -0,0 +1,52 @@
import { createSorter } from "@core/algorithms/sort";
import type { Manga } from "@types";
import type { LibrarySortMode, LibrarySortDir } from "@store/state.svelte";
export const librarySorter = createSorter<Manga>({
defaultField: "az",
defaultDir: "asc",
fields: [
{
key: "az",
comparator: (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
},
{
key: "unreadCount",
comparator: (a, b) => (a.unreadCount ?? 0) - (b.unreadCount ?? 0),
},
{
key: "totalChapters",
comparator: (a, b) => (a.chapters?.totalCount ?? 0) - (b.chapters?.totalCount ?? 0),
},
{
key: "recentlyAdded",
comparator: (a, b) => Number(a.inLibraryAt ?? 0) - Number(b.inLibraryAt ?? 0),
},
{
key: "recentlyRead",
comparator: (a, b, ctx) => {
const map = ctx?.recentlyReadMap as Map<number, number> | undefined;
const ra = map?.get(a.id) ?? 0;
const rb = map?.get(b.id) ?? 0;
return ra - rb;
},
},
{
key: "latestFetched",
comparator: (a, b) => Number(a.latestFetchedChapter?.uploadDate ?? 0) - Number(b.latestFetchedChapter?.uploadDate ?? 0),
},
{
key: "latestUploaded",
comparator: (a, b) => Number(a.latestUploadedChapter?.uploadDate ?? 0) - Number(b.latestUploadedChapter?.uploadDate ?? 0),
},
],
});
export function sortLibrary(
items: Manga[],
mode: LibrarySortMode,
dir: LibrarySortDir,
recentlyReadMap?: Map<number, number>,
): Manga[] {
return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined);
}
+166
View File
@@ -0,0 +1,166 @@
import { gql } from "@api/client";
import { LIBRARY_UPDATE_STATUS } from "@api/queries/manga";
import { UPDATE_LIBRARY, FETCH_MANGA } from "@api/mutations/manga";
import { GET_LIBRARY } from "@api/queries/manga";
import type { LibraryUpdateEntry } from "@store/state.svelte";
const POLL_INTERVAL_MS = 2000;
const POLL_INITIAL_MS = 500;
export interface UpdateProgress {
finished: number;
total: number;
skippedManga: number;
skippedCategories: number;
}
export interface UpdateResult {
entries: LibraryUpdateEntry[];
totalUpdated: number;
newChapters: number;
}
export interface LibraryUpdaterCallbacks {
onProgress: (p: UpdateProgress) => void;
onDone: (r: UpdateResult) => void;
onError: (e?: unknown) => void;
}
export async function refreshLibraryMetadata(
onProgress?: (done: number, total: number) => void,
): Promise<void> {
const data = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LIBRARY, {});
const ids = data.mangas.nodes.map(m => m.id);
let done = 0;
for (const id of ids) {
try {
await gql(FETCH_MANGA, { id });
} catch {}
onProgress?.(++done, ids.length);
}
}
export function startLibraryUpdate(callbacks: LibraryUpdaterCallbacks): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
function cancel() {
cancelled = true;
if (timer) { clearTimeout(timer); timer = null; }
}
function buildEntries(
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[]
): LibraryUpdateEntry[] {
const byManga = new Map<number, LibraryUpdateEntry>();
for (const u of mangaUpdates) {
if (u.status !== "UPDATED") continue;
const existing = byManga.get(u.manga.id);
if (existing) {
existing.newChapters++;
} else {
byManga.set(u.manga.id, {
mangaId: u.manga.id,
mangaTitle: u.manga.title,
thumbnailUrl: u.manga.thumbnailUrl,
newChapters: 1,
checkedAt: Date.now(),
});
}
}
return [...byManga.values()];
}
async function run() {
let jobsStarted = false;
try {
const res = await gql<{
updateLibrary: {
updateStatus: {
jobsInfo: {
isRunning: boolean;
totalJobs: number;
finishedJobs: number;
skippedMangasCount: number;
skippedCategoriesCount: number;
}
}
}
}>(UPDATE_LIBRARY, {});
if (cancelled) return;
const { jobsInfo } = res.updateLibrary.updateStatus;
jobsStarted = jobsInfo.totalJobs > 0;
callbacks.onProgress({
finished: jobsInfo.finishedJobs,
total: jobsInfo.totalJobs,
skippedManga: jobsInfo.skippedMangasCount,
skippedCategories: jobsInfo.skippedCategoriesCount,
});
if (!jobsStarted) {
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
return;
}
if (jobsStarted && !jobsInfo.isRunning) {
callbacks.onDone({ entries: [], totalUpdated: 0, newChapters: 0 });
return;
}
} catch (e) {
console.error("[libraryUpdater] failed to start update", e);
if (!cancelled) callbacks.onError(e);
return;
}
function poll() {
gql<{
libraryUpdateStatus: {
jobsInfo: {
isRunning: boolean;
finishedJobs: number;
totalJobs: number;
skippedMangasCount: number;
skippedCategoriesCount: number;
};
mangaUpdates: {
status: string;
manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number };
}[];
}
}>(LIBRARY_UPDATE_STATUS, {})
.then(async d => {
if (cancelled) return;
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
if (jobsInfo.totalJobs > 0) jobsStarted = true;
callbacks.onProgress({
finished: jobsInfo.finishedJobs,
total: jobsInfo.totalJobs,
skippedManga: jobsInfo.skippedMangasCount,
skippedCategories: jobsInfo.skippedCategoriesCount,
});
if (!jobsInfo.isRunning && jobsStarted) {
const entries = buildEntries(mangaUpdates);
const newChapters = entries.reduce((s, e) => s + e.newChapters, 0);
callbacks.onDone({ entries, totalUpdated: entries.length, newChapters });
return;
}
timer = setTimeout(poll, POLL_INTERVAL_MS);
})
.catch((e) => {
console.error("[libraryUpdater] poll error", e);
if (!cancelled) callbacks.onError(e);
});
}
timer = setTimeout(poll, POLL_INITIAL_MS);
}
run();
return cancel;
}
@@ -0,0 +1,318 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs";
import { store, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import type { MangaPrefs } from "@store/state.svelte";
let { ids, onClose }: {
ids: Set<number>;
onClose: () => void;
} = $props();
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
let draft: MangaPrefs = $state({ ...DEFAULT_MANGA_PREFS });
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
const mosaicCovers = $derived.by(() => {
const idArr = [...ids].slice(0, 9);
return idArr
.map(id => store.library?.find(m => m.id === id))
.filter(Boolean)
.map(m => resolvedCover(m!.id, m!.thumbnailUrl));
});
function apply() {
for (const id of ids) {
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
setPref(id, key, draft[key] as any);
}
}
onClose();
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
<div class="modal-header">
<div class="header-inner">
<div class="header-left">
{#if mosaicCovers.length > 0}
<div class="mosaic" aria-hidden="true">
{#each mosaicCovers.slice(0, 5) as src}
<img class="mosaic-tile" {src} alt="" />
{/each}
{#if ids.size > 5}
<span class="mosaic-overflow">+{ids.size - 5}</span>
{/if}
</div>
{/if}
<div class="header-text">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">{ids.size} series selected</span>
</div>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={get("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={get("autoDownload")}
onclick={() => set("autoDownload", !get("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("downloadAhead") === opt.value}
onclick={() => set("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("maxKeepChapters") === opt.value}
onclick={() => set("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={get("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={get("deleteOnRead")}
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if get("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("deleteDelayHours") === opt.value}
onclick={() => set("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={get("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={get("pauseUpdates")}
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("refreshInterval") === opt.value}
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
<div class="modal-footer">
<button class="apply-btn" onclick={apply}>Apply to {ids.size} series</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header { border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-inner {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); gap: var(--sp-3);
}
.header-left { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.mosaic {
display: flex; align-items: center; flex-shrink: 0;
}
.mosaic-tile {
width: 28px; height: 38px;
object-fit: cover; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
margin-left: -6px; box-shadow: -1px 0 0 var(--bg-surface);
}
.mosaic-tile:first-child { margin-left: 0; }
.mosaic-overflow {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
margin-left: var(--sp-1); flex-shrink: 0;
}
.header-text { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
.modal-footer {
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-dim); flex-shrink: 0;
}
.apply-btn {
width: 100%; padding: 8px; border-radius: var(--radius-md);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
}
.apply-btn:hover { background: var(--accent-dim); border-color: var(--accent); }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,45 @@
import { store, updateSettings, setCategories, setLibraryUpdates, addToast } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
import type { Category } from "@types";
export { store };
export function setTabSort(tab: string, mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[tab];
const newDir = dir ?? prev?.dir ?? "asc";
updateSettings({
libraryTabSort: { ...store.settings.libraryTabSort, [tab]: { mode, dir: newDir } },
});
}
export function toggleTabSortDir(tab: string) {
const prev = store.settings.libraryTabSort[tab];
const mode = prev?.mode ?? "az";
const dir = prev?.dir === "asc" ? "desc" : "asc";
setTabSort(tab, mode, dir);
}
export function setTabStatus(tab: string, status: LibraryStatusFilter) {
updateSettings({
libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: status },
});
}
export function toggleTabFilter(tab: string, filter: LibraryContentFilter) {
const current = store.settings.libraryTabFilters?.[tab] ?? {};
updateSettings({
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[tab]: { ...current, [filter]: !current[filter] },
},
});
}
export function clearTabFilters(tab: string) {
updateSettings({
libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: "ALL" },
libraryTabFilters: { ...(store.settings.libraryTabFilters ?? {}), [tab]: {} },
});
}
export { setCategories, setLibraryUpdates, addToast };