mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Finalized Svelte-5 Rewrite (Testing Phase)
This commit is contained in:
@@ -22,9 +22,13 @@
|
||||
let focused = $state(-1);
|
||||
let el = $state<HTMLDivElement | undefined>(undefined);
|
||||
|
||||
const actionable = items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled);
|
||||
const actionable = $derived(
|
||||
items
|
||||
.map((_, i) => i)
|
||||
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled)
|
||||
);
|
||||
|
||||
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
|
||||
|
||||
const pos = $derived.by(() => {
|
||||
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
||||
@@ -37,8 +41,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
if (actionable.length) focused = actionable[0];
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (el && !el.contains(e.target as Node)) onClose();
|
||||
}
|
||||
@@ -74,7 +76,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={el} class="menu" role="menu" style="left:{pos.left}px;top:{pos.top}px"
|
||||
<div bind:this={el} class="menu" role="menu" tabindex="-1" style="left:{pos.left}px;top:{pos.top}px"
|
||||
oncontextmenu={(e) => e.preventDefault()}>
|
||||
{#each items as item, i}
|
||||
{#if "separator" in item}
|
||||
|
||||
@@ -1,54 +1,85 @@
|
||||
<script lang="ts">
|
||||
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_ALL_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga } from "../../store";
|
||||
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";
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingDetail = $state(false);
|
||||
let loadingChapters = $state(false);
|
||||
let togglingLib = $state(false);
|
||||
let descExpanded = $state(false);
|
||||
let folderOpen = $state(false);
|
||||
let newFolderName = $state("");
|
||||
let creatingFolder = $state(false);
|
||||
let queueingAll = $state(false);
|
||||
let fetchError: string | null = $state(null);
|
||||
let folderRef = $state<HTMLDivElement | undefined>(undefined);
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingDetail = $state(false);
|
||||
let loadingChapters = $state(false);
|
||||
let togglingLib = $state(false);
|
||||
let descExpanded = $state(false);
|
||||
let folderOpen = $state(false);
|
||||
let newFolderName = $state("");
|
||||
let creatingFolder = $state(false);
|
||||
let queueingAll = $state(false);
|
||||
let fetchError: string|null = $state(null);
|
||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||
|
||||
let linkPickerOpen = $state(false);
|
||||
let linkSearch = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList = $state(false);
|
||||
let linkPickerOpen = $state(false);
|
||||
let linkSearch = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList = $state(false);
|
||||
|
||||
const linkedIds = $derived(previewManga ? (settings.mangaLinks?.[previewManga.id] ?? []) : []);
|
||||
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const others = allMangaForLink.filter((m) => m.id !== previewManga?.id);
|
||||
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter((m) => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
|
||||
const displayManga = $derived(manga ?? previewManga);
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true; linkSearch = "";
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||
|
||||
function handleLink(other: Manga) {
|
||||
if (!store.previewManga) return;
|
||||
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
|
||||
else linkManga(store.previewManga.id, other.id);
|
||||
}
|
||||
|
||||
let detailAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
|
||||
function close() {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
setPreviewManga(null);
|
||||
manga = null; chapters = []; descExpanded = false;
|
||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
||||
|
||||
const displayManga = $derived(manga ?? store.previewManga);
|
||||
const totalCount = $derived(chapters.length);
|
||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
||||
const unreadCount = $derived(totalCount - readCount);
|
||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
|
||||
const inLibrary = $derived(manga?.inLibrary ?? previewManga?.inLibrary ?? false);
|
||||
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
|
||||
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
|
||||
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
|
||||
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(previewManga ? settings.folders.filter((f) => f.mangaIds.includes(previewManga!.id)) : []);
|
||||
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
|
||||
|
||||
const continueChapter = $derived.by(() => {
|
||||
if (!chapters.length) return null;
|
||||
@@ -59,61 +90,19 @@
|
||||
return { ch: chapters[0], label: "Read again" };
|
||||
});
|
||||
|
||||
let detailAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (previewManga) load(previewManga.id);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!folderOpen) {
|
||||
document.removeEventListener("mousedown", handleFolderOutside);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("mousedown", handleFolderOutside);
|
||||
};
|
||||
});
|
||||
|
||||
function close() {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
previewManga = null;
|
||||
manga = null;
|
||||
chapters = [];
|
||||
descExpanded = false;
|
||||
folderOpen = false;
|
||||
creatingFolder = false;
|
||||
newFolderName = "";
|
||||
fetchError = null;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
||||
|
||||
async function load(id: number) {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
||||
manga = previewManga as Manga;
|
||||
manga = store.previewManga as Manga;
|
||||
chapters = []; descExpanded = false; fetchError = null;
|
||||
loadingDetail = true; loadingChapters = true;
|
||||
|
||||
(async (): Promise<Manga> => {
|
||||
const key = CACHE_KEYS.MANGA(id);
|
||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(previewManga as Manga)) as Promise<Manga>;
|
||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
||||
return d.fetchManga.manga;
|
||||
@@ -129,8 +118,8 @@
|
||||
manga = fullManga; loadingDetail = false;
|
||||
}).catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
manga = previewManga as Manga;
|
||||
fetchError = "Could not load full details — showing cached data";
|
||||
manga = store.previewManga as Manga;
|
||||
fetchError = "Could not load full details — showing cached data";
|
||||
loadingDetail = false;
|
||||
});
|
||||
|
||||
@@ -156,7 +145,7 @@
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
togglingLib = true;
|
||||
const next = !manga.inLibrary;
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
manga = { ...manga, inLibrary: next };
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
@@ -177,79 +166,58 @@
|
||||
|
||||
function openSeriesDetail() {
|
||||
if (!displayManga) return;
|
||||
activeManga = displayManga;
|
||||
navPage = "library";
|
||||
setActiveManga(displayManga);
|
||||
setNavPage("library");
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !previewManga) return;
|
||||
if (!name || !store.previewManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, previewManga.id);
|
||||
assignMangaToFolder(id, store.previewManga.id);
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
}
|
||||
|
||||
function handleFolderOutside(e: MouseEvent) {
|
||||
if (folderRef && !folderRef.contains(e.target as Node)) {
|
||||
folderOpen = false; creatingFolder = false; newFolderName = "";
|
||||
}
|
||||
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (folderOpen) {
|
||||
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
||||
return () => document.removeEventListener("mousedown", handleFolderOutside);
|
||||
}
|
||||
});
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
||||
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true;
|
||||
linkSearch = "";
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||
.then((d) => { allMangaForLink = d.mangas.nodes; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||
|
||||
function handleLink(other: Manga) {
|
||||
if (!previewManga) return;
|
||||
if (linkedIds.includes(other.id)) {
|
||||
unlinkManga(previewManga.id, other.id);
|
||||
} else {
|
||||
linkManga(previewManga.id, other.id);
|
||||
}
|
||||
}
|
||||
onMount(() => window.addEventListener("keydown", onKey));
|
||||
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
||||
</script>
|
||||
|
||||
{#if previewManga}
|
||||
<div class="backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
|
||||
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
{#if store.previewManga}
|
||||
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
||||
|
||||
<!-- Cover column -->
|
||||
<div class="cover-col">
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||
{#if loadingDetail}
|
||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
|
||||
<!-- Library -->
|
||||
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
||||
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
||||
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
||||
</button>
|
||||
|
||||
<!-- Series Detail -->
|
||||
<button class="action-btn" onclick={openSeriesDetail}>
|
||||
<span class="action-icon"><Books size={13} weight="light" /></span>
|
||||
<span class="action-label">Series Detail</span>
|
||||
</button>
|
||||
|
||||
<!-- Folders -->
|
||||
<div class="folder-wrap" bind:this={folderRef}>
|
||||
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
|
||||
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
||||
@@ -257,11 +225,11 @@
|
||||
</button>
|
||||
{#if folderOpen}
|
||||
<div class="folder-menu">
|
||||
{#if settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
||||
{#each settings.folders as f}
|
||||
{@const isIn = previewManga ? f.mangaIds.includes(previewManga.id) : false}
|
||||
{#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={() => previewManga && (isIn ? removeMangaFromFolder(f.id, previewManga.id) : assignMangaToFolder(f.id, previewManga.id))}>
|
||||
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}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -269,8 +237,8 @@
|
||||
{#if creatingFolder}
|
||||
<div class="folder-create-row">
|
||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
||||
autofocus
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }} />
|
||||
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
|
||||
use:focusAction />
|
||||
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -280,7 +248,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Series Link -->
|
||||
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
|
||||
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
|
||||
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
||||
@@ -289,7 +256,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content column -->
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<div class="title-block">
|
||||
@@ -306,7 +272,6 @@
|
||||
<div class="content-body">
|
||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
||||
|
||||
<!-- Badges -->
|
||||
{#if loadingDetail}
|
||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
||||
{:else}
|
||||
@@ -319,7 +284,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter box -->
|
||||
<div class="chapter-box">
|
||||
{#if loadingChapters}
|
||||
<div class="chapter-loading">
|
||||
@@ -351,7 +315,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if loadingDetail}
|
||||
<div class="sk-desc">
|
||||
<div class="sk-line" style="width:100%"></div>
|
||||
@@ -370,16 +333,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Genres -->
|
||||
{#if !loadingDetail && displayManga?.genre?.length}
|
||||
<div class="genres">
|
||||
{#each displayManga.genre as g}
|
||||
<button class="genre-tag" onclick={() => { genreFilter = g; navPage = "explore"; close(); }}>{g}</button>
|
||||
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta table -->
|
||||
{#if !loadingDetail}
|
||||
<div class="meta-table">
|
||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
||||
@@ -402,7 +363,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link picker modal -->
|
||||
{#if linkPickerOpen}
|
||||
<div class="link-backdrop" role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||
@@ -417,7 +377,7 @@
|
||||
Click a linked entry again to unlink.
|
||||
</p>
|
||||
<div class="link-search-wrap">
|
||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} autofocus />
|
||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
|
||||
</div>
|
||||
<div class="link-list">
|
||||
{#if loadingLinkList}
|
||||
@@ -444,6 +404,10 @@
|
||||
|
||||
{/if}
|
||||
|
||||
<script module>
|
||||
function focusAction(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
||||
@@ -452,14 +416,7 @@
|
||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.action-btn {
|
||||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||||
padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
|
||||
cursor: pointer; text-align: left;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
@@ -546,7 +503,6 @@
|
||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
|
||||
@@ -2,37 +2,37 @@
|
||||
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 { activeSource, activeManga, navPage, settings, addFolder, assignMangaToFolder } from "../../store";
|
||||
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||
|
||||
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 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;
|
||||
|
||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||
if (!activeSource) return;
|
||||
if (!$store.activeSource) return;
|
||||
loading = true; mangas = [];
|
||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: 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; });
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$effect(() => { if (activeSource) fetchMangas(browseType, page, search); });
|
||||
$: if ($store.activeSource) fetchMangas(browseType, page, search);
|
||||
|
||||
function submitSearch() {
|
||||
search = searchInput.trim();
|
||||
search = searchInput.trim();
|
||||
browseType = "SEARCH";
|
||||
page = 1;
|
||||
page = 1;
|
||||
}
|
||||
|
||||
function setMode(mode: BrowseType) {
|
||||
@@ -42,48 +42,36 @@
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: m.inLibrary ? "In Library" : "Add to library",
|
||||
icon: BookmarkSimple,
|
||||
disabled: m.inLibrary,
|
||||
{ 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),
|
||||
},
|
||||
...(settings.folders.length > 0 ? [
|
||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||
.catch(console.error) },
|
||||
...($store.settings.folders.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||
icon: Folder,
|
||||
...$store.settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ 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: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if activeSource}
|
||||
{#if $store.activeSource}
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" onclick={() => activeSource = null}>
|
||||
<button class="back" on:click={() => store.activeSource.set(null)}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||
</button>
|
||||
<span class="source-name">{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} onclick={() => setMode(mode)}>
|
||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -92,7 +80,7 @@
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,9 +95,8 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each mangas as m (m.id)}
|
||||
<button class="card"
|
||||
onclick={() => { activeManga = m; navPage = "library"; }}
|
||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<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 }; }}>
|
||||
<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}
|
||||
@@ -122,11 +109,11 @@
|
||||
|
||||
{#if !loading && (page > 1 || hasNextPage)}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<button class="page-btn" on:click={() => 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" onclick={() => page++} disabled={!hasNextPage}>
|
||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
||||
Next <Next size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES } from "../../lib/queries";
|
||||
import { activeSource } from "../../store";
|
||||
import { store } from "../../store/state.svelte";
|
||||
import type { Source } from "../../lib/types";
|
||||
|
||||
let sources: Source[] = $state([]);
|
||||
@@ -71,7 +71,7 @@
|
||||
{@const single = g.sources.length === 1}
|
||||
{@const open = expanded.has(g.name)}
|
||||
<div>
|
||||
<button class="row" onclick={() => single ? activeSource = g.sources[0] : toggleGroup(g.name)}>
|
||||
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
|
||||
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
|
||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||
<div class="info">
|
||||
@@ -84,7 +84,7 @@
|
||||
</button>
|
||||
{#if !single && open}
|
||||
{#each g.sources as src}
|
||||
<button class="row row-indented" onclick={() => activeSource = src}>
|
||||
<button class="row row-indented" onclick={() => store.activeSource = src}>
|
||||
<div class="indent-spacer"></div>
|
||||
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
|
||||
<span class="arrow">→</span>
|
||||
|
||||
Reference in New Issue
Block a user