mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
1264 lines
58 KiB
Svelte
1264 lines
58 KiB
Svelte
<script lang="ts">
|
||
import { onMount, untrack } from "svelte";
|
||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, X, ArrowSquareOut, SortAscending, Funnel, CaretUp, CaretDown, Check, ArrowsClockwise } from "phosphor-svelte";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { gql, thumbUrl } from "../../lib/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_LIBRARY, LIBRARY_UPDATE_STATUS } from "../../lib/queries";
|
||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "../../lib/util";
|
||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings, setCategories, setLibraryUpdates, addToast } from "../../store/state.svelte";
|
||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "../../store/state.svelte";
|
||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||
import Thumbnail from "../shared/Thumbnail.svelte";
|
||
|
||
const CARD_MIN_W = 130;
|
||
const CARD_GAP = 16;
|
||
const COMPLETED_NAME = "Completed";
|
||
|
||
// Drag type discriminators (tab reorder only — manga cards no longer use drag).
|
||
const DT_TAB = "application/x-moku-tab";
|
||
|
||
let activeDragKind: "tab" | null = $state(null);
|
||
let dragInsertIdx: number = $state(-1);
|
||
|
||
// ── Sort / filter panel ───────────────────────────────────────────────────
|
||
|
||
let sortPanelOpen: boolean = $state(false);
|
||
let filterPanelOpen: boolean = $state(false);
|
||
|
||
function openSortPanel() {
|
||
sortPanelOpen = !sortPanelOpen;
|
||
filterPanelOpen = false;
|
||
}
|
||
|
||
function openFilterPanel() {
|
||
filterPanelOpen = !filterPanelOpen;
|
||
sortPanelOpen = false;
|
||
}
|
||
|
||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||
az: "A–Z",
|
||
unreadCount: "Unread chapters",
|
||
totalChapters: "Total chapters",
|
||
recentlyAdded: "Recently added",
|
||
recentlyRead: "Recently read",
|
||
latestFetched: "Latest fetched chapter",
|
||
latestUploaded: "Latest uploaded chapter",
|
||
};
|
||
|
||
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
|
||
ALL: "All statuses",
|
||
ONGOING: "Ongoing",
|
||
COMPLETED: "Completed",
|
||
CANCELLED: "Cancelled",
|
||
HIATUS: "Hiatus",
|
||
UNKNOWN: "Unknown",
|
||
};
|
||
|
||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||
"az", "unreadCount", "totalChapters", "recentlyAdded",
|
||
"recentlyRead", "latestFetched", "latestUploaded",
|
||
];
|
||
|
||
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
|
||
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
|
||
];
|
||
|
||
// Per-tab reactive state — $derived so Svelte tracks changes to libraryFilter and settings
|
||
const tabSortMode = $derived(
|
||
store.settings.libraryTabSort[store.libraryFilter]?.mode ?? "az" as LibrarySortMode
|
||
);
|
||
const tabSortDir = $derived(
|
||
store.settings.libraryTabSort[store.libraryFilter]?.dir ?? "asc" as LibrarySortDir
|
||
);
|
||
const tabStatus = $derived(
|
||
store.settings.libraryTabStatus[store.libraryFilter] ?? "ALL" as LibraryStatusFilter
|
||
);
|
||
const tabFilters = $derived(
|
||
store.settings.libraryTabFilters?.[store.libraryFilter] ?? {} as Partial<Record<LibraryContentFilter, boolean>>
|
||
);
|
||
const hasActiveFilters = $derived(
|
||
tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean)
|
||
);
|
||
|
||
function setTabSort(mode: LibrarySortMode, dir?: LibrarySortDir) {
|
||
const prev = store.settings.libraryTabSort[store.libraryFilter];
|
||
const newDir = dir ?? prev?.dir ?? "asc";
|
||
updateSettings({
|
||
libraryTabSort: {
|
||
...store.settings.libraryTabSort,
|
||
[store.libraryFilter]: { mode, dir: newDir },
|
||
},
|
||
});
|
||
}
|
||
|
||
function toggleTabSortDir() {
|
||
setTabSort(tabSortMode, tabSortDir === "asc" ? "desc" : "asc");
|
||
}
|
||
|
||
function setTabStatus(status: LibraryStatusFilter) {
|
||
updateSettings({
|
||
libraryTabStatus: {
|
||
...store.settings.libraryTabStatus,
|
||
[store.libraryFilter]: status,
|
||
},
|
||
});
|
||
}
|
||
|
||
function toggleTabFilter(filter: LibraryContentFilter) {
|
||
const current = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||
updateSettings({
|
||
libraryTabFilters: {
|
||
...(store.settings.libraryTabFilters ?? {}),
|
||
[store.libraryFilter]: {
|
||
...current,
|
||
[filter]: !current[filter],
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function clearAllFilters() {
|
||
updateSettings({
|
||
libraryTabStatus: {
|
||
...store.settings.libraryTabStatus,
|
||
[store.libraryFilter]: "ALL",
|
||
},
|
||
libraryTabFilters: {
|
||
...(store.settings.libraryTabFilters ?? {}),
|
||
[store.libraryFilter]: {},
|
||
},
|
||
});
|
||
}
|
||
|
||
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(0);
|
||
let scrollEl: HTMLDivElement;
|
||
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);
|
||
|
||
// ── Multi-select ──────────────────────────────────────────────────────────
|
||
|
||
let selectedIds: Set<number> = $state(new Set());
|
||
let selectMode: boolean = $state(false);
|
||
let bulkWorking: boolean = $state(false);
|
||
// Which folder-move popup is open (shows inline folder list)
|
||
let bulkMoveOpen: boolean = $state(false);
|
||
|
||
function enterSelectMode(id?: number) {
|
||
selectMode = true;
|
||
if (id !== undefined) selectedIds = new Set([id]);
|
||
}
|
||
|
||
function exitSelectMode() {
|
||
selectMode = false;
|
||
selectedIds = new Set();
|
||
bulkMoveOpen = false;
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
// Long-press to enter select mode on touch devices
|
||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||
function onCardPointerDown(e: PointerEvent, m: Manga) {
|
||
if (e.button !== 0) return; // only primary
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTimer = null;
|
||
enterSelectMode(m.id);
|
||
}, 500);
|
||
}
|
||
function onCardPointerUp() {
|
||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||
}
|
||
function onCardPointerLeave() {
|
||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||
}
|
||
|
||
function onCardClick(e: MouseEvent, m: Manga) {
|
||
if (selectMode) {
|
||
toggleSelect(m.id);
|
||
return;
|
||
}
|
||
// Cmd/Ctrl+click or Shift+click enters select mode
|
||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||
e.preventDefault();
|
||
enterSelectMode(m.id);
|
||
return;
|
||
}
|
||
store.activeManga = m;
|
||
}
|
||
|
||
// ── Bulk mutations ────────────────────────────────────────────────────────
|
||
|
||
async function bulkMoveToCategory(cat: Category) {
|
||
bulkWorking = true;
|
||
bulkMoveOpen = false;
|
||
try {
|
||
await Promise.all(
|
||
[...selectedIds].map(id => {
|
||
const manga = allManga.find(m => m.id === id);
|
||
if (!manga) return Promise.resolve();
|
||
return toggleMangaCategory(manga, cat);
|
||
})
|
||
);
|
||
} finally {
|
||
bulkWorking = false;
|
||
exitSelectMode();
|
||
}
|
||
}
|
||
|
||
async function bulkRemoveFromLibrary() {
|
||
bulkWorking = true;
|
||
try {
|
||
await Promise.all(
|
||
[...selectedIds].map(id => {
|
||
const manga = allManga.find(m => m.id === id);
|
||
if (!manga) return Promise.resolve();
|
||
return removeFromLibrary(manga);
|
||
})
|
||
);
|
||
} finally {
|
||
bulkWorking = false;
|
||
exitSelectMode();
|
||
}
|
||
}
|
||
|
||
// ── Completed category auto-create ────────────────────────────────────────
|
||
|
||
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; }
|
||
}
|
||
|
||
// ── Data loading ──────────────────────────────────────────────────────────
|
||
|
||
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); }
|
||
}
|
||
|
||
function loadLibrary() {
|
||
return cache.get(
|
||
CACHE_KEYS.LIBRARY,
|
||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
||
DEFAULT_TTL_MS,
|
||
CACHE_GROUPS.LIBRARY,
|
||
);
|
||
}
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [nodes] = await Promise.all([loadLibrary(), reloadCategories()]);
|
||
const mapped = nodes.map((m: any) => ({
|
||
...m,
|
||
chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0,
|
||
}));
|
||
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);
|
||
}
|
||
|
||
$effect(() => {
|
||
retryCount;
|
||
loading = true; error = null;
|
||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||
untrack(() => loadData());
|
||
});
|
||
|
||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
||
|
||
$effect(() => {
|
||
const f = store.libraryFilter;
|
||
if (f === "library" || f === "downloaded") return;
|
||
const id = Number(f);
|
||
if (!store.categories.some(c => c.id === id)) {
|
||
untrack(() => { store.libraryFilter = "library"; });
|
||
}
|
||
});
|
||
|
||
// Exit select mode when the filter changes
|
||
$effect(() => { store.libraryFilter; untrack(() => exitSelectMode()); });
|
||
|
||
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());
|
||
}
|
||
});
|
||
|
||
// ── Derived ───────────────────────────────────────────────────────────────
|
||
|
||
const visibleCategories = $derived((() => {
|
||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||
return store.categories
|
||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
||
.sort((a, b) => {
|
||
if (a.id === defaultId) return -1;
|
||
if (b.id === defaultId) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
})());
|
||
|
||
const categoryMangaMap = $derived((() => {
|
||
const map = new Map<number, Manga[]>();
|
||
for (const cat of store.categories) {
|
||
const nodes = cat.mangas?.nodes ?? [];
|
||
map.set(cat.id, nodes);
|
||
}
|
||
return map;
|
||
})());
|
||
|
||
const filtered = $derived((() => {
|
||
const q = search.trim().toLowerCase();
|
||
const mode = tabSortMode;
|
||
const dir = tabSortDir;
|
||
const status = tabStatus;
|
||
|
||
// 1. Pick the right base list for this tab
|
||
let items: Manga[];
|
||
if (store.libraryFilter === "library") {
|
||
// "Saved" shows all in-library manga so that manga in folders are still visible here.
|
||
// If the user prefers the old behaviour (only uncategorised), they can toggle it off in settings.
|
||
if (store.settings.libraryShowAllInSaved ?? true) {
|
||
items = allManga.filter(m => m.inLibrary);
|
||
} else {
|
||
items = categoryMangaMap.get(0) ?? [];
|
||
}
|
||
} else if (store.libraryFilter === "downloaded") {
|
||
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||
} else {
|
||
items = categoryMangaMap.get(Number(store.libraryFilter)) ?? [];
|
||
}
|
||
|
||
// 2. NSFW filter — always applied before text search or sort
|
||
items = items.filter(m => !shouldHideNsfw(m, store.settings));
|
||
|
||
// 3. Text search
|
||
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
|
||
|
||
// 4. Status filter
|
||
if (status !== "ALL") {
|
||
items = items.filter(m => {
|
||
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
|
||
return s === status;
|
||
});
|
||
}
|
||
|
||
// 4b. Content filters (additive — each active filter further narrows the list)
|
||
const filters = store.settings.libraryTabFilters?.[store.libraryFilter] ?? {};
|
||
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||
if (filters.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
|
||
if (filters.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||
if (filters.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
|
||
|
||
// 5. Sort
|
||
const recentlyReadMap = new Map<number, number>();
|
||
if (mode === "recentlyRead") {
|
||
for (const h of store.history) {
|
||
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
|
||
}
|
||
}
|
||
|
||
const sorted = [...items].sort((a, b) => {
|
||
let cmp = 0;
|
||
switch (mode) {
|
||
case "az":
|
||
cmp = a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
|
||
break;
|
||
case "unreadCount":
|
||
cmp = (a.unreadCount ?? 0) - (b.unreadCount ?? 0);
|
||
break;
|
||
case "totalChapters":
|
||
cmp = (a.chapterCount ?? 0) - (b.chapterCount ?? 0);
|
||
break;
|
||
case "recentlyAdded":
|
||
// id is monotonically increasing on Suwayomi — higher = newer
|
||
cmp = a.id - b.id;
|
||
break;
|
||
case "recentlyRead": {
|
||
const ra = recentlyReadMap.get(a.id) ?? 0;
|
||
const rb = recentlyReadMap.get(b.id) ?? 0;
|
||
cmp = ra - rb;
|
||
break;
|
||
}
|
||
// latestFetched / latestUploaded: no per-manga date available at list level;
|
||
// fall back to id ordering so the option still does something sensible.
|
||
case "latestFetched":
|
||
case "latestUploaded":
|
||
cmp = a.id - b.id;
|
||
break;
|
||
}
|
||
return dir === "asc" ? cmp : -cmp;
|
||
});
|
||
|
||
return sorted;
|
||
})());
|
||
|
||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||
const visibleManga = $derived(filtered.slice(0, renderVisible));
|
||
const hasMore = $derived(filtered.length > renderVisible);
|
||
const remainingCount = $derived(filtered.length - renderVisible);
|
||
|
||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
||
|
||
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;
|
||
})());
|
||
|
||
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
||
|
||
// ── Drag: tab reorder ─────────────────────────────────────────────────────
|
||
|
||
let dragTabId: number | null = $state(null);
|
||
let dragOverTabId: number | null = $state(null);
|
||
let dropTargetTabId: number | null = $state(null);
|
||
|
||
function onTabDragStart(e: DragEvent, cat: Category) {
|
||
activeDragKind = "tab";
|
||
dragTabId = cat.id;
|
||
e.dataTransfer!.effectAllowed = "move";
|
||
e.dataTransfer!.setData(DT_TAB, String(cat.id));
|
||
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
|
||
}
|
||
|
||
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||
if (activeDragKind !== "tab") return;
|
||
if (dragTabId === null || dragTabId === cat.id) return;
|
||
e.preventDefault();
|
||
e.dataTransfer!.dropEffect = "move";
|
||
dragOverTabId = cat.id;
|
||
dragInsertIdx = idx;
|
||
}
|
||
|
||
function onTabDragLeave() {
|
||
dragOverTabId = null;
|
||
}
|
||
|
||
async function onTabDrop(e: DragEvent, dropCat: Category) {
|
||
e.preventDefault();
|
||
dragOverTabId = null;
|
||
dragInsertIdx = -1;
|
||
|
||
if (activeDragKind !== "tab") return;
|
||
if (dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
|
||
|
||
const dragId = dragTabId;
|
||
dragTabId = null;
|
||
activeDragKind = null;
|
||
|
||
const sorted = [...store.categories]
|
||
.filter(c => c.id !== 0)
|
||
.sort((a, b) => a.order - b.order);
|
||
|
||
const fromIdx = sorted.findIndex(c => c.id === dragId);
|
||
const toIdx = sorted.findIndex(c => c.id === dropCat.id);
|
||
if (fromIdx < 0 || toIdx < 0) return;
|
||
|
||
const reordered = [...sorted];
|
||
const [moved] = reordered.splice(fromIdx, 1);
|
||
reordered.splice(toIdx, 0, moved);
|
||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
||
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
|
||
|
||
const newPos = toIdx + 1;
|
||
try {
|
||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(
|
||
UPDATE_CATEGORY_ORDER,
|
||
{ id: dragId, position: newPos },
|
||
);
|
||
} catch (err) {
|
||
console.error("Tab reorder failed:", err);
|
||
await reloadCategories();
|
||
}
|
||
}
|
||
|
||
function onTabDragEnd() {
|
||
activeDragKind = null;
|
||
dragTabId = null;
|
||
dragOverTabId = null;
|
||
dragInsertIdx = -1;
|
||
}
|
||
|
||
// ── Mutations ─────────────────────────────────────────────────────────────
|
||
|
||
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 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 } };
|
||
}));
|
||
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: [] });
|
||
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); }
|
||
}
|
||
|
||
// ── Context menu ──────────────────────────────────────────────────────────
|
||
|
||
function openCtx(e: MouseEvent, m: Manga) {
|
||
if (selectMode) { toggleSelect(m.id); return; }
|
||
e.preventDefault();
|
||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||
}
|
||
|
||
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 });
|
||
}
|
||
}
|
||
|
||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
|
||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
|
||
return {
|
||
label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`,
|
||
icon: Folder,
|
||
onClick: () => toggleMangaCategory(m, cat),
|
||
};
|
||
});
|
||
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: "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 this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
|
||
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
|
||
{ 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); }
|
||
},
|
||
}];
|
||
}
|
||
|
||
// ── Completed auto-assign ─────────────────────────────────────────────────
|
||
|
||
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||
await reloadCategories();
|
||
}
|
||
|
||
let refreshing: boolean = $state(false);
|
||
let refreshProgress = $state({ finished: 0, total: 0 });
|
||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let refreshDone: boolean = $state(false); // brief "done" flash on button
|
||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
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 startLibraryRefresh() {
|
||
if (refreshing) return;
|
||
refreshing = true;
|
||
refreshProgress = { finished: 0, total: 0 };
|
||
|
||
const prevCounts = new Map(allManga.map(m => [m.id, m.unreadCount ?? 0]));
|
||
|
||
let seenWork = false;
|
||
try {
|
||
const updateRes = await gql<{ updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } } }>(UPDATE_LIBRARY, {});
|
||
seenWork = updateRes.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
|
||
} catch {
|
||
refreshing = false;
|
||
return;
|
||
}
|
||
|
||
pollTimer = setTimeout(function poll() {
|
||
gql<{ libraryUpdateStatus: {
|
||
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
|
||
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[];
|
||
} }>(LIBRARY_UPDATE_STATUS, {})
|
||
.then(d => {
|
||
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
|
||
refreshProgress = { finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs };
|
||
|
||
if (jobsInfo.totalJobs > 0) seenWork = true;
|
||
|
||
if (!jobsInfo.isRunning && seenWork) {
|
||
refreshing = false;
|
||
pollTimer = null;
|
||
|
||
const entries: LibraryUpdateEntry[] = mangaUpdates
|
||
.filter(u => u.status === "FINISHED")
|
||
.reduce<LibraryUpdateEntry[]>((acc, u) => {
|
||
const prev = prevCounts.get(u.manga.id) ?? 0;
|
||
const newChapters = Math.max(0, (u.manga.unreadCount ?? 0) - prev);
|
||
if (newChapters > 0) {
|
||
acc.push({
|
||
mangaId: u.manga.id,
|
||
mangaTitle: u.manga.title,
|
||
thumbnailUrl: u.manga.thumbnailUrl,
|
||
newChapters,
|
||
checkedAt: Date.now(),
|
||
});
|
||
}
|
||
return acc;
|
||
}, []);
|
||
|
||
setLibraryUpdates(entries);
|
||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||
loadData();
|
||
|
||
// Done flash on button
|
||
refreshDone = true;
|
||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
|
||
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
|
||
|
||
// Toast summary
|
||
const totalNew = entries.reduce((s, e) => s + e.newChapters, 0);
|
||
showToast(totalNew, entries.length);
|
||
return;
|
||
}
|
||
|
||
pollTimer = setTimeout(poll, 3000);
|
||
})
|
||
.catch(() => {
|
||
refreshing = false;
|
||
pollTimer = null;
|
||
});
|
||
}, 2000);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Escape key exits select mode
|
||
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 target = e.target as HTMLElement;
|
||
if (sortPanelOpen && !target.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
|
||
if (filterPanelOpen && !target.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
|
||
}
|
||
|
||
window.addEventListener("keydown", onKeyDown);
|
||
document.addEventListener("mousedown", onDocMouseDown, true);
|
||
|
||
return () => {
|
||
ro.disconnect();
|
||
unsub();
|
||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
document.removeEventListener("mousedown", onDocMouseDown, true);
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<div
|
||
class="root"
|
||
role="presentation"
|
||
bind:this={scrollEl}
|
||
oncontextmenu={(e) => {
|
||
if ((e.target as HTMLElement).closest("button")) return;
|
||
e.preventDefault();
|
||
emptyCtx = { x: e.clientX, y: e.clientY };
|
||
}}
|
||
>
|
||
{#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}
|
||
<div class="header">
|
||
<div class="header-left">
|
||
<span class="heading">Library</span>
|
||
<div class="tabs">
|
||
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
||
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
|
||
{#if f === "library"}<Books size={11} weight="bold" />
|
||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
||
{label}
|
||
<span class="tab-count">{counts[f] ?? 0}</span>
|
||
</button>
|
||
{/each}
|
||
{#each visibleCategories as cat, idx}
|
||
{@const isDefault = (store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||
{/if}
|
||
<button
|
||
class="tab"
|
||
class:active={store.libraryFilter === String(cat.id)}
|
||
class:tab-dragging={dragTabId === cat.id}
|
||
class:tab-drop-target={dropTargetTabId === cat.id}
|
||
class:tab-default={isDefault}
|
||
draggable="true"
|
||
onclick={() => store.libraryFilter = String(cat.id)}
|
||
ondragstart={(e) => onTabDragStart(e, cat)}
|
||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
||
ondragleave={onTabDragLeave}
|
||
ondrop={(e) => onTabDrop(e, cat)}
|
||
ondragend={onTabDragEnd}
|
||
>
|
||
{#if isDefault}
|
||
<Star size={11} weight="fill" style="color:var(--accent-fg)" />
|
||
{:else}
|
||
<Folder size={11} weight="bold" />
|
||
{/if}
|
||
{cat.name}
|
||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
||
</button>
|
||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="search-wrap">
|
||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
||
<input class="search" placeholder="Search" bind:value={search} />
|
||
</div>
|
||
|
||
<button
|
||
class="icon-btn refresh-btn"
|
||
class:icon-btn-active={refreshing}
|
||
class:refresh-btn-done={refreshDone}
|
||
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"}
|
||
disabled={refreshing}
|
||
onclick={startLibraryRefresh}
|
||
>
|
||
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} />
|
||
{#if refreshing && refreshProgress.total > 0}
|
||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||
{/if}
|
||
</button>
|
||
<button
|
||
class="icon-btn"
|
||
title="Open downloads folder"
|
||
onclick={openDownloadsFolder}
|
||
>
|
||
<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={openSortPanel}
|
||
>
|
||
<SortAscending size={15} weight="bold" />
|
||
</button>
|
||
{#if sortPanelOpen}
|
||
<div class="dropdown-panel sort-panel" 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={() => { setTabSort(m); sortPanelOpen = false; }}
|
||
>
|
||
{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={toggleTabSortDir}
|
||
>
|
||
{tabSortDir === "asc" ? "Ascending" : "Descending"}
|
||
{#if tabSortDir === "asc"}
|
||
<CaretUp size={11} weight="bold" />
|
||
{:else}
|
||
<CaretDown size={11} weight="bold" />
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Filter panel -->
|
||
<div class="filter-panel-wrap">
|
||
<button
|
||
class="icon-btn"
|
||
class:icon-btn-active={hasActiveFilters}
|
||
title="Filter"
|
||
onclick={openFilterPanel}
|
||
>
|
||
<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={clearAllFilters}>Clear all</button>
|
||
{/if}
|
||
</div>
|
||
<div class="panel-divider"></div>
|
||
<p class="panel-label">Content</p>
|
||
{#each ([["unread","Unread"],["started","Started"],["downloaded","Downloaded"],["bookmarked","Bookmarked"]] as const) as [f, label]}
|
||
<button
|
||
class="panel-item panel-item-check"
|
||
class:panel-item-active={tabFilters[f]}
|
||
role="menuitem"
|
||
onclick={() => toggleTabFilter(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={() => setTabStatus(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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Refresh progress bar ──────────────────────────────────────────────── -->
|
||
{#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}
|
||
|
||
<!-- ── Selection toolbar ───────────────────────────────────────────────── -->
|
||
{#if selectMode}
|
||
<div class="select-bar">
|
||
<div class="select-bar-left">
|
||
<button class="sel-btn sel-cancel" onclick={exitSelectMode} title="Cancel (Esc)">
|
||
<X size={13} weight="bold" />
|
||
</button>
|
||
<span class="sel-count">{selectedIds.size} selected</span>
|
||
<button class="sel-btn sel-all" onclick={selectAll} title="Select all (⌘A)">
|
||
Select all
|
||
</button>
|
||
</div>
|
||
<div class="select-bar-right">
|
||
{#if visibleCategories.length}
|
||
<div class="bulk-move-wrap">
|
||
<button
|
||
class="sel-btn sel-move"
|
||
disabled={selectedIds.size === 0 || bulkWorking}
|
||
onclick={() => bulkMoveOpen = !bulkMoveOpen}
|
||
>
|
||
<Folder size={13} weight="bold" />
|
||
Move to folder
|
||
</button>
|
||
{#if bulkMoveOpen}
|
||
<div class="bulk-folder-list">
|
||
{#each visibleCategories as cat}
|
||
<button class="bulk-folder-item" onclick={() => bulkMoveToCategory(cat)}>
|
||
<Folder size={11} weight="bold" />
|
||
{cat.name}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<button
|
||
class="sel-btn sel-remove"
|
||
disabled={selectedIds.size === 0 || bulkWorking}
|
||
onclick={bulkRemoveFromLibrary}
|
||
>
|
||
<Trash size={13} weight="bold" />
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="content">
|
||
{#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">
|
||
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
||
: store.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)}
|
||
<button
|
||
class="card"
|
||
class:card-selected={isSelected}
|
||
class:select-mode={selectMode}
|
||
onclick={(e) => onCardClick(e, m)}
|
||
oncontextmenu={(e) => openCtx(e, m)}
|
||
onpointerdown={(e) => onCardPointerDown(e, m)}
|
||
onpointerup={onCardPointerUp}
|
||
onpointerleave={onCardPointerLeave}
|
||
>
|
||
<div class="cover-wrap">
|
||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||
{#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={loadMore}>
|
||
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
|
||
<span class="load-more-count">({remainingCount} remaining)</span>
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div><!-- .content -->
|
||
{/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}
|
||
|
||
<style>
|
||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
|
||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||
.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; }
|
||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
||
.header { position: relative; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; }
|
||
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||
.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; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||
.tab { 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); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: grab; }
|
||
.tab:hover { color: var(--text-muted); }
|
||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||
.tab-default { color: var(--text-muted); }
|
||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
|
||
.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); }
|
||
|
||
/* ── Header right cluster ───────────────────────────────────────────────── */
|
||
.header-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||
|
||
/* ── Icon buttons (sort / filter triggers) ──────────────────────────────── */
|
||
.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); }
|
||
|
||
/* ── Dropdown panels (shared) ───────────────────────────────────────────── */
|
||
.sort-panel-wrap,
|
||
.filter-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); animation: fadeIn 0.1s ease both; }
|
||
.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 row — shared by sort + filter panels */
|
||
.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); }
|
||
/* Check items */
|
||
.panel-item-check { justify-content: flex-start; gap: var(--sp-2); }
|
||
.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); }
|
||
.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; }
|
||
|
||
/* ── Selection toolbar ──────────────────────────────────────────────────── */
|
||
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); }
|
||
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; }
|
||
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||
.sel-btn { 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 var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
|
||
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.sel-cancel { border-color: transparent; background: transparent; }
|
||
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
|
||
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
|
||
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
|
||
.sel-all { border-color: transparent; background: transparent; }
|
||
|
||
/* Bulk folder dropdown */
|
||
.bulk-move-wrap { position: relative; }
|
||
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; 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 & cards ───────────────────────────────────────────────────────── */
|
||
.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:hover .cover { filter: brightness(1.07); }
|
||
.card: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); }
|
||
.cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; }
|
||
.badge-dl { position: absolute; bottom: var(--sp-1); right: var(--sp-1); min-width: 18px; height: 18px; padding: 0 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--accent-dim); color: var(--accent-fg); border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
|
||
.badge-unread { position: absolute; top: var(--sp-1); left: var(--sp-1); min-width: 18px; height: 18px; padding: 0 4px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; background: var(--bg-void); color: var(--text-primary); border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
||
|
||
/* Select overlay (checkbox) */
|
||
.select-overlay { position: absolute; inset: 0; 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; 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); }
|
||
.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 progress bar ───────────────────────────────────────────────── */
|
||
.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; }
|
||
|
||
/* Done flash on button */
|
||
.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; }
|
||
|
||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||
</style>
|