mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Reader Store Refactor (Issue #11) & Feat: Drag n Drop (WIP)
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
import { onDestroy } from "svelte";
|
||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } from "../../lib/types";
|
||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||
@@ -48,6 +48,8 @@
|
||||
|
||||
let activeCtrl: AbortController | null = null;
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||
@@ -253,6 +255,12 @@
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
@@ -266,20 +274,26 @@
|
||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
||||
}).catch(console.error),
|
||||
},
|
||||
...(store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...store.settings.folders.map(f => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
...categories.map(cat => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||
icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add", icon: FolderSimplePlus,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
const n = prompt("Folder name:");
|
||||
if (n?.trim()) { const id = addFolder(n.trim()); assignMangaToFolder(id, m.id); }
|
||||
if (!n?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
||||
if (res) {
|
||||
const cat = res.createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Source, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -39,6 +39,8 @@
|
||||
let loadingMore = $state(false);
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = $state([]);
|
||||
@@ -143,19 +145,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
...(store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...store.settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(
|
||||
CREATE_CATEGORY,
|
||||
{ name: name.trim() }
|
||||
).catch(console.error);
|
||||
if (res) {
|
||||
const cat = (res as any).createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
}},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -190,7 +215,7 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visibleItems as m (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
||||
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
@@ -30,20 +30,31 @@
|
||||
|
||||
function focusEl(node: HTMLElement) { node.focus(); }
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]);
|
||||
let loadingLibrary: boolean = $state(true);
|
||||
let completedCategory: Category | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
function loadLibrary() {
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
const libraryP = cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
);
|
||||
const categoriesP = gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => d.categories.nodes.find(c => c.name === "Completed") ?? null)
|
||||
.catch(() => null);
|
||||
|
||||
Promise.all([libraryP, categoriesP])
|
||||
.then(([m, completed]) => {
|
||||
libraryManga = m;
|
||||
completedCategory = completed;
|
||||
fetchExtraCompleted(m, completed);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => loadingLibrary = false);
|
||||
}
|
||||
|
||||
// Re-fetch library and reset hero chapters whenever the reader closes,
|
||||
@@ -59,8 +70,8 @@
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
async function fetchExtraCompleted(library: Manga[]) {
|
||||
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
||||
async function fetchExtraCompleted(library: Manga[], completed: Category | null) {
|
||||
const completedIds = completed?.mangas?.nodes.map(m => m.id) ?? [];
|
||||
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
|
||||
if (!missingIds.length) return;
|
||||
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
|
||||
@@ -206,7 +217,7 @@
|
||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||
|
||||
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
||||
const completedIds = $derived(completedCategory?.mangas?.nodes.map(m => m.id) ?? []);
|
||||
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
||||
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
||||
const recentHistory = $derived(store.history.slice(0, 6));
|
||||
|
||||
@@ -1,40 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash, Star } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||
import { GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
|
||||
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import { store, setLibraryFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted, updateSettings } from "../../store/state.svelte";
|
||||
import type { Manga, Category, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const COMPLETED_NAME = "Completed";
|
||||
|
||||
let allManga: Manga[] = $state([]);
|
||||
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
||||
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);
|
||||
// Drag type discriminators. We keep the custom MIME types for standards
|
||||
// browsers, but also rely on `activeDragKind` as the authoritative signal
|
||||
// because Tauri's WebKit sometimes strips custom MIME types from
|
||||
// dataTransfer.types during dragover/drop events.
|
||||
const DT_TAB = "application/x-moku-tab";
|
||||
const DT_MANGA = "application/x-moku-manga";
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
// Set at dragstart, cleared at dragend. This is the authoritative
|
||||
// discriminator — not affected by MIME stripping in any webview.
|
||||
let activeDragKind: "tab" | "manga" | null = $state(null);
|
||||
// Track insert position for the green drop-indicator bar (tab reorder).
|
||||
// -1 = no indicator. Value = index in visibleCategories before which the bar shows.
|
||||
let dragInsertIdx: number = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
const wasOpen = prevChapterId !== null;
|
||||
prevChapterId = store.activeChapter?.id ?? null;
|
||||
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
||||
});
|
||||
let allManga: Manga[] = $state([]);
|
||||
let categories: Category[] = $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);
|
||||
|
||||
function fetchLibrary() {
|
||||
// ── 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
// Guard flag: true while Library is publishing to CATEGORIES itself, so the
|
||||
// subscriber below doesn't re-fire reloadCategories in an infinite loop.
|
||||
let suppressCatSubscriber = false;
|
||||
|
||||
/** Fetch and apply categories directly — never cached, always fresh. */
|
||||
async function reloadCategories() {
|
||||
try {
|
||||
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||
const cats = await ensureCompletedCategory(d.categories.nodes);
|
||||
categories = cats;
|
||||
// Publish full category data (including mangas.nodes) so Settings can
|
||||
// pick up order changes. Guard prevents re-entrancy.
|
||||
suppressCatSubscriber = true;
|
||||
cache.set(CACHE_KEYS.CATEGORIES, cats);
|
||||
suppressCatSubscriber = false;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function loadLibrary() {
|
||||
return cache.get(
|
||||
CACHE_KEYS.LIBRARY,
|
||||
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes),
|
||||
@@ -43,11 +78,16 @@
|
||||
);
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
fetchLibrary()
|
||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => loading = false);
|
||||
async function loadData() {
|
||||
try {
|
||||
const [nodes] = await Promise.all([loadLibrary(), reloadCategories()]);
|
||||
allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks);
|
||||
error = null;
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -57,42 +97,51 @@
|
||||
untrack(() => loadData());
|
||||
});
|
||||
|
||||
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
|
||||
$effect(() => {
|
||||
const allIds = new Set(allManga.map(m => m.id));
|
||||
const missingIds = store.settings.folders
|
||||
.flatMap(f => f.mangaIds)
|
||||
.filter(id => !allIds.has(id));
|
||||
if (!missingIds.length) return;
|
||||
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
|
||||
if (!toFetch.length) return;
|
||||
untrack(() => {
|
||||
Promise.all(
|
||||
toFetch.map(id =>
|
||||
cache.get(CACHE_KEYS.MANGA(id), () =>
|
||||
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
|
||||
).catch(() => null)
|
||||
)
|
||||
).then(results => {
|
||||
const valid = results.filter(Boolean) as Manga[];
|
||||
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
||||
|
||||
// Reset filter to library if the active category tab no longer exists.
|
||||
// Uses untrack on the write side to avoid a read→write→re-run loop.
|
||||
$effect(() => {
|
||||
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
||||
const f = store.libraryFilter;
|
||||
if (f === "library" || f === "downloaded") return;
|
||||
const id = Number(f);
|
||||
if (!categories.some(c => c.id === id)) {
|
||||
untrack(() => { store.libraryFilter = "library"; });
|
||||
}
|
||||
});
|
||||
|
||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
||||
// Re-fetch library when reader closes
|
||||
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());
|
||||
}
|
||||
});
|
||||
|
||||
// All manga available for folder filtering — library + any extras fetched above
|
||||
const folderPool = $derived((() => {
|
||||
const seen = new Set(allManga.map(m => m.id));
|
||||
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
|
||||
const visibleCategories = $derived((() => {
|
||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||
return categories
|
||||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
||||
.sort((a, b) => {
|
||||
// Starred folder always first
|
||||
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 categories) {
|
||||
const nodes = cat.mangas?.nodes ?? [];
|
||||
map.set(cat.id, nodes);
|
||||
}
|
||||
return map;
|
||||
})());
|
||||
|
||||
const filtered = $derived((() => {
|
||||
@@ -104,12 +153,9 @@
|
||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||
}
|
||||
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||
if (folder) {
|
||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||
}
|
||||
return [];
|
||||
const id = Number(store.libraryFilter);
|
||||
const items = categoryMangaMap.get(id) ?? [];
|
||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||
})());
|
||||
|
||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||
@@ -119,18 +165,224 @@
|
||||
|
||||
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
||||
|
||||
const counts = $derived({
|
||||
library: allManga.length,
|
||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
||||
});
|
||||
const counts = $derived((() => {
|
||||
const m: Record<string, number> = {
|
||||
library: allManga.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 ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Optimistically reorders categories immediately on drop, then syncs to server.
|
||||
// `activeDragKind` is the reliable discriminator — not dataTransfer.types.
|
||||
|
||||
let dragTabId: number | null = $state(null);
|
||||
let dragOverTabId: number | null = $state(null);
|
||||
|
||||
function onTabDragStart(e: DragEvent, cat: Category) {
|
||||
// If a manga card drag is already in flight (e.g. dragged over a tab and
|
||||
// WebKit fires dragstart on the underlying draggable tab element), ignore it
|
||||
// so we don't clobber activeDragKind and break the manga drop.
|
||||
if (activeDragKind === "manga") { e.preventDefault(); return; }
|
||||
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; // show green bar before this tab
|
||||
}
|
||||
|
||||
function onTabDragLeave() {
|
||||
dragOverTabId = null;
|
||||
// Don't clear dragInsertIdx on leave — wait for the next dragover or drop
|
||||
// so the bar doesn't flicker as the cursor crosses element boundaries.
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Work on `categories` sorted by current .order (server-authoritative)
|
||||
const sorted = [...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;
|
||||
|
||||
// Optimistic reorder: splice, reassign .order, merge back into categories
|
||||
const reordered = [...sorted];
|
||||
const [moved] = reordered.splice(fromIdx, 1);
|
||||
reordered.splice(toIdx, 0, moved);
|
||||
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
||||
categories = categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c);
|
||||
|
||||
// Server sync — position is 1-based index of the drop target in sorted list
|
||||
const newPos = toIdx + 1;
|
||||
try {
|
||||
await gql<{ updateCategoryOrder: { categories: Category[] } }>(
|
||||
UPDATE_CATEGORY_ORDER,
|
||||
{ id: dragId, position: newPos },
|
||||
);
|
||||
// Publish reordered categories so Settings panel reflects new order.
|
||||
suppressCatSubscriber = true;
|
||||
cache.set(CACHE_KEYS.CATEGORIES, [...categories]);
|
||||
suppressCatSubscriber = false;
|
||||
} catch (err) {
|
||||
console.error("Tab reorder failed:", err);
|
||||
await reloadCategories(); // revert to server truth on error
|
||||
}
|
||||
}
|
||||
|
||||
function onTabDragEnd() {
|
||||
activeDragKind = null;
|
||||
dragTabId = null;
|
||||
dragOverTabId = null;
|
||||
dragInsertIdx = -1;
|
||||
}
|
||||
|
||||
// ── Drag: manga card → folder tab ─────────────────────────────────────────
|
||||
//
|
||||
// `activeDragKind` is set to "manga" at dragstart so every handler knows
|
||||
// what is being dragged without relying on dataTransfer.types.
|
||||
|
||||
let dragMangaId: number | null = $state(null);
|
||||
let dropTargetTabId: number | null = $state(null);
|
||||
|
||||
// Off-screen container for the custom drag ghost — created once per drag.
|
||||
let dragGhostEl: HTMLDivElement | null = null;
|
||||
|
||||
function onCardDragStart(e: DragEvent, m: Manga) {
|
||||
activeDragKind = "manga";
|
||||
dragMangaId = m.id;
|
||||
e.dataTransfer!.effectAllowed = "copyMove";
|
||||
e.dataTransfer!.setData(DT_MANGA, String(m.id));
|
||||
e.dataTransfer!.setData("text/plain", `manga:${m.id}`);
|
||||
|
||||
// ── Custom drag ghost ──────────────────────────────────────────────────
|
||||
if (dragGhostEl) dragGhostEl.remove();
|
||||
const ghost = document.createElement("div");
|
||||
ghost.style.cssText = [
|
||||
"position:fixed", "top:-9999px", "left:-9999px",
|
||||
"width:72px",
|
||||
"border-radius:10px",
|
||||
"overflow:hidden",
|
||||
"box-shadow:0 8px 24px rgba(0,0,0,0.7)",
|
||||
"border:1.5px solid var(--accent-dim)",
|
||||
"background:var(--bg-raised)",
|
||||
"pointer-events:none",
|
||||
"z-index:99999",
|
||||
].join(";");
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = thumbUrl(m.thumbnailUrl);
|
||||
img.style.cssText = "width:72px;aspect-ratio:2/3;object-fit:cover;display:block;";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.textContent = m.title;
|
||||
label.style.cssText = [
|
||||
"padding:4px 6px",
|
||||
"font-size:9px",
|
||||
"line-height:1.3",
|
||||
"color:var(--text-secondary)",
|
||||
"background:var(--bg-raised)",
|
||||
"white-space:nowrap",
|
||||
"overflow:hidden",
|
||||
"text-overflow:ellipsis",
|
||||
"font-family:var(--font-ui)",
|
||||
"letter-spacing:var(--tracking-wide)",
|
||||
].join(";");
|
||||
|
||||
ghost.appendChild(img);
|
||||
ghost.appendChild(label);
|
||||
document.body.appendChild(ghost);
|
||||
dragGhostEl = ghost;
|
||||
e.dataTransfer!.setDragImage(ghost, 36, 40);
|
||||
}
|
||||
|
||||
function onCardDragEnd() {
|
||||
activeDragKind = null;
|
||||
dragMangaId = null;
|
||||
dropTargetTabId = null;
|
||||
if (dragGhostEl) { dragGhostEl.remove(); dragGhostEl = null; }
|
||||
}
|
||||
|
||||
function onFolderTabDragOver(e: DragEvent, cat: Category) {
|
||||
if (activeDragKind !== "manga") return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = "copy";
|
||||
dropTargetTabId = cat.id;
|
||||
}
|
||||
|
||||
function onFolderTabDragLeave() {
|
||||
if (activeDragKind !== "manga") return;
|
||||
dropTargetTabId = null;
|
||||
}
|
||||
|
||||
async function onFolderTabDrop(e: DragEvent, cat: Category) {
|
||||
if (activeDragKind !== "manga") return;
|
||||
e.preventDefault();
|
||||
dropTargetTabId = null;
|
||||
|
||||
const mid = dragMangaId;
|
||||
activeDragKind = null;
|
||||
dragMangaId = null;
|
||||
if (mid === null) return;
|
||||
|
||||
const manga = allManga.find(m => m.id === mid);
|
||||
if (!manga) return;
|
||||
await toggleMangaCategory(manga, cat);
|
||||
}
|
||||
|
||||
// ── Unified dispatchers for folder tabs ───────────────────────────────────
|
||||
// `activeDragKind` ensures each handler ignores drags it doesn't own.
|
||||
|
||||
function tabDragOver(e: DragEvent, cat: Category, idx: number) {
|
||||
if (activeDragKind === "tab") onTabDragOver(e, cat, idx);
|
||||
else if (activeDragKind === "manga") onFolderTabDragOver(e, cat);
|
||||
}
|
||||
|
||||
function tabDragLeave() {
|
||||
if (activeDragKind === "tab") onTabDragLeave();
|
||||
else if (activeDragKind === "manga") onFolderTabDragLeave();
|
||||
}
|
||||
|
||||
function tabDrop(e: DragEvent, cat: Category) {
|
||||
if (activeDragKind === "tab") onTabDrop(e, cat);
|
||||
else if (activeDragKind === "manga") onFolderTabDrop(e, cat);
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
@@ -144,32 +396,108 @@
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
|
||||
// Optimistic update: patch categories in-place so counts and content
|
||||
// update instantly without waiting for the server round-trip.
|
||||
categories = 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] : [],
|
||||
});
|
||||
// Reload to get the authoritative state from the server
|
||||
await reloadCategories();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Revert on failure
|
||||
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: [] });
|
||||
await reloadCategories();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
const mangaFolders = getMangaFolders(m.id);
|
||||
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
||||
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
|
||||
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: "Delete all downloads", icon: Trash, danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => deleteAllDownloads(m) },
|
||||
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
||||
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
{ label: "New folder", icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtx(): MenuEntry[] {
|
||||
return [{ label: "New folder", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); } }];
|
||||
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, categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
await reloadCategories();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
||||
ro.observe(scrollEl);
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
||||
return () => { ro.disconnect(); unsub(); };
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
|
||||
|
||||
// When Settings reorders folders, re-fetch so we get the correct order
|
||||
// AND keep mangas.nodes intact. Guard prevents re-entrancy when Library
|
||||
// itself publishes to this key.
|
||||
const unsubCats = cache.subscribe(CACHE_KEYS.CATEGORIES, () => {
|
||||
if (suppressCatSubscriber) return;
|
||||
reloadCategories();
|
||||
});
|
||||
|
||||
// One-time: if a default folder is pinned and the user hasn't navigated
|
||||
// to a specific tab yet, jump straight to it.
|
||||
const defaultId = store.settings.defaultLibraryCategoryId;
|
||||
if (defaultId && store.libraryFilter === "library") {
|
||||
store.libraryFilter = String(defaultId);
|
||||
}
|
||||
|
||||
return () => { ro.disconnect(); unsub(); unsubCats(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -223,12 +551,36 @@
|
||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
||||
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
||||
<Folder size={11} weight="bold" />
|
||||
{folder.name}
|
||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
||||
{#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) => tabDragOver(e, cat, idx)}
|
||||
ondragleave={tabDragLeave}
|
||||
ondrop={(e) => tabDrop(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>
|
||||
@@ -257,7 +609,15 @@
|
||||
{:else}
|
||||
<div class="grid" style="--cols:{cols}">
|
||||
{#each visibleManga as m (m.id)}
|
||||
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<button
|
||||
class="card"
|
||||
class:card-dragging={dragMangaId === m.id}
|
||||
draggable="true"
|
||||
onclick={() => store.activeManga = m}
|
||||
oncontextmenu={(e) => openCtx(e, m)}
|
||||
ondragstart={(e) => onCardDragStart(e, m)}
|
||||
ondragend={onCardDragEnd}
|
||||
>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
||||
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||||
@@ -297,9 +657,13 @@
|
||||
.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); }
|
||||
.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; }
|
||||
@@ -308,6 +672,7 @@
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.grid { position: relative; z-index: 1; 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-dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.card:hover .cover { filter: brightness(1.07); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
.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); transform: translateZ(0); }
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA } from "../../lib/queries";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import MigrateModal from "./MigrateModal.svelte";
|
||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||
@@ -36,6 +37,9 @@
|
||||
let folderPickerOpen: boolean = $state(false);
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
@@ -102,9 +106,34 @@
|
||||
})());
|
||||
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => {
|
||||
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { catsLoading = false; });
|
||||
}
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
// Sync local mangaCategories state after the mutation
|
||||
if (chaps.length) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadManga(id: number) {
|
||||
mangaAbort?.abort();
|
||||
const ctrl = new AbortController();
|
||||
@@ -164,7 +193,7 @@
|
||||
|
||||
$effect(() => {
|
||||
const m = store.activeManga;
|
||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
||||
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
||||
});
|
||||
|
||||
let prevChapterId: number | null = null;
|
||||
@@ -300,14 +329,34 @@
|
||||
enqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
async function createCategory() {
|
||||
const name = folderNewName.trim();
|
||||
if (!name || !store.activeManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, store.activeManga.id);
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
allCategories = [...allCategories, cat];
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
folderNewName = ""; folderCreating = false;
|
||||
}
|
||||
|
||||
async function toggleCategory(cat: Category) {
|
||||
if (!store.activeManga) return;
|
||||
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||
try {
|
||||
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||
mangaId: store.activeManga.id,
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
});
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
|
||||
// ── Series link ────────────────────────────────────────────────────────────
|
||||
@@ -505,29 +554,30 @@
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<!-- Folder picker -->
|
||||
<!-- Category picker -->
|
||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||
</button>
|
||||
{#if folderPickerOpen}
|
||||
<div class="fp-menu">
|
||||
{#if store.settings.folders.length === 0 && !folderCreating}
|
||||
{#if catsLoading}
|
||||
<p class="fp-empty">Loading…</p>
|
||||
{:else if allCategories.length === 0 && !folderCreating}
|
||||
<p class="fp-empty">No folders yet</p>
|
||||
{/if}
|
||||
{#each store.settings.folders as folder}
|
||||
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
||||
<button class="fp-item" class:fp-item-active={isIn}
|
||||
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
||||
{#each allCategories as cat}
|
||||
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||
<button class="fp-item" class:fp-item-active={isIn} onclick={() => toggleCategory(cat)}>
|
||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="fp-div"></div>
|
||||
{#if folderCreating}
|
||||
<div class="fp-create">
|
||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
||||
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount />
|
||||
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
|
||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
|
||||
@@ -199,8 +199,7 @@
|
||||
error = null;
|
||||
pageGroups = [];
|
||||
pageReady = false;
|
||||
stripChapters = [];
|
||||
visibleChapterId = null;
|
||||
stripChapters = [];
|
||||
store.pageUrls = [];
|
||||
store.pageNumber = 1;
|
||||
try {
|
||||
@@ -457,7 +456,7 @@
|
||||
}
|
||||
|
||||
function maybeMarkCurrentRead() {
|
||||
const ch = store.activeChapter;
|
||||
const ch = displayChapter ?? store.activeChapter;
|
||||
if (ch && markOnNext) markChapterRead(ch.id);
|
||||
}
|
||||
|
||||
@@ -711,11 +710,11 @@
|
||||
</div>
|
||||
|
||||
<div class="bottombar" class:hidden={!uiVisible}>
|
||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<button class="nav-btn" onclick={goBack} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||||
{#if rtl}<ArrowRight size={13} weight="light" />{:else}<ArrowLeft size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
<button class="nav-btn" onclick={goForward} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||||
{#if rtl}<ArrowLeft size={13} weight="light" />{:else}<ArrowRight size={13} weight="light" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock } from "phosphor-svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks, Lock, Eye, EyeSlash, Star } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_CATEGORIES, CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "../../lib/queries";
|
||||
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
|
||||
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme } from "../../store/state.svelte";
|
||||
import { cache } from "../../lib/cache";
|
||||
import type { Category } from "../../lib/types";
|
||||
import { store, updateSettings, resetKeybinds, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme, toggleHiddenCategory } from "../../store/state.svelte";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||
import type { Keybinds } from "../../lib/keybinds";
|
||||
@@ -168,23 +170,102 @@
|
||||
}
|
||||
|
||||
|
||||
let newFolderName = $state("");
|
||||
let editingId: string | null = $state(null);
|
||||
let editingName = $state("");
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let catsError: string|null = $state(null);
|
||||
let newFolderName = $state("");
|
||||
let editingId: number | null = $state(null);
|
||||
let editingName = $state("");
|
||||
|
||||
function createFolder() {
|
||||
async function loadCategories() {
|
||||
catsLoading = true; catsError = null;
|
||||
try {
|
||||
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||
categories = res.categories.nodes.filter(c => c.id !== 0);
|
||||
// Publish so Library picks up order changes via its subscription
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
} catch (e: any) {
|
||||
catsError = e?.message ?? "Failed to load folders";
|
||||
} finally { catsLoading = false; }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
addFolder(name); newFolderName = "";
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
categories = [...categories, res.createCategory.category];
|
||||
newFolderName = "";
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
|
||||
}
|
||||
|
||||
function startEdit(id: string, name: string) { editingId = id; editingName = name; }
|
||||
function startEdit(id: number, name: string) { editingId = id; editingName = name; }
|
||||
|
||||
function commitEdit() {
|
||||
if (editingId && editingName.trim()) renameFolder(editingId, editingName.trim());
|
||||
async function commitEdit() {
|
||||
if (editingId !== null && editingName.trim()) {
|
||||
try {
|
||||
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
|
||||
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c);
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
|
||||
}
|
||||
editingId = null; editingName = "";
|
||||
}
|
||||
|
||||
async function deleteFolder(id: number) {
|
||||
try {
|
||||
await gql(DELETE_CATEGORY, { id });
|
||||
categories = categories.filter(c => c.id !== id);
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
|
||||
}
|
||||
|
||||
async function moveCategory(id: number, direction: -1 | 1) {
|
||||
const idx = categories.findIndex(c => c.id === id);
|
||||
if (idx < 0) return;
|
||||
const newPos = idx + 1 + direction;
|
||||
if (newPos < 1 || newPos > categories.length) return;
|
||||
// Optimistic reorder so the UI moves instantly
|
||||
const reordered = [...categories];
|
||||
const [moved] = reordered.splice(idx, 1);
|
||||
reordered.splice(idx + direction, 0, moved);
|
||||
categories = reordered.map((c, i) => ({ ...c, order: i + 1 }));
|
||||
// Publish optimistic order immediately — Library re-fetches on this signal
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
try {
|
||||
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
|
||||
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||
categories = updated.sort((a, b) => a.order - b.order);
|
||||
// Publish server-authoritative order
|
||||
cache.set(CACHE_KEYS.CATEGORIES, categories);
|
||||
} catch (e: any) {
|
||||
catsError = e?.message ?? "Failed to reorder";
|
||||
await loadCategories(); // revert — loadCategories also publishes
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the shared categories cache so Library-initiated drag reorders
|
||||
// are reflected in the Settings folder list without needing a manual reload.
|
||||
// Library publishes its full category data (with mangas.nodes), so we only
|
||||
// pull the ordering signal — no content is lost here since Settings never
|
||||
// renders mangas.nodes, just names and counts.
|
||||
$effect(() => {
|
||||
const unsub = cache.subscribe(CACHE_KEYS.CATEGORIES, async () => {
|
||||
// Don't clobber an in-progress server fetch
|
||||
if (catsLoading) return;
|
||||
// Re-read directly from the server so we get mangas.nodes counts too.
|
||||
// This is a lightweight call and only fires when Library reorders.
|
||||
try {
|
||||
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
|
||||
categories = res.categories.nodes.filter(c => c.id !== 0);
|
||||
} catch {}
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
$effect(() => { if (tab === "folders" && !categories.length && !catsLoading) loadCategories(); });
|
||||
|
||||
|
||||
let selectOpen: string | null = $state(null);
|
||||
|
||||
@@ -1132,7 +1213,10 @@
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Manage Folders</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Folders are stored as Suwayomi categories. Changes sync across all clients.</p>
|
||||
{#if catsError}
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2);color:var(--color-error);display:block">{catsError}</p>
|
||||
{/if}
|
||||
<div class="folder-create-row">
|
||||
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
|
||||
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
|
||||
@@ -1140,26 +1224,39 @@
|
||||
<Plus size={13} weight="bold" /> Create
|
||||
</button>
|
||||
</div>
|
||||
{#if store.settings.folders.length === 0}
|
||||
{#if catsLoading}
|
||||
<p class="storage-loading">Loading folders…</p>
|
||||
{:else if categories.length === 0}
|
||||
<p class="storage-loading">No folders yet. Create one above.</p>
|
||||
{:else}
|
||||
<div class="folder-list">
|
||||
{#each store.settings.folders as folder}
|
||||
{#each categories as cat, i}
|
||||
<div class="folder-row">
|
||||
{#if editingId === folder.id}
|
||||
{#if editingId === cat.id}
|
||||
<input class="text-input" bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
||||
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
|
||||
<button class="kb-reset" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="folder-row-name">{folder.name}</span>
|
||||
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
|
||||
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}>
|
||||
{folder.showTab ? "Tab on" : "Tab off"}
|
||||
</button>
|
||||
<button class="kb-reset" onclick={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
||||
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||
<span class="folder-row-name">{cat.name}</span>
|
||||
<span class="folder-row-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
<button
|
||||
class="kb-reset"
|
||||
class:folder-default-active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
|
||||
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder — opens first when you visit Library"}
|
||||
><Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} /></button>
|
||||
<button
|
||||
class="kb-reset"
|
||||
class:folder-hidden={(store.settings.hiddenCategoryIds ?? []).includes(cat.id)}
|
||||
onclick={() => toggleHiddenCategory(cat.id)}
|
||||
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}
|
||||
>{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}</button>
|
||||
<button class="kb-reset" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up">↑</button>
|
||||
<button class="kb-reset" onclick={() => moveCategory(cat.id, 1)} disabled={i === categories.length - 1} title="Move down">↓</button>
|
||||
<button class="kb-reset" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
||||
<button class="kb-reset folder-delete" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1819,6 +1916,8 @@
|
||||
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
||||
.folder-hidden { opacity: 0.35; }
|
||||
.folder-default-active { color: var(--accent-fg) !important; }
|
||||
|
||||
/* About */
|
||||
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import { store, openReader, addToast, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter, Category } from "../../lib/types";
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
@@ -17,6 +17,9 @@
|
||||
let folderOpen = $state(false);
|
||||
let newFolderName = $state("");
|
||||
let creatingFolder = $state(false);
|
||||
let allCategories: Category[] = $state([]);
|
||||
let mangaCategories: Category[] = $state([]);
|
||||
let catsLoading: boolean = $state(false);
|
||||
let queueingAll = $state(false);
|
||||
let fetchError: string|null = $state(null);
|
||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||
@@ -79,7 +82,7 @@
|
||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
||||
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
||||
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
|
||||
|
||||
const continueChapter = $derived.by(() => {
|
||||
if (!chapters.length) return null;
|
||||
@@ -90,7 +93,7 @@
|
||||
return { ch: chapters[0], label: "Read again" };
|
||||
});
|
||||
|
||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
||||
$effect(() => { if (store.previewManga) { load(store.previewManga.id); loadCategories(store.previewManga.id); } });
|
||||
|
||||
async function load(id: number) {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
@@ -171,11 +174,55 @@
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => {
|
||||
allCategories = d.categories.nodes.filter(c => c.id !== 0);
|
||||
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { catsLoading = false; });
|
||||
}
|
||||
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
|
||||
// Sync local mangaCategories state after the mutation
|
||||
if (chaps.length) {
|
||||
const allRead = chaps.every(c => c.isRead);
|
||||
const completed = allCategories.find(c => c.name === "Completed");
|
||||
if (completed) {
|
||||
const inCompleted = mangaCategories.some(c => c.id === completed.id);
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCategory(cat: Category) {
|
||||
if (!store.previewManga) return;
|
||||
const mangaId = store.previewManga.id;
|
||||
const inCat = mangaCategories.some(c => c.id === cat.id);
|
||||
await gql(UPDATE_MANGA_CATEGORIES, {
|
||||
mangaId,
|
||||
addTo: inCat ? [] : [cat.id],
|
||||
removeFrom: inCat ? [cat.id] : [],
|
||||
}).catch(console.error);
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter(c => c.id !== cat.id)
|
||||
: [...mangaCategories, cat];
|
||||
}
|
||||
|
||||
async function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !store.previewManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, store.previewManga.id);
|
||||
try {
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
|
||||
const cat = res.createCategory.category;
|
||||
allCategories = [...allCategories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.previewManga.id, addTo: [cat.id], removeFrom: [] });
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
}
|
||||
|
||||
@@ -225,12 +272,15 @@
|
||||
</button>
|
||||
{#if folderOpen}
|
||||
<div class="folder-menu">
|
||||
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
||||
{#each store.settings.folders as f}
|
||||
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
||||
<button class="folder-item" class:folder-item-on={isIn}
|
||||
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
||||
{#if catsLoading}
|
||||
<p class="folder-empty">Loading…</p>
|
||||
{:else if allCategories.length === 0 && !creatingFolder}
|
||||
<p class="folder-empty">No folders yet</p>
|
||||
{/if}
|
||||
{#each allCategories as cat}
|
||||
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||||
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{cat.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="folder-divider"></div>
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
||||
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga, Category } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||
|
||||
let mangas: Manga[] = [];
|
||||
let loading = true;
|
||||
let page = 1;
|
||||
let hasNextPage = false;
|
||||
let browseType: BrowseType = "POPULAR";
|
||||
let search = "";
|
||||
let searchInput = "";
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||
let mangas: Manga[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let page = $state(1);
|
||||
let hasNextPage = $state(false);
|
||||
let browseType: BrowseType = $state("POPULAR");
|
||||
let search = $state("");
|
||||
let searchInput = $state("");
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||
if (!$store.activeSource) return;
|
||||
if (!store.activeSource) return;
|
||||
loading = true; mangas = [];
|
||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
|
||||
FETCH_SOURCE_MANGA, { source: store.activeSource.id, type, page: p, query: q || null }
|
||||
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||
.catch(console.error)
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$: if ($store.activeSource) fetchMangas(browseType, page, search);
|
||||
$effect(() => { if (store.activeSource) fetchMangas(browseType, page, search); });
|
||||
|
||||
function submitSearch() {
|
||||
search = searchInput.trim();
|
||||
@@ -40,38 +42,58 @@
|
||||
browseType = mode; search = ""; searchInput = ""; page = 1;
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||
if (!catsLoaded) {
|
||||
catsLoaded = true;
|
||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||
.catch(console.error) },
|
||||
...($store.settings.folders.length > 0 ? [
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...$store.settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some(x => x.id === m.id) ? `✓ ${cat.name}` : cat.name, icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() }).catch(console.error);
|
||||
if (res) {
|
||||
const cat = res.createCategory.category;
|
||||
categories = [...categories, cat];
|
||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
||||
}
|
||||
}},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $store.activeSource}
|
||||
{#if store.activeSource}
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" on:click={() => store.activeSource.set(null)}>
|
||||
<button class="back" onclick={() => setActiveSource(null)}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||
</button>
|
||||
<span class="source-name">{$store.activeSource.displayName}</span>
|
||||
<span class="source-name">{store.activeSource.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tabs">
|
||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
||||
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -80,7 +102,7 @@
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,8 +117,8 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each mangas as m (m.id)}
|
||||
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
|
||||
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
|
||||
oncontextmenu={(e) => openCtx(e, m)}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||
@@ -109,11 +131,11 @@
|
||||
|
||||
{#if !loading && (page > 1 || hasNextPage)}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<Prev size={13} weight="light" /> Prev
|
||||
</button>
|
||||
<span class="page-num">{page}</span>
|
||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
||||
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
|
||||
Next <Next size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -158,4 +180,5 @@
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user