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:
+24
-20
@@ -4,7 +4,7 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { gql } from "./lib/client";
|
import { gql } from "./lib/client";
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
import { activeChapter, settingsOpen, settings, activeDownloads, addToast } from "./store";
|
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
import Layout from "./components/layout/Layout.svelte";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import Reader from "./components/reader/Reader.svelte";
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
const MAX_ATTEMPTS = 30;
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
let serverProbeOk = $state(!settings.autoStartServer);
|
let serverProbeOk = $state(!store.settings.autoStartServer);
|
||||||
let appReady = $state(!settings.autoStartServer);
|
let appReady = $state(!store.settings.autoStartServer);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let idle = $state(false);
|
let idle = $state(false);
|
||||||
let devSplash = $state(false);
|
let devSplash = $state(false);
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
function applyQueue(next: DownloadQueueItem[]) {
|
function applyQueue(next: DownloadQueueItem[]) {
|
||||||
detectCompletions(prevQueue, next);
|
detectCompletions(prevQueue, next);
|
||||||
prevQueue = next;
|
prevQueue = next;
|
||||||
activeDownloads = next.map(item => ({
|
setActiveDownloads(next.map(item => ({
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||||
}));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetIdle() {
|
function resetIdle() {
|
||||||
if (idle) return;
|
if (idle) return;
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
const ms = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
if (ms === 0) return;
|
if (ms === 0) return;
|
||||||
idleTimer = setTimeout(() => idle = true, ms);
|
idleTimer = setTimeout(() => idle = true, ms);
|
||||||
}
|
}
|
||||||
@@ -65,11 +65,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
const scale = store.settings.uiScale * 1.5;
|
||||||
|
document.documentElement.style.zoom = `${scale}%`;
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(scale));
|
||||||
|
// --visual-vh gives true viewport height independent of zoom
|
||||||
|
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (scale / 100)}px`);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.documentElement.setAttribute("data-theme", settings.theme ?? "dark");
|
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -85,8 +89,8 @@
|
|||||||
document.addEventListener("contextmenu", e => e.preventDefault());
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
(window as any).__mokuShowSplash = () => devSplash = true;
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
if (settings.autoStartServer) {
|
if (store.settings.autoStartServer) {
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
invoke("spawn_server", { binary: store.settings.serverBinary }).catch(err =>
|
||||||
console.warn("Could not start server:", err));
|
console.warn("Could not start server:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
tries++;
|
tries++;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
const res = await fetch(`${store.settings.serverUrl}/api/graphql`, {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
@@ -110,10 +114,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
type P = { chapterId: number; mangaId: number; progress: number }[];
|
type P = { chapterId: number; mangaId: number; progress: number }[];
|
||||||
unlistenDownload = await listen<P>("download-progress", e => { activeDownloads = e.payload; });
|
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (settings.autoStartServer) invoke("kill_server").catch(() => {});
|
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||||
if (idleTimer) clearTimeout(idleTimer);
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
unlistenDownload?.();
|
unlistenDownload?.();
|
||||||
@@ -125,24 +129,24 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if devSplash}
|
{#if devSplash}
|
||||||
<SplashScreen mode="idle" showFps showCards={settings.splashCards ?? true}
|
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
|
||||||
{:else if !appReady}
|
{:else if !appReady}
|
||||||
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed}
|
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed}
|
||||||
showCards={settings.splashCards ?? true}
|
showCards={store.settings.splashCards ?? true}
|
||||||
onReady={() => appReady = true}
|
onReady={() => appReady = true}
|
||||||
onRetry={handleRetry} />
|
onRetry={handleRetry} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
{#if idle && !activeChapter}
|
{#if idle && !store.activeChapter}
|
||||||
<SplashScreen mode="idle" showCards={settings.splashCards ?? true}
|
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
|
||||||
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
onDismiss={() => setTimeout(() => idle = false, 340)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if !activeChapter}<TitleBar />{/if}
|
{#if !store.activeChapter}<TitleBar />{/if}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if settingsOpen}<Settings />{/if}
|
{#if store.settingsOpen}<Settings />{/if}
|
||||||
<MangaPreview />
|
<MangaPreview />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { navPage, activeManga } from "../../store";
|
import { store } from "../../store/state.svelte";
|
||||||
import Sidebar from "./Sidebar.svelte";
|
import Sidebar from "./Sidebar.svelte";
|
||||||
import Home from "../pages/Home.svelte";
|
import Home from "../pages/Home.svelte";
|
||||||
import Library from "../pages/Library.svelte";
|
import Library from "../pages/Library.svelte";
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||||
import History from "../pages/History.svelte";
|
import History from "../pages/History.svelte";
|
||||||
import Search from "../pages/Search.svelte";
|
import Search from "../pages/Search.svelte";
|
||||||
import Discover from "../pages/Discover.svelte";
|
import Discover from "../pages/Discover.svelte";
|
||||||
import Downloads from "../pages/Downloads.svelte";
|
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
import Downloads from "../pages/Downloads.svelte";
|
||||||
|
import Extensions from "../pages/Extensions.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
{#if activeManga}
|
{#if store.activeManga}
|
||||||
<SeriesDetail />
|
<SeriesDetail />
|
||||||
{:else if navPage === "home"}
|
{:else if store.navPage === "home"}
|
||||||
<Home />
|
<Home />
|
||||||
{:else if navPage === "library"}
|
{:else if store.navPage === "library"}
|
||||||
<Library />
|
<Library />
|
||||||
{:else if navPage === "search"}
|
{:else if store.navPage === "search"}
|
||||||
<Search />
|
<Search />
|
||||||
{:else if navPage === "history"}
|
{:else if store.navPage === "history"}
|
||||||
<History />
|
<History />
|
||||||
{:else if navPage === "explore" || navPage === "sources"}
|
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
||||||
|
<GenreDrillPage />
|
||||||
|
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
||||||
<Discover />
|
<Discover />
|
||||||
{:else if navPage === "downloads"}
|
{:else if store.navPage === "downloads"}
|
||||||
<Downloads />
|
<Downloads />
|
||||||
{:else if navPage === "extensions"}
|
{:else if store.navPage === "extensions"}
|
||||||
<Extensions />
|
<Extensions />
|
||||||
{:else}
|
{:else}
|
||||||
<Home />
|
<Home />
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import { thumbUrl } from "../../lib/client";
|
import { thumbUrl } from "../../lib/client";
|
||||||
import { history, readingStats, clearHistory, activeManga, activeChapterList, openReader } from "../../store";
|
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
|
|
||||||
let search = "";
|
let search = $state("");
|
||||||
let confirmClear = false;
|
let confirmClear = $state(false);
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
if (m < 1) return "Just now";
|
if (m < 1) return "Just now";
|
||||||
@@ -34,13 +33,18 @@
|
|||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session grouping — collapses rapid same-manga reads ───────────────────────
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
mangaId: number;
|
||||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
mangaTitle: string;
|
||||||
firstChapterName: string; chapterCount: number; readAt: number;
|
thumbnailUrl: string;
|
||||||
|
latestChapterId: number;
|
||||||
|
latestChapterName: string;
|
||||||
|
latestPageNumber: number;
|
||||||
|
firstChapterName: string;
|
||||||
|
chapterCount: number;
|
||||||
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
function buildSessions(entries: HistoryEntry[]): Session[] {
|
||||||
@@ -53,28 +57,37 @@
|
|||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
while (j < entries.length) {
|
while (j < entries.length) {
|
||||||
const next = entries[j];
|
const next = entries[j];
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||||
else break;
|
group.push(next); j++;
|
||||||
|
} else break;
|
||||||
}
|
}
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
const latest = group[0], oldest = group[group.length - 1];
|
||||||
sessions.push({
|
sessions.push({
|
||||||
mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl,
|
mangaId: latest.mangaId,
|
||||||
latestChapterId: latest.chapterId, latestChapterName: latest.chapterName,
|
mangaTitle: latest.mangaTitle,
|
||||||
latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName,
|
thumbnailUrl: latest.thumbnailUrl,
|
||||||
chapterCount: group.length, readAt: latest.readAt,
|
latestChapterId: latest.chapterId,
|
||||||
|
latestChapterName: latest.chapterName,
|
||||||
|
latestPageNumber: latest.pageNumber,
|
||||||
|
firstChapterName: oldest.chapterName,
|
||||||
|
chapterCount: group.length,
|
||||||
|
readAt: latest.readAt,
|
||||||
});
|
});
|
||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: filtered = search.trim()
|
const filtered = $derived(search.trim()
|
||||||
? $history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
? store..filter((e) =>
|
||||||
: $history;
|
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: store.);
|
||||||
|
|
||||||
$: sessions = buildSessions(filtered);
|
const sessions = $derived(buildSessions(filtered));
|
||||||
|
|
||||||
$: groups = (() => {
|
const groups = $derived.by(() => {
|
||||||
const map = new Map<string, Session[]>();
|
const map = new Map<string, Session[]>();
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const l = dayLabel(s.readAt);
|
const l = dayLabel(s.readAt);
|
||||||
@@ -82,12 +95,12 @@
|
|||||||
map.get(l)!.push(s);
|
map.get(l)!.push(s);
|
||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||||
})();
|
});
|
||||||
|
|
||||||
function resume(session: Session) {
|
function resume(session: Session) {
|
||||||
const ch = $activeChapterList.find(c => c.id === session.latestChapterId);
|
const ch = store..find((c) => c.id === session.latestChapterId);
|
||||||
if (ch && $activeChapterList.length > 0) openReader(ch, $activeChapterList);
|
if (ch && store..length > 0) openReader(ch, );
|
||||||
else activeManga.set({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
@@ -98,18 +111,17 @@
|
|||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
|
||||||
<!-- ── Header ──────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<span class="heading">History</span>
|
<span class="heading">History</span>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
<input class="search" placeholder="Search store.…" bind:value={search} />
|
||||||
{#if search}<button class="search-clear" on:click={() => search = ""}>×</button>{/if}
|
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $history.length > 0}
|
{#if store..length > 0}
|
||||||
<button class="clear-btn" class:confirm={confirmClear} on:click={handleClear}
|
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history feed"}>
|
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
||||||
<Trash size={14} weight="light" />
|
<Trash size={14} weight="light" />
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,46 +129,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Persistent stats bar — never cleared ────────────────────────────────── -->
|
{#if store..totalChaptersRead > 0}
|
||||||
{#if $readingStats.totalChaptersRead > 0}
|
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<Fire size={13} weight="fill" class="stat-fire" />
|
<Fire size={13} weight="fill" class="stat-fire" />
|
||||||
<span class="stat-val accent">{$readingStats.currentStreakDays}</span>
|
<span class="stat-val accent">{store..currentStreakDays}</span>
|
||||||
<span class="stat-label">day streak</span>
|
<span class="stat-label">day streak</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{$readingStats.totalChaptersRead}</span>
|
<span class="stat-val">{store..totalChaptersRead}</span>
|
||||||
<span class="stat-label">chapters</span>
|
<span class="stat-label">chapters</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{formatReadTime($readingStats.totalMinutesRead)}</span>
|
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
||||||
<span class="stat-label">read time</span>
|
<span class="stat-label">read time</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
||||||
<span class="stat-val">{$readingStats.totalMangaRead}</span>
|
<span class="stat-val">{store..totalMangaRead}</span>
|
||||||
<span class="stat-label">series</span>
|
<span class="stat-label">series</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sep"></div>
|
<div class="stat-sep"></div>
|
||||||
<div class="stat-group">
|
<div class="stat-group">
|
||||||
<span class="stat-val muted">{$readingStats.longestStreakDays}d</span>
|
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
||||||
<span class="stat-label">best streak</span>
|
<span class="stat-label">best streak</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Empty states ────────────────────────────────────────────────────────── -->
|
{#if store..length === 0}
|
||||||
{#if $history.length === 0}
|
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||||
<p class="empty-text">No reading history</p>
|
<p class="empty-text">No reading store.</p>
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
<p class="empty-hint">Chapters you read will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if sessions.length === 0}
|
{:else if sessions.length === 0}
|
||||||
@@ -164,8 +174,6 @@
|
|||||||
<Books size={28} weight="light" class="empty-icon" />
|
<Books size={28} weight="light" class="empty-icon" />
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
<p class="empty-text">No results for "{search}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Timeline ────────────────────────────────────────────────────────────── -->
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
{#each groups as { label, items }}
|
{#each groups as { label, items }}
|
||||||
@@ -176,17 +184,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
{#each items as session (session.latestChapterId)}
|
{#each items as session (session.latestChapterId)}
|
||||||
<button class="session-row" on:click={() => resume(session)}>
|
<button class="session-row" onclick={() => resume(session)}>
|
||||||
|
|
||||||
<!-- Cover -->
|
|
||||||
<div class="thumb-wrap">
|
<div class="thumb-wrap">
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
||||||
{#if session.chapterCount > 1}
|
{#if session.chapterCount > 1}
|
||||||
<span class="session-count">{session.chapterCount}</span>
|
<span class="session-count">{session.chapterCount}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<div class="session-info">
|
<div class="session-info">
|
||||||
<span class="session-title">{session.mangaTitle}</span>
|
<span class="session-title">{session.mangaTitle}</span>
|
||||||
<span class="session-chapter">
|
<span class="session-chapter">
|
||||||
@@ -202,13 +206,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time + play -->
|
|
||||||
<span class="session-time">{timeAgo(session.readAt)}</span>
|
<span class="session-time">{timeAgo(session.readAt)}</span>
|
||||||
<div class="play-pill">
|
<div class="play-pill">
|
||||||
<Play size={10} weight="fill" /> Resume
|
<Play size={10} weight="fill" /> Resume
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +223,6 @@
|
|||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
@@ -250,7 +250,6 @@
|
|||||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
.clear-label { font-size: var(--text-2xs); }
|
||||||
|
|
||||||
/* ── Stats bar — persisted, never clears ────────────────────────────────── */
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
||||||
@@ -266,7 +265,6 @@
|
|||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
|
||||||
|
|
||||||
/* ── Timeline ────────────────────────────────────────────────────────────── */
|
|
||||||
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||||
|
|
||||||
.day-group { margin-bottom: var(--sp-5); }
|
.day-group { margin-bottom: var(--sp-5); }
|
||||||
@@ -285,7 +283,6 @@
|
|||||||
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
|
||||||
|
|
||||||
/* Thumb */
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||||
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
.session-count {
|
.session-count {
|
||||||
@@ -295,14 +292,12 @@
|
|||||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Info */
|
|
||||||
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||||
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
/* Time & play */
|
|
||||||
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||||
.play-pill {
|
.play-pill {
|
||||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||||
@@ -313,7 +308,6 @@
|
|||||||
transition: opacity var(--t-base), transform var(--t-base);
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────────────────────────────────────── */
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
:global(.empty-icon) { color: var(--text-faint); }
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
||||||
import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store";
|
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||||
import type { NavPage } from "../../store";
|
import type { NavPage } from "../../store/state.svelte";
|
||||||
|
|
||||||
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
const TABS: { id: NavPage; label: string; icon: any }[] = [
|
||||||
{ id: "home", label: "Home", icon: House },
|
{ id: "home", label: "Home", icon: House },
|
||||||
@@ -14,18 +14,18 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
navPage = id;
|
store.navPage = id;
|
||||||
activeManga = null;
|
store.activeManga = null;
|
||||||
genreFilter = "";
|
store.genreFilter = "";
|
||||||
if (id !== "explore") activeSource = null;
|
if (id !== "explore") store.activeSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
navPage = "home";
|
store.navPage = "home";
|
||||||
activeSource = null;
|
store.activeSource = null;
|
||||||
activeManga = null;
|
store.activeManga = null;
|
||||||
libraryFilter = "library";
|
store.libraryFilter = "library";
|
||||||
genreFilter = "";
|
store.genreFilter = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
{#each TABS as tab}
|
{#each TABS as tab}
|
||||||
<button class="tab" class:active={navPage === tab.id}
|
<button class="tab" class:active={store.navPage === tab.id}
|
||||||
title={tab.label} onclick={() => navigate(tab.id)}>
|
title={tab.label} onclick={() => navigate(tab.id)}>
|
||||||
<tab.icon size={18} weight="light" />
|
<tab.icon size={18} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<button class="settings-btn" onclick={() => settingsOpen = true} title="Settings">
|
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
|
||||||
<GearSix size={18} weight="light" />
|
<GearSix size={18} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let exiting = $state(false);
|
let exiting = $state(false);
|
||||||
let exitLock = false;
|
let exitLock = false;
|
||||||
|
|
||||||
let fpsEl: HTMLSpanElement;
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
function triggerExit(cb?: () => void) {
|
||||||
if (exitLock) return;
|
if (exitLock) return;
|
||||||
|
|||||||
@@ -6,17 +6,17 @@
|
|||||||
<div class="bar" data-tauri-drag-region>
|
<div class="bar" data-tauri-drag-region>
|
||||||
<span class="title" data-tauri-drag-region>Moku</span>
|
<span class="title" data-tauri-drag-region>Moku</span>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button on:click={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||||
<svg width="9" height="9" viewBox="0 0 9 9">
|
<svg width="9" height="9" viewBox="0 0 9 9">
|
||||||
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="close" on:click={() => win.close()} title="Close" aria-label="Close">
|
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { store, dismissToast } from "../../store/state.svelte";
|
||||||
import { toasts, dismissToast } from "../../store";
|
import type { Toast } from "../../store/state.svelte";
|
||||||
import type { Toast } from "../../store";
|
|
||||||
|
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
@@ -12,9 +11,10 @@
|
|||||||
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
||||||
}
|
}
|
||||||
|
|
||||||
$: $toasts.forEach(schedule);
|
$effect(() => {
|
||||||
|
store.toasts.forEach(schedule);
|
||||||
onDestroy(() => timers.forEach(clearTimeout));
|
return () => timers.forEach(clearTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
const icons: Record<Toast["kind"], string> = {
|
const icons: Record<Toast["kind"], string> = {
|
||||||
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $toasts.length}
|
{#if store.toasts.length}
|
||||||
<div class="toaster" aria-live="polite">
|
<div class="toaster" aria-live="polite">
|
||||||
{#each $toasts as t (t.id)}
|
{#each store.toasts as t (t.id)}
|
||||||
<div class="toast toast-{t.kind}" role="alert">
|
<div class="toast toast-{t.kind}" role="alert">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<p class="title">{t.title}</p>
|
<p class="title">{t.title}</p>
|
||||||
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
<button class="close" on:click={() => dismissToast(t.id)} title="Dismiss">
|
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
||||||
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
|
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
@@ -35,17 +35,17 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
let allManga: Manga[] = []; // local library — loaded once, never triggers lag
|
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
||||||
let allSources: Source[] = []; // all deduped sources — loaded once
|
let allSources: Source[] = $state([]); // all deduped sources — loaded once
|
||||||
let loadingLib = true;
|
let loadingLib = $state(true);
|
||||||
let loadError = false;
|
let loadError = $state(false);
|
||||||
|
|
||||||
// Per-genre result map. Keyed by genre string.
|
// Per-genre result map. Keyed by genre string.
|
||||||
// "All" key → local library deduped by title
|
// "All" key → local library deduped by title
|
||||||
// Each tab key → local + background source results, deduped id+title
|
// Each tab key → local + background source results, deduped id+title
|
||||||
let genreResults = new Map<string, Manga[]>();
|
let genreResults = $state(new Map<string, Manga[]>());
|
||||||
let genreLoading = false; // true only during the initial local fetch for a new tab
|
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
||||||
let currentGenre = "All";
|
let currentGenre = $state("All");
|
||||||
let genreAbort: AbortController | null = null;
|
let genreAbort: AbortController | null = null;
|
||||||
|
|
||||||
// batch timer handle for background source fan-out
|
// batch timer handle for background source fan-out
|
||||||
@@ -54,15 +54,16 @@
|
|||||||
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||||
$: visibleGrid = genreResults.get(currentGenre) ?? [];
|
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||||
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
|
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
|
||||||
|
|
||||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
// ── Dedup helper — always apply id first then title ───────────────────────────
|
||||||
function dedup(items: Manga[]): Manga[] {
|
function dedup(items: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), $settings.mangaLinks);
|
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
// Does NOT set genreLoading = true — the local result is already showing.
|
// Does NOT set genreLoading = true — the local result is already showing.
|
||||||
async function fanOutSources(genre: string, ctrl: AbortController) {
|
async function fanOutSources(genre: string, ctrl: AbortController) {
|
||||||
if (!allSources.length) return;
|
if (!allSources.length) return;
|
||||||
const lang = $settings.preferredExtensionLang || "en";
|
const lang = store.settings.preferredExtensionLang || "en";
|
||||||
const srcs = dedupeSources(allSources, lang);
|
const srcs = dedupeSources(allSources, lang);
|
||||||
|
|
||||||
startBatchFlush();
|
startBatchFlush();
|
||||||
@@ -211,9 +212,9 @@
|
|||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
||||||
},
|
},
|
||||||
...($settings.folders.length > 0 ? [
|
...(store.settings.folders.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...$settings.folders.map(f => ({
|
...store.settings.folders.map(f => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
@@ -235,7 +236,7 @@
|
|||||||
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
||||||
function loadAll() {
|
function loadAll() {
|
||||||
loadingLib = true; loadError = false;
|
loadingLib = true; loadError = false;
|
||||||
const lang = $settings.preferredExtensionLang || "en";
|
const lang = store.settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
// Local library — populates "All" tab
|
// Local library — populates "All" tab
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||||
@@ -266,7 +267,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
||||||
{#if $activeSource}
|
{#if store.activeSource}
|
||||||
<SourceBrowse />
|
<SourceBrowse />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
@@ -279,7 +280,7 @@
|
|||||||
<button
|
<button
|
||||||
class="genre-tab"
|
class="genre-tab"
|
||||||
class:active={currentGenre === tab}
|
class:active={currentGenre === tab}
|
||||||
on:click={() => switchGenre(tab)}
|
onclick={() => switchGenre(tab)}
|
||||||
>
|
>
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
||||||
{tab}
|
{tab}
|
||||||
@@ -302,7 +303,7 @@
|
|||||||
{:else if loadError && visibleGrid.length === 0}
|
{:else if loadError && visibleGrid.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<span>Could not reach Suwayomi</span>
|
<span>Could not reach Suwayomi</span>
|
||||||
<button class="retry-btn" on:click={loadAll}>Retry</button>
|
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
{:else if visibleGrid.length === 0}
|
||||||
@@ -313,8 +314,8 @@
|
|||||||
{#each visibleGrid as m (m.id)}
|
{#each visibleGrid as m (m.id)}
|
||||||
<button
|
<button
|
||||||
class="manga-card"
|
class="manga-card"
|
||||||
on:click={() => previewManga.set(m)}
|
onclick={() => setPreviewManga(m)}
|
||||||
on:contextmenu={(e) => openCtx(e, m)}
|
oncontextmenu={(e) => openCtx(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { activeDownloads } from "../../store";
|
import { store, setActiveDownloads } from "../../store/state.svelte";
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
import type { DownloadStatus } from "../../lib/types";
|
||||||
|
|
||||||
let status: DownloadStatus | null = null;
|
let status: DownloadStatus | null = $state(null);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let togglingPlay = false;
|
let togglingPlay = $state(false);
|
||||||
let clearing = false;
|
let clearing = $state(false);
|
||||||
let dequeueing = new Set<number>();
|
let dequeueing = $state(new Set<number>());
|
||||||
let interval: ReturnType<typeof setInterval>;
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
function applyStatus(ds: DownloadStatus) {
|
function applyStatus(ds: DownloadStatus) {
|
||||||
status = ds;
|
status = ds;
|
||||||
activeDownloads.set(ds.queue.map((item) => ({
|
setActiveDownloads(ds.queue.map((item) => ({
|
||||||
chapterId: item.chapter.id,
|
chapterId: item.chapter.id,
|
||||||
mangaId: item.chapter.mangaId,
|
mangaId: item.chapter.mangaId,
|
||||||
progress: item.progress,
|
progress: item.progress,
|
||||||
@@ -29,8 +28,7 @@
|
|||||||
.finally(() => loading = false);
|
.finally(() => loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => { poll(); interval = setInterval(poll, 2000); });
|
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
|
||||||
onDestroy(() => clearInterval(interval));
|
|
||||||
|
|
||||||
async function togglePlay() {
|
async function togglePlay() {
|
||||||
if (togglingPlay) return;
|
if (togglingPlay) return;
|
||||||
@@ -53,7 +51,7 @@
|
|||||||
if (clearing) return;
|
if (clearing) return;
|
||||||
clearing = true;
|
clearing = true;
|
||||||
if (status) status = { ...status, queue: [] };
|
if (status) status = { ...status, queue: [] };
|
||||||
activeDownloads.set([]);
|
setActiveDownloads([]);
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||||
applyStatus(d.clearDownloader.downloadStatus);
|
applyStatus(d.clearDownloader.downloadStatus);
|
||||||
@@ -69,22 +67,21 @@
|
|||||||
catch (e) { console.error(e); poll(); }
|
catch (e) { console.error(e); poll(); }
|
||||||
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
||||||
}
|
}
|
||||||
|
let queue = $derived(status?.queue ?? []);
|
||||||
$: queue = status?.queue ?? [];
|
const isRunning = $derived(status?.state === "STARTED");
|
||||||
$: isRunning = status?.state === "STARTED";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="heading">Downloads</h1>
|
<h1 class="heading">Downloads</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="icon-btn" class:loading={togglingPlay} on:click={togglePlay}
|
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||||
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
{:else if isRunning}<Pause size={14} weight="fill" />
|
{:else if isRunning}<Pause size={14} weight="fill" />
|
||||||
{:else}<Play size={14} weight="fill" />{/if}
|
{:else}<Play size={14} weight="fill" />{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" class:loading={clearing} on:click={clear}
|
<button class="icon-btn" class:loading={clearing} onclick={clear}
|
||||||
disabled={clearing || queue.length === 0} title="Clear queue">
|
disabled={clearing || queue.length === 0} title="Clear queue">
|
||||||
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
{:else}<Trash size={14} weight="regular" />{/if}
|
{:else}<Trash size={14} weight="regular" />{/if}
|
||||||
@@ -133,7 +130,7 @@
|
|||||||
<div class="row-right">
|
<div class="row-right">
|
||||||
<span class="state-label">{item.state}</span>
|
<span class="state-label">{item.state}</span>
|
||||||
{#if !isActive}
|
{#if !isActive}
|
||||||
<button class="remove-btn" on:click={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
||||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
||||||
import { settings } from "../../store";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Extension } from "../../lib/types";
|
import type { Extension } from "../../lib/types";
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
type Filter = "installed" | "available" | "updates" | "all";
|
||||||
@@ -11,23 +11,23 @@
|
|||||||
|
|
||||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
||||||
|
|
||||||
let extensions: Extension[] = [];
|
let extensions: Extension[] = $state([]);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let refreshing = false;
|
let refreshing = $state(false);
|
||||||
let filter: Filter = "installed";
|
let filter: Filter = $state("installed");
|
||||||
let search = "";
|
let search = $state("");
|
||||||
let working = new Set<string>();
|
let working = $state(new Set<string>());
|
||||||
let expanded = new Set<string>();
|
let expanded = $state(new Set<string>());
|
||||||
let panel: Panel = null;
|
let panel: Panel = $state(null);
|
||||||
let externalUrl = "";
|
let externalUrl = $state("");
|
||||||
let installing = false;
|
let installing = $state(false);
|
||||||
let installError: string|null = null;
|
let installError: string|null = $state(null);
|
||||||
let installSuccess = false;
|
let installSuccess = $state(false);
|
||||||
let repos: string[] = [];
|
let repos: string[] = $state([]);
|
||||||
let reposLoading = false;
|
let reposLoading = $state(false);
|
||||||
let newRepoUrl = "";
|
let newRepoUrl = $state("");
|
||||||
let repoError: string|null = null;
|
let repoError: string|null = $state(null);
|
||||||
let savingRepos = false;
|
let savingRepos = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||||
@@ -93,26 +93,25 @@
|
|||||||
if (p === "repos") loadRepos();
|
if (p === "repos") loadRepos();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => { fetchFromRepo().finally(() => loading = false); });
|
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
|
||||||
|
|
||||||
$: filtered = extensions.filter((e) => {
|
const filtered = $derived(extensions.filter((e) => {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
||||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
||||||
return matchSearch && matchFilter;
|
return matchSearch && matchFilter;
|
||||||
});
|
}));
|
||||||
|
|
||||||
$: groups = (() => {
|
const groups = $derived.by(() => {
|
||||||
const map = new Map<string, Extension[]>();
|
const map = new Map<string, Extension[]>();
|
||||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
||||||
const preferredLang = $settings.preferredExtensionLang;
|
const preferredLang = store.settings.preferredExtensionLang;
|
||||||
return Array.from(map.entries()).map(([base, all]) => {
|
return Array.from(map.entries()).map(([base, all]) => {
|
||||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
||||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
||||||
});
|
});
|
||||||
})();
|
});
|
||||||
|
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
|
||||||
$: updateCount = extensions.filter((e) => e.hasUpdate).length;
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
const FILTERS: { id: Filter; label: string }[] = [
|
||||||
{ id: "installed", label: "Installed" },
|
{ id: "installed", label: "Installed" },
|
||||||
@@ -132,13 +131,13 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 class="heading">Extensions</h1>
|
<h1 class="heading">Extensions</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="icon-btn" class:active={panel === "repos"} on:click={() => openPanel("repos")} title="Manage repos">
|
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
||||||
<GitBranch size={14} weight="light" />
|
<GitBranch size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" class:active={panel === "apk"} on:click={() => openPanel("apk")} title="Install from URL">
|
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
|
||||||
<Plus size={14} weight="light" />
|
<Plus size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" on:click={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,15 +147,14 @@
|
|||||||
<div class="ext-panel">
|
<div class="ext-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Install from APK URL</span>
|
<span class="panel-title">Install from APK URL</span>
|
||||||
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
|
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ext-row">
|
<div class="ext-row">
|
||||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
||||||
bind:value={externalUrl} disabled={installing}
|
bind:value={externalUrl} disabled={installing}
|
||||||
on:input={() => installError = null}
|
oninput={() => installError = null}
|
||||||
on:keydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
|
||||||
use:focusEl />
|
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||||
<button class="install-btn" class:success={installSuccess} on:click={installExternal} disabled={installing || !externalUrl.trim()}>
|
|
||||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||||
{:else}Install{/if}
|
{:else}Install{/if}
|
||||||
@@ -170,7 +168,7 @@
|
|||||||
<div class="ext-panel">
|
<div class="ext-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Extension Repositories</span>
|
<span class="panel-title">Extension Repositories</span>
|
||||||
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
|
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
{#if reposLoading}
|
{#if reposLoading}
|
||||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
@@ -182,7 +180,7 @@
|
|||||||
{#each repos as url}
|
{#each repos as url}
|
||||||
<div class="repo-row">
|
<div class="repo-row">
|
||||||
<span class="repo-url">{url}</span>
|
<span class="repo-url">{url}</span>
|
||||||
<button class="repo-remove" on:click={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,9 +190,9 @@
|
|||||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
<div class="ext-row" style="margin-top:var(--sp-2)">
|
||||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
||||||
bind:value={newRepoUrl} disabled={savingRepos}
|
bind:value={newRepoUrl} disabled={savingRepos}
|
||||||
on:input={() => repoError = null}
|
oninput={() => repoError = null}
|
||||||
on:keydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
||||||
<button class="install-btn" on:click={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +204,7 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each FILTERS as f}
|
{#each FILTERS as f}
|
||||||
<button class="tab" class:active={filter === f.id} on:click={() => filter = f.id}>
|
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -228,7 +226,7 @@
|
|||||||
{@const hasVariants = variants.length > 0}
|
{@const hasVariants = variants.length > 0}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="name">{base}</span>
|
<span class="name">{base}</span>
|
||||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||||
@@ -238,16 +236,16 @@
|
|||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
{:else if primary.hasUpdate}
|
{:else if primary.hasUpdate}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
||||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if primary.isInstalled}
|
{:else if primary.isInstalled}
|
||||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasVariants}
|
{#if hasVariants}
|
||||||
<button class="expand-btn" on:click={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
||||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||||
<span class="expand-count">{variants.length + 1}</span>
|
<span class="expand-count">{variants.length + 1}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -265,11 +263,11 @@
|
|||||||
{#if working.has(v.pkgName)}
|
{#if working.has(v.pkgName)}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
{:else if v.hasUpdate}
|
{:else if v.hasUpdate}
|
||||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
||||||
{:else if v.isInstalled}
|
{:else if v.isInstalled}
|
||||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,9 +280,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script context="module">
|
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
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 } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||||
import { settings, genreFilter, previewManga, addFolder, assignMangaToFolder } from "../../store";
|
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
@@ -28,34 +28,32 @@
|
|||||||
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
|
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
const prevNavPage = store.navPage;
|
||||||
|
const tags = $derived(parseTags(store.genreFilter));
|
||||||
|
const primaryTag = $derived(tags[0] ?? "");
|
||||||
|
const label = $derived(tagsLabel(tags));
|
||||||
|
|
||||||
$: tags = parseTags($genreFilter);
|
let libraryManga: Manga[] = $state([]);
|
||||||
$: primaryTag = tags[0] ?? "";
|
let sourceManga: Manga[] = $state([]);
|
||||||
$: label = tagsLabel(tags);
|
|
||||||
|
|
||||||
let libraryManga: Manga[] = [];
|
|
||||||
let sourceManga: Manga[] = [];
|
|
||||||
let loadingInitial = true;
|
let loadingInitial = true;
|
||||||
let loadingMore = false;
|
let loadingMore = false;
|
||||||
let visibleCount = PAGE_SIZE;
|
let visibleCount = PAGE_SIZE;
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
|
|
||||||
const nextPageMap = new Map<string, number>();
|
const nextPageMap = new Map<string, number>();
|
||||||
let sources: Source[] = [];
|
let sources: Source[] = $state([]);
|
||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
$: filtered = (() => {
|
const filtered = $derived.by(() => {
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
const libIds = new Set(libMatches.map((m) => m.id));
|
||||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
||||||
})();
|
});
|
||||||
|
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||||
$: visibleItems = filtered.slice(0, visibleCount);
|
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||||
$: hasMoreVisible = visibleCount < filtered.length;
|
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||||
$: hasMoreNetwork = sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||||
$: hasMore = hasMoreVisible || hasMoreNetwork;
|
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||||
|
|
||||||
$: if ($genreFilter) load($genreFilter);
|
|
||||||
|
|
||||||
async function load(filter: string) {
|
async function load(filter: string) {
|
||||||
abortCtrl?.abort();
|
abortCtrl?.abort();
|
||||||
@@ -67,7 +65,7 @@
|
|||||||
visibleCount = PAGE_SIZE;
|
visibleCount = PAGE_SIZE;
|
||||||
nextPageMap.clear();
|
nextPageMap.clear();
|
||||||
|
|
||||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||||
const t = parseTags(filter);
|
const t = parseTags(filter);
|
||||||
const pt = t[0] ?? "";
|
const pt = t[0] ?? "";
|
||||||
|
|
||||||
@@ -149,9 +147,9 @@
|
|||||||
return [
|
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(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
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) },
|
||||||
...($settings.folders.length > 0 ? [
|
...(store.settings.folders.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...$settings.folders.map((f): MenuEntry => ({
|
...store.settings.folders.map((f): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
})),
|
})),
|
||||||
@@ -161,12 +159,12 @@
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => abortCtrl?.abort());
|
$effect(() => () => { abortCtrl?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<button class="back" on:click={() => genreFilter.set("")}>
|
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="title">{label}</span>
|
<span class="title">{label}</span>
|
||||||
@@ -192,7 +190,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each visibleItems as m (m.id)}
|
{#each visibleItems as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<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}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
@@ -202,7 +200,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<div class="show-more-cell">
|
<div class="show-more-cell">
|
||||||
<button class="show-more-btn" on:click={loadMore} disabled={loadingMore}>
|
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
||||||
import { thumbUrl, gql } from "../../lib/client";
|
import { thumbUrl, gql } from "../../lib/client";
|
||||||
import { GET_CHAPTERS } from "../../lib/queries";
|
import { GET_CHAPTERS } from "../../lib/queries";
|
||||||
import { history, readingStats, openReader, clearHistory, clearHistoryForManga } from "../../store";
|
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
|
|
||||||
let search = $state("");
|
let search = $state("");
|
||||||
let confirmClearAll = $state(false);
|
let confirmClearAll = $state(false);
|
||||||
@@ -67,8 +67,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = $derived(search.trim()
|
const filtered = $derived(search.trim()
|
||||||
? history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
||||||
: history);
|
: store.history);
|
||||||
|
|
||||||
const sessions = $derived(buildSessions(filtered));
|
const sessions = $derived(buildSessions(filtered));
|
||||||
|
|
||||||
@@ -83,9 +83,9 @@
|
|||||||
})());
|
})());
|
||||||
|
|
||||||
const stats = $derived({
|
const stats = $derived({
|
||||||
uniqueChapters: new Set(history.map(e => e.chapterId)).size,
|
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
|
||||||
uniqueManga: new Set(history.map(e => e.mangaId)).size,
|
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
|
||||||
estimatedMinutes: Math.round(new Set(history.map(e => e.chapterId)).size * 4.5),
|
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
|
||||||
});
|
});
|
||||||
|
|
||||||
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
||||||
@@ -106,14 +106,14 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
<input class="search" placeholder="Search store.history…" bind:value={search} />
|
||||||
{#if search}
|
{#if search}
|
||||||
<button class="search-clear" onclick={() => search = ""}>
|
<button class="search-clear" onclick={() => search = ""}>
|
||||||
<XIcon size={10} weight="bold" />
|
<XIcon size={10} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if history.length > 0}
|
{#if store.history.length > 0}
|
||||||
{#if confirmClearAll}
|
{#if confirmClearAll}
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<span class="confirm-label">Clear all activity?</span>
|
<span class="confirm-label">Clear all activity?</span>
|
||||||
@@ -135,16 +135,16 @@
|
|||||||
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
||||||
<span class="stat-sep"></span>
|
<span class="stat-sep"></span>
|
||||||
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
||||||
{#if readingStats.currentStreakDays > 0}
|
{#if store.readingStats.currentStreakDays > 0}
|
||||||
<span class="stat-sep"></span>
|
<span class="stat-sep"></span>
|
||||||
<span class="stat-item"><span class="stat-val">{readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if history.length === 0}
|
{#if store.history.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||||
<p class="empty-text">No reading history yet</p>
|
<p class="empty-text">No reading store.history yet</p>
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
<p class="empty-hint">Chapters you read will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if sessions.length === 0}
|
{:else if sessions.length === 0}
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<span class="time">{timeAgo(session.readAt)}</span>
|
<span class="time">{timeAgo(session.readAt)}</span>
|
||||||
<Play size={11} weight="fill" class="play-icon" />
|
<Play size={11} weight="fill" class="play-icon" />
|
||||||
</button>
|
</button>
|
||||||
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from history" aria-label="Remove from history">
|
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
|
||||||
<XIcon size={9} weight="bold" />
|
<XIcon size={9} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
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 { 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 { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_CHAPTERS } from "../../lib/queries";
|
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { history, readingStats, settings, activeManga, navPage, previewManga, openReader, COMPLETED_FOLDER_ID, setHeroSlot } from "../../store";
|
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
|
||||||
import type { HistoryEntry } from "../../store";
|
import type { HistoryEntry } from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
@@ -31,20 +31,30 @@
|
|||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
function focusEl(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
|
let extraManga: Manga[] = $state([]);
|
||||||
let loadingLibrary: boolean = $state(true);
|
let loadingLibrary: boolean = $state(true);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
).then(m => { libraryManga = m; })
|
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => loadingLibrary = false);
|
.finally(() => loadingLibrary = false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function fetchExtraCompleted(library: Manga[]) {
|
||||||
|
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
|
||||||
|
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)));
|
||||||
|
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
|
||||||
|
if (valid.length) extraManga = valid;
|
||||||
|
}
|
||||||
|
|
||||||
const continueReading = $derived((() => {
|
const continueReading = $derived((() => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const out: HistoryEntry[] = [];
|
const out: HistoryEntry[] = [];
|
||||||
for (const e of history) {
|
for (const e of store.history) {
|
||||||
if (seen.has(e.mangaId)) continue;
|
if (seen.has(e.mangaId)) continue;
|
||||||
seen.add(e.mangaId);
|
seen.add(e.mangaId);
|
||||||
out.push(e);
|
out.push(e);
|
||||||
@@ -57,7 +67,7 @@
|
|||||||
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
|
||||||
|
|
||||||
const resolvedSlots = $derived((() => {
|
const resolvedSlots = $derived((() => {
|
||||||
const pins = settings.heroSlots ?? [null, null, null, null];
|
const pins = store.settings.heroSlots ?? [null, null, null, null];
|
||||||
const slots: HeroSlot[] = [];
|
const slots: HeroSlot[] = [];
|
||||||
const first = continueReading[0];
|
const first = continueReading[0];
|
||||||
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
|
||||||
@@ -96,12 +106,14 @@
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let heroStageH = $state(300);
|
||||||
let heroChapters: Chapter[] = $state([]);
|
let heroChapters: Chapter[] = $state([]);
|
||||||
let loadingHeroChapters = $state(false);
|
let loadingHeroChapters = $state(false);
|
||||||
let heroChaptersFor: number | null = null;
|
let heroChaptersFor: number | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (heroMangaId && heroMangaId !== heroChaptersFor) loadHeroChapters(heroMangaId);
|
const id = heroMangaId;
|
||||||
|
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadHeroChapters(mangaId: number) {
|
async function loadHeroChapters(mangaId: number) {
|
||||||
@@ -131,12 +143,12 @@
|
|||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
}
|
}
|
||||||
openReader(chapter, all);
|
openReader(chapter, all);
|
||||||
} catch { activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeActive() {
|
async function resumeActive() {
|
||||||
if (!heroEntry && heroManga) { activeManga = heroManga; return; }
|
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||||
if (!heroEntry) return;
|
if (!heroEntry) return;
|
||||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
||||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
if (target && heroChapters.length) { await openChapter(target); return; }
|
||||||
@@ -146,8 +158,8 @@
|
|||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
|
||||||
if (ch) openReader(ch, chapters);
|
if (ch) openReader(ch, chapters);
|
||||||
else activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
|
||||||
} catch { activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +169,8 @@
|
|||||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
|
||||||
if (ch) openReader(ch, chapters);
|
if (ch) openReader(ch, chapters);
|
||||||
else activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
|
||||||
} catch { activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
|
||||||
}
|
}
|
||||||
|
|
||||||
let pickerOpen = $state(false);
|
let pickerOpen = $state(false);
|
||||||
@@ -174,10 +186,11 @@
|
|||||||
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
|
||||||
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
|
||||||
|
|
||||||
const completedIds = $derived(settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
|
||||||
const completedManga = $derived(completedIds.length > 0 ? libraryManga.filter(m => completedIds.includes(m.id)).slice(0, 10) : []);
|
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
|
||||||
const recentHistory = $derived(history.slice(0, 8));
|
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
|
||||||
const stats = $derived(readingStats);
|
const recentHistory = $derived(store.history.slice(0, 8));
|
||||||
|
const stats = $derived(store.readingStats);
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
function handleRowWheel(e: WheelEvent) {
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||||
@@ -190,7 +203,7 @@
|
|||||||
<div class="body">
|
<div class="body">
|
||||||
|
|
||||||
<div class="hero-section">
|
<div class="hero-section">
|
||||||
<div class="hero-stage">
|
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
|
||||||
|
|
||||||
{#if heroThumb}
|
{#if heroThumb}
|
||||||
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
|
||||||
@@ -199,7 +212,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="hero-scrim"></div>
|
<div class="hero-scrim"></div>
|
||||||
|
|
||||||
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} title={heroTitle ? `Resume ${heroTitle}` : undefined} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
|
||||||
{#if heroThumb}
|
{#if heroThumb}
|
||||||
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
|
||||||
{#if activeSlot?.kind === "continue"}
|
{#if activeSlot?.kind === "continue"}
|
||||||
@@ -227,7 +240,7 @@
|
|||||||
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
|
||||||
<span class="hero-tag">{g}</span>
|
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -251,7 +264,7 @@
|
|||||||
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
|
||||||
</button>
|
</button>
|
||||||
{:else if heroManga}
|
{:else if heroManga}
|
||||||
<button class="hero-cta" onclick={() => previewManga = heroManga!}>
|
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
|
||||||
<BookOpen size={11} weight="light" /> View manga
|
<BookOpen size={11} weight="light" /> View manga
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -314,7 +327,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if heroManga}
|
{#if heroManga}
|
||||||
<button class="ch-view-all" onclick={() => { if (heroManga) activeManga = heroManga; }}>
|
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
|
||||||
All chapters <ArrowRight size={9} weight="bold" />
|
All chapters <ArrowRight size={9} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -328,7 +341,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
|
||||||
<button class="see-all" onclick={() => navPage = "history"}>Full history <ArrowRight size={9} weight="bold" /></button>
|
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-list">
|
<div class="activity-list">
|
||||||
{#each recentHistory as entry (entry.chapterId)}
|
{#each recentHistory as entry (entry.chapterId)}
|
||||||
@@ -347,7 +360,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p class="empty-text">Start reading to build your activity feed</p>
|
<p class="empty-text">Start reading to build your activity feed</p>
|
||||||
<button class="empty-cta" onclick={() => navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
|
<button class="empty-cta" onclick={() => store.navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -356,13 +369,13 @@
|
|||||||
<div class="bottom-section-hd">
|
<div class="bottom-section-hd">
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
<button class="see-all" onclick={() => navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if completedManga.length > 0}
|
{#if completedManga.length > 0}
|
||||||
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
||||||
{#each completedManga as m (m.id)}
|
{#each completedManga as m (m.id)}
|
||||||
<button class="mini-card" onclick={() => previewManga = m}>
|
<button class="mini-card" onclick={() => store.previewManga = m}>
|
||||||
<div class="mini-cover-wrap">
|
<div class="mini-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
||||||
<div class="mini-gradient"></div>
|
<div class="mini-gradient"></div>
|
||||||
@@ -433,31 +446,31 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
.body { flex: 1; overflow-y: auto; scrollbar-width: none; padding-bottom: var(--sp-8); }
|
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
||||||
.body::-webkit-scrollbar { display: none; }
|
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
|
||||||
.hero-section { padding: var(--sp-4) var(--sp-5) 0; }
|
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
||||||
.hero-stage { position: relative; display: flex; align-items: stretch; height: 340px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
|
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
||||||
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(24px) saturate(1.4) brightness(0.32); transform: scale(1.07); pointer-events: none; z-index: 0; }
|
|
||||||
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
.hero-bd-empty { background: var(--bg-void); filter: none; }
|
||||||
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.4) 100%); }
|
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
|
||||||
.hero-cover-col { position: relative; z-index: 2; width: clamp(150px, 30%, 195px); flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: var(--sp-5); background: none; border: none; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.06); }
|
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
|
||||||
.hero-cover-col:hover .hero-cover { filter: brightness(1.1); }
|
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
|
||||||
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
|
||||||
.hero-cover-col:disabled { cursor: default; }
|
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
|
||||||
.hero-cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-lg); box-shadow: 0 10px 36px rgba(0,0,0,0.75), 0 2px 8px rgba(0,0,0,0.4); display: block; transition: filter 0.18s ease; }
|
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
.hero-cover-empty { width: 100%; aspect-ratio: 2/3; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); border-radius: var(--radius-lg); color: var(--text-faint); }
|
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
||||||
.cover-resume-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 32px; background: rgba(0,0,0,0.35); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
|
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
||||||
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-5) var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
|
|
||||||
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
|
||||||
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
|
||||||
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
|
||||||
|
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
|
||||||
|
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
|
||||||
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
|
||||||
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
|
||||||
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
.hero-prog-page { color: rgba(255,255,255,0.38); }
|
||||||
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
|
||||||
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; flex: 1; min-height: 0; }
|
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
|
||||||
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
|
||||||
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||||
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
||||||
@@ -499,31 +512,31 @@
|
|||||||
.sk-meta { height: 9px; width: 50%; }
|
.sk-meta { height: 9px; width: 50%; }
|
||||||
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
|
||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
.ch-view-all:hover { color: var(--accent-fg); }
|
||||||
.section { border-top: 1px solid var(--border-dim); margin-top: var(--sp-4); }
|
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-5) var(--sp-2); }
|
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
.see-all:hover { color: var(--accent-fg); }
|
||||||
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); }
|
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
|
||||||
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.activity-row:hover .activity-play { opacity: 1; }
|
.activity-row:hover .activity-play { opacity: 1; }
|
||||||
.activity-thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.activity-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.activity-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.activity-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||||
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-5) 0; margin-top: var(--sp-4); border-top: 1px solid var(--border-dim); align-items: start; }
|
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; min-height: 100%; }
|
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
||||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); }
|
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); padding-bottom: var(--sp-4); }
|
||||||
.bottom-col:first-child { padding-right: var(--sp-5); }
|
.bottom-col:first-child { padding-right: var(--sp-4); }
|
||||||
.bottom-col:last-child { padding-left: var(--sp-5); }
|
.bottom-col:last-child { padding-left: var(--sp-4); }
|
||||||
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) 0; }
|
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
|
||||||
.mini-row { display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none; padding-bottom: var(--sp-2); }
|
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: var(--sp-3); }
|
||||||
.mini-row::-webkit-scrollbar { display: none; }
|
|
||||||
.mini-card { flex-shrink: 0; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.mini-card:hover { will-change: transform; }
|
.mini-card:hover { will-change: transform; }
|
||||||
.mini-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); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
.mini-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); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
|
||||||
@@ -533,16 +546,16 @@
|
|||||||
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
||||||
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
||||||
.stat-card { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
|
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
|
||||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
||||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
||||||
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
.stat-val { font-family: var(--font-ui); font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); padding: var(--sp-7) var(--sp-6); }
|
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
|
||||||
.empty-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.empty-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
.empty-cta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
.empty-cta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||||
.empty-cta:hover { filter: brightness(1.1); }
|
.empty-cta:hover { filter: brightness(1.1); }
|
||||||
@@ -560,7 +573,7 @@
|
|||||||
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
|
||||||
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.picker-row:hover { background: var(--bg-raised); }
|
.picker-row:hover { background: var(--bg-raised); }
|
||||||
.picker-thumb { width: 34px; height: 50px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
|
||||||
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||||
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
|
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
|
||||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
|
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 type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
const CARD_MIN_W = 130;
|
||||||
const CARD_GAP = 16;
|
const CARD_GAP = 16;
|
||||||
|
|
||||||
let allManga: Manga[] = $state([]);
|
let allManga: Manga[] = $state([]);
|
||||||
let allMangaUnfiltered: Manga[] = $state([]);
|
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
|
||||||
let loading: boolean = $state(true);
|
let loading: boolean = $state(true);
|
||||||
let error: string|null = $state(null);
|
let error: string|null = $state(null);
|
||||||
let retryCount: number = $state(0);
|
let retryCount: number = $state(0);
|
||||||
let search: string = $state("");
|
let search: string = $state("");
|
||||||
let renderVisible: number = $state(0);
|
let renderVisible: number = $state(0);
|
||||||
let scrollEl: HTMLDivElement;
|
let scrollEl: HTMLDivElement;
|
||||||
let containerWidth: number = $state(800);
|
let containerWidth: number = $state(800);
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
let emptyCtx: { x: number; y: number } | null = $state(null);
|
let emptyCtx: { x: number; y: number } | null = $state(null);
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const wasOpen = prevChapterId !== null;
|
const wasOpen = prevChapterId !== null;
|
||||||
prevChapterId = activeChapter?.id ?? null;
|
prevChapterId = store.activeChapter?.id ?? null;
|
||||||
if (wasOpen && !activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchLibrary() {
|
function fetchLibrary() {
|
||||||
@@ -44,47 +45,66 @@
|
|||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
fetchLibrary()
|
fetchLibrary()
|
||||||
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), settings.mangaLinks); error = null; })
|
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
|
||||||
.catch(e => error = e.message)
|
.catch(e => error = e.message)
|
||||||
.finally(() => loading = false);
|
.finally(() => loading = false);
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.ALL_MANGA, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then(d => d.mangas.nodes),
|
|
||||||
DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY,
|
|
||||||
).then(nodes => { allMangaUnfiltered = dedupeMangaById(nodes); }).catch(console.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
retryCount;
|
retryCount;
|
||||||
loading = true; error = null;
|
loading = true; error = null;
|
||||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
loadData();
|
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 }); });
|
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const f = settings.folders.find(f => f.id === libraryFilter);
|
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||||
if (f && !f.showTab) libraryFilter = "library";
|
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
||||||
|
|
||||||
|
// All manga available for folder filtering — library + any extras fetched above
|
||||||
const folderPool = $derived((() => {
|
const folderPool = $derived((() => {
|
||||||
const seen = new Set(allManga.map(m => m.id));
|
const seen = new Set(allManga.map(m => m.id));
|
||||||
return [...allManga, ...allMangaUnfiltered.filter(m => !seen.has(m.id))];
|
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const filtered = $derived((() => {
|
const filtered = $derived((() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
if (libraryFilter === "library") {
|
if (store.libraryFilter === "library") {
|
||||||
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
|
||||||
}
|
}
|
||||||
if (libraryFilter === "downloaded") {
|
if (store.libraryFilter === "downloaded") {
|
||||||
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||||
}
|
}
|
||||||
const folder = settings.folders.find(f => f.id === libraryFilter);
|
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
|
||||||
if (folder) {
|
if (folder) {
|
||||||
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
|
||||||
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
|
||||||
@@ -97,15 +117,15 @@
|
|||||||
const hasMore = $derived(filtered.length > renderVisible);
|
const hasMore = $derived(filtered.length > renderVisible);
|
||||||
const remainingCount = $derived(filtered.length - renderVisible);
|
const remainingCount = $derived(filtered.length - renderVisible);
|
||||||
|
|
||||||
$effect(() => { filtered; renderVisible = settings.renderLimit ?? 48; });
|
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
|
||||||
|
|
||||||
const counts = $derived({
|
const counts = $derived({
|
||||||
library: allManga.length,
|
library: allManga.length,
|
||||||
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
|
||||||
...settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadMore() { renderVisible += settings.renderLimit ?? 48; }
|
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
async function removeFromLibrary(manga: Manga) {
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
@@ -128,7 +148,7 @@
|
|||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
const mangaFolders = getMangaFolders(m.id);
|
const mangaFolders = getMangaFolders(m.id);
|
||||||
const folderEntries: MenuEntry[] = settings.folders.map(f => {
|
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
|
||||||
const inFolder = mangaFolders.some(mf => mf.id === f.id);
|
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) };
|
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) };
|
||||||
});
|
});
|
||||||
@@ -163,7 +183,7 @@
|
|||||||
emptyCtx = { x: e.clientX, y: e.clientY };
|
emptyCtx = { x: e.clientX, y: e.clientY };
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if settings.libraryBranches ?? true}
|
{#if store.settings.libraryBranches ?? true}
|
||||||
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
|
||||||
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
|
||||||
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
|
||||||
@@ -196,15 +216,15 @@
|
|||||||
<span class="heading">Library</span>
|
<span class="heading">Library</span>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
||||||
<button class="tab" class:active={libraryFilter === f} onclick={() => libraryFilter = f}>
|
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
{#if f === "library"}<Books size={11} weight="bold" />
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
||||||
{label}
|
{label}
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
<span class="tab-count">{counts[f] ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each settings.folders.filter(f => f.showTab) as folder}
|
{#each store.settings.folders.filter(f => f.showTab) as folder}
|
||||||
<button class="tab" class:active={libraryFilter === folder.id} onclick={() => libraryFilter = folder.id}>
|
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
|
||||||
<Folder size={11} weight="bold" />
|
<Folder size={11} weight="bold" />
|
||||||
{folder.name}
|
{folder.name}
|
||||||
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
||||||
@@ -229,16 +249,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if filtered.length === 0}
|
{:else if filtered.length === 0}
|
||||||
<div class="center">
|
<div class="center">
|
||||||
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
|
||||||
: libraryFilter === "downloaded" ? "No downloaded manga."
|
: store.libraryFilter === "downloaded" ? "No downloaded manga."
|
||||||
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid" style="--cols:{cols}">
|
<div class="grid" style="--cols:{cols}">
|
||||||
{#each visibleManga as m (m.id)}
|
{#each visibleManga as m (m.id)}
|
||||||
<button class="card" onclick={() => activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
|
<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}
|
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
||||||
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +269,7 @@
|
|||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<div class="load-more-row">
|
<div class="load-more-row">
|
||||||
<button class="load-more-btn" onclick={loadMore}>
|
<button class="load-more-btn" onclick={loadMore}>
|
||||||
Show {Math.min(remainingCount, settings.renderLimit ?? 48)} more
|
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
|
||||||
<span class="load-more-count">({remainingCount} remaining)</span>
|
<span class="load-more-count">({remainingCount} remaining)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
export let manga: Manga;
|
interface Props {
|
||||||
export let currentChapters: Chapter[];
|
manga: Manga;
|
||||||
export let onClose: () => void;
|
currentChapters: Chapter[];
|
||||||
export let onMigrated: (newManga: Manga) => void;
|
onClose: () => void;
|
||||||
|
onMigrated: (newManga: Manga) => void;
|
||||||
|
}
|
||||||
|
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
|
||||||
|
|
||||||
type Step = "source" | "search" | "confirm";
|
type Step = "source" | "search" | "confirm";
|
||||||
|
|
||||||
@@ -30,35 +32,34 @@
|
|||||||
return intersection / union;
|
return intersection / union;
|
||||||
}
|
}
|
||||||
|
|
||||||
let step: Step = "source";
|
let step: Step = $state("source");
|
||||||
let sources: Source[] = [];
|
let sources: Source[] = $state([]);
|
||||||
let loadingSources = true;
|
let loadingSources = $state(true);
|
||||||
let selectedSource: Source | null = null;
|
let selectedSource: Source | null = $state(null);
|
||||||
let query = manga.title;
|
const _initialTitle = manga.title;
|
||||||
let results: { manga: Manga; similarity: number }[] = [];
|
let query = $state(_initialTitle);
|
||||||
let searching = false;
|
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||||
let selectedMatch: Match | null = null;
|
let searching = $state(false);
|
||||||
let loadingMatchId: number | null = null;
|
let selectedMatch: Match | null = $state(null);
|
||||||
let migrating = false;
|
let loadingMatchId: number | null = $state(null);
|
||||||
let error: string | null = null;
|
let migrating = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
const readCount = $derived(currentChapters.filter((c) => c.isRead).length);
|
||||||
|
const totalCount = $derived(currentChapters.length);
|
||||||
|
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
|
||||||
|
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
|
||||||
|
const stepIdx = $derived(STEPS.indexOf(step));
|
||||||
|
|
||||||
$: readCount = currentChapters.filter((c) => c.isRead).length;
|
$effect(() => {
|
||||||
$: totalCount = currentChapters.length;
|
|
||||||
$: chapterDiff = selectedMatch ? selectedMatch.chapters.length - totalCount : 0;
|
|
||||||
$: STEPS = (["source", "search", "confirm"] as Step[]);
|
|
||||||
$: stepIdx = STEPS.indexOf(step);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
.then((d) => sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))
|
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => loadingSources = false);
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => window.removeEventListener("keydown", onKey));
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
|
||||||
async function searchSource(src: Source, q: string) {
|
async function searchSource(src: Source, q: string) {
|
||||||
@@ -144,9 +145,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="overlay" on:click={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -155,7 +156,7 @@
|
|||||||
<span class="modal-title-label">Migrate source</span>
|
<span class="modal-title-label">Migrate source</span>
|
||||||
<span class="modal-title-manga">{manga.title}</span>
|
<span class="modal-title-manga">{manga.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" on:click={onClose}><X size={14} weight="light" /></button>
|
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step indicators -->
|
<!-- Step indicators -->
|
||||||
@@ -189,9 +190,9 @@
|
|||||||
<button
|
<button
|
||||||
class="source-row"
|
class="source-row"
|
||||||
class:source-row-active={selectedSource?.id === src.id}
|
class:source-row-active={selectedSource?.id === src.id}
|
||||||
on:click={() => pickSource(src)}>
|
onclick={() => pickSource(src)}>
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
||||||
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<div class="source-info">
|
<div class="source-info">
|
||||||
<span class="source-name">{src.displayName}</span>
|
<span class="source-name">{src.displayName}</span>
|
||||||
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
@@ -210,9 +211,9 @@
|
|||||||
{#if selectedSource}
|
{#if selectedSource}
|
||||||
<div class="search-context">
|
<div class="search-context">
|
||||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
||||||
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||||
<button class="search-context-change" on:click={() => { step = "source"; results = []; }}>Change</button>
|
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -220,11 +221,11 @@
|
|||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||||
<input class="search-input" bind:value={query}
|
<input class="search-input" bind:value={query}
|
||||||
on:keydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
placeholder="Search title…" autofocus />
|
placeholder="Search title…" autofocus />
|
||||||
</div>
|
</div>
|
||||||
<button class="search-btn"
|
<button class="search-btn"
|
||||||
on:click={() => selectedSource && searchSource(selectedSource, query)}
|
onclick={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
disabled={searching || !selectedSource}>
|
disabled={searching || !selectedSource}>
|
||||||
{#if searching}
|
{#if searching}
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin" />
|
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
@@ -250,7 +251,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each results as { manga: m, similarity }, idx}
|
{#each results as { manga: m, similarity }, idx}
|
||||||
<button class="result-row"
|
<button class="result-row"
|
||||||
on:click={() => selectMatch(m, similarity)}
|
onclick={() => selectMatch(m, similarity)}
|
||||||
disabled={loadingMatchId !== null}>
|
disabled={loadingMatchId !== null}>
|
||||||
<div class="result-cover-wrap">
|
<div class="result-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
||||||
@@ -345,8 +346,8 @@
|
|||||||
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
||||||
|
|
||||||
<div class="confirm-actions">
|
<div class="confirm-actions">
|
||||||
<button class="back-btn" on:click={() => step = "search"} disabled={migrating}>Back</button>
|
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
|
||||||
<button class="migrate-btn" on:click={migrate} disabled={migrating}>
|
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
|
||||||
{#if migrating}
|
{#if migrating}
|
||||||
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
|
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
+148
-215
@@ -1,14 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy, untrack } from "svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||||
import { settings, searchPrefill, previewManga } from "../../store";
|
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type SearchTab = "keyword" | "tag" | "source";
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
type TagMode = "AND" | "OR";
|
type TagMode = "AND" | "OR";
|
||||||
|
|
||||||
@@ -19,13 +17,7 @@
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONCURRENCY = 6;
|
||||||
|
|
||||||
const CONCURRENCY = 6; // more parallel source requests
|
|
||||||
// RESULTS_PER_SOURCE and TAG_PAGE_SIZE are driven by $settings.renderLimit
|
|
||||||
// (accessed inline) so changing the setting takes effect immediately.
|
|
||||||
// No MAX_TAG_SOURCES cap — we fan out to all deduped sources so the grid
|
|
||||||
// is fully populated. Concurrency + caching keep this fast.
|
|
||||||
|
|
||||||
const COMMON_GENRES = [
|
const COMMON_GENRES = [
|
||||||
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||||
@@ -35,13 +27,7 @@
|
|||||||
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
|
||||||
items: T[],
|
|
||||||
fn: (item: T) => Promise<void>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
while (i < items.length) {
|
while (i < items.length) {
|
||||||
@@ -77,24 +63,25 @@
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
let tab: SearchTab = $state("keyword");
|
||||||
|
|
||||||
let tab: SearchTab = "keyword";
|
let preferredLang = store.settings?.preferredExtensionLang ?? "";
|
||||||
|
|
||||||
let preferredLang = $settings?.preferredExtensionLang ?? "";
|
let allSources: Source[] = $state([]);
|
||||||
|
let loadingSources = $state(false);
|
||||||
|
let pendingPrefill = $state("");
|
||||||
|
|
||||||
let allSources: Source[] = [];
|
$effect(() => {
|
||||||
let loadingSources = false;
|
if (store.searchPrefill) {
|
||||||
let pendingPrefill = "";
|
const prefill = store.searchPrefill;
|
||||||
|
untrack(() => {
|
||||||
|
pendingPrefill = prefill;
|
||||||
|
tab = "keyword";
|
||||||
|
setSearchPrefill("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
$: if ($searchPrefill) {
|
|
||||||
pendingPrefill = $searchPrefill;
|
|
||||||
tab = "keyword";
|
|
||||||
searchPrefill.set("");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
loadingSources = true;
|
loadingSources = true;
|
||||||
cache.get(
|
cache.get(
|
||||||
CACHE_KEYS.SOURCES,
|
CACHE_KEYS.SOURCES,
|
||||||
@@ -106,35 +93,37 @@
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
$: availableLangs = Array.from(new Set<string>(allSources.map((s) => s.lang))).sort();
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
$: hasMultipleLangs = availableLangs.length > 1;
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
|
// ── Keyword search ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let kw_query = "";
|
let kw_query = $state("");
|
||||||
let kw_submitted = "";
|
let kw_submitted = $state("");
|
||||||
let kw_results: SourceResult[] = [];
|
let kw_results: SourceResult[] = $state([]);
|
||||||
let kw_showAdvanced = false;
|
let kw_showAdvanced = $state(false);
|
||||||
let kw_selectedLangs: Set<string> = new Set();
|
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||||
let kw_includeNsfw = false;
|
let kw_includeNsfw = $state(false);
|
||||||
let kw_inputEl: HTMLInputElement | null = null;
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
let kw_abortCtrl: AbortController | null = null;
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
$: if (allSources.length) {
|
if (allSources.length) {
|
||||||
const available = new Set(allSources.map((s) => s.lang));
|
const available = new Set(allSources.map((s) => s.lang));
|
||||||
kw_selectedLangs = available.has(preferredLang)
|
kw_selectedLangs = available.has(preferredLang)
|
||||||
? new Set([preferredLang])
|
? new Set([preferredLang])
|
||||||
: new Set(availableLangs.slice(0, 1));
|
: new Set(availableLangs.slice(0, 1));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
$: if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
|
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
|
||||||
const q = pendingPrefill;
|
const q = pendingPrefill;
|
||||||
pendingPrefill = "";
|
pendingPrefill = "";
|
||||||
kw_query = q;
|
kw_query = q;
|
||||||
kwDoSearch(q);
|
kwDoSearch(q);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function kwGetVisibleSources(): Source[] {
|
function kwGetVisibleSources(): Source[] {
|
||||||
let filtered = allSources;
|
let filtered = allSources;
|
||||||
@@ -150,21 +139,16 @@
|
|||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const visible = kwGetVisibleSources();
|
const visible = kwGetVisibleSources();
|
||||||
if (!visible.length) return;
|
if (!visible.length) return;
|
||||||
|
|
||||||
kw_abortCtrl?.abort();
|
kw_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
kw_abortCtrl = ctrl;
|
kw_abortCtrl = ctrl;
|
||||||
|
|
||||||
kw_submitted = trimmed;
|
kw_submitted = trimmed;
|
||||||
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||||
|
|
||||||
await runConcurrent(visible, async (src) => {
|
await runConcurrent(visible, async (src) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
|
||||||
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
|
||||||
ctrl.signal,
|
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
kw_results = kw_results.map((r) =>
|
kw_results = kw_results.map((r) =>
|
||||||
@@ -186,72 +170,67 @@
|
|||||||
kw_selectedLangs = next;
|
kw_selectedLangs = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: kw_visibleCount = kwGetVisibleSources().length;
|
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||||
$: kw_hasResults = kw_results.some((r) => r.mangas.length > 0);
|
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
|
||||||
$: kw_allDone = kw_results.length > 0 && kw_results.every((r) => !r.loading);
|
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
|
||||||
|
|
||||||
|
// ── Tag search ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let tag_activeTags: string[] = [];
|
let tag_activeTags: string[] = $state([]);
|
||||||
let tag_tagMode: TagMode = "AND";
|
let tag_tagMode: TagMode = $state("AND");
|
||||||
let tag_tagFilter = "";
|
let tag_tagFilter = $state("");
|
||||||
|
|
||||||
let tag_localResults: Manga[] = [];
|
let tag_localResults: Manga[] = $state([]);
|
||||||
let tag_totalCount = 0;
|
let tag_totalCount = $state(0);
|
||||||
let tag_loadingLocal = false;
|
let tag_loadingLocal = $state(false);
|
||||||
let tag_loadingMoreLocal = false;
|
let tag_loadingMoreLocal = $state(false);
|
||||||
let tag_localOffset = 0;
|
let tag_localOffset = $state(0);
|
||||||
let tag_localHasNext = false;
|
let tag_localHasNext = $state(false);
|
||||||
let tag_abortLocal: AbortController | null = null;
|
let tag_abortLocal: AbortController | null = null;
|
||||||
|
|
||||||
let tag_searchSources = false;
|
let tag_searchSources = $state(false);
|
||||||
let tag_sourceResults: Manga[] = [];
|
let tag_sourceResults: Manga[] = $state([]);
|
||||||
let tag_loadingSourceSearch = false;
|
let tag_loadingSourceSearch = $state(false);
|
||||||
let tag_loadingMoreSource = false;
|
let tag_loadingMoreSource = $state(false);
|
||||||
let tag_srcNextPage: Map<string, number> = new Map();
|
let tag_srcNextPage: Map<string, number> = $state(new Map());
|
||||||
let tag_abortSource: AbortController | null = null;
|
let tag_abortSource: AbortController | null = null;
|
||||||
|
|
||||||
$: tag_filteredGenres = (() => {
|
const tag_filteredGenres = $derived.by(() => {
|
||||||
const q = tag_tagFilter.trim().toLowerCase();
|
const q = tag_tagFilter.trim().toLowerCase();
|
||||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||||
})();
|
});
|
||||||
|
|
||||||
$: tag_hasActiveTags = tag_activeTags.length > 0;
|
const tag_hasActiveTags = $derived(tag_activeTags.length > 0);
|
||||||
$: tag_localIds = new Set(tag_localResults.map((m) => m.id));
|
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||||
$: tag_mergedResults = dedupeMangaByTitle(dedupeMangaById(
|
const tag_mergedResults = $derived(dedupeMangaByTitle(dedupeMangaById(
|
||||||
tag_searchSources
|
tag_searchSources
|
||||||
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
|
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
|
||||||
: tag_localResults
|
: tag_localResults
|
||||||
), $settings.mangaLinks);
|
), store.settings.mangaLinks));
|
||||||
$: tag_totalVisible = tag_mergedResults.length;
|
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||||
$: tag_sourceHasMore = tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0);
|
const tag_sourceHasMore = $derived(tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
$: {
|
|
||||||
const _activeTags = tag_activeTags;
|
const _activeTags = tag_activeTags;
|
||||||
const _tagMode = tag_tagMode;
|
const _tagMode = tag_tagMode;
|
||||||
tagFetchLocal(_activeTags, _tagMode);
|
untrack(() => tagFetchLocal(_activeTags, _tagMode));
|
||||||
}
|
});
|
||||||
|
|
||||||
// Auto-enable source search if local results are sparse (< 20 after initial load)
|
let tag_autoSearchFired = $state(false);
|
||||||
// Use a flag so this only fires once per tag set, not on every reactive update
|
$effect(() => {
|
||||||
let tag_autoSearchFired = false;
|
if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
|
||||||
$: if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
|
if (tag_localResults.length < 20) {
|
||||||
if (tag_localResults.length < 20) {
|
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
||||||
tag_autoSearchFired = true;
|
}
|
||||||
tag_searchSources = true;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
// Reset the flag when tags change
|
$effect(() => { const _ = tag_activeTags; untrack(() => { tag_autoSearchFired = false; }); });
|
||||||
$: { tag_activeTags; tag_autoSearchFired = false; }
|
$effect(() => {
|
||||||
|
if (tag_searchSources && tag_activeTags.length > 0 && !loadingSources) {
|
||||||
$: {
|
const tags = tag_activeTags;
|
||||||
const _search = tag_searchSources;
|
untrack(() => tagFetchSources(tags));
|
||||||
const _tags = tag_activeTags;
|
|
||||||
if (_search && _tags.length > 0 && !loadingSources) {
|
|
||||||
tagFetchSources(_tags);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
async function tagFetchLocal(activeTags: string[], tagMode: TagMode) {
|
async function tagFetchLocal(activeTags: string[], tagMode: TagMode) {
|
||||||
if (activeTags.length === 0) {
|
if (activeTags.length === 0) {
|
||||||
@@ -263,17 +242,16 @@
|
|||||||
tag_abortLocal = ctrl;
|
tag_abortLocal = ctrl;
|
||||||
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||||
tag_loadingLocal = true;
|
tag_loadingLocal = true;
|
||||||
|
|
||||||
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||||
MANGAS_BY_GENRE,
|
MANGAS_BY_GENRE,
|
||||||
{ filter: buildGenreFilter(activeTags, tagMode), first: ($settings.renderLimit ?? 48), offset: 0 },
|
{ filter: buildGenreFilter(activeTags, tagMode), first: (store.settings.renderLimit ?? 48), offset: 0 },
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
).then((d) => {
|
).then((d) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
tag_localResults = d.mangas.nodes;
|
tag_localResults = d.mangas.nodes;
|
||||||
tag_totalCount = d.mangas.totalCount;
|
tag_totalCount = d.mangas.totalCount;
|
||||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset = ($settings.renderLimit ?? 48);
|
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||||
}).catch((e: any) => {
|
}).catch((e: any) => {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -285,50 +263,32 @@
|
|||||||
tag_abortSource?.abort();
|
tag_abortSource?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
tag_abortSource = ctrl;
|
tag_abortSource = ctrl;
|
||||||
|
|
||||||
// Don't blank existing results — keep them visible while new ones load.
|
|
||||||
// Only reset if the tags actually changed (tracked by the calling reactive block).
|
|
||||||
tag_srcNextPage = new Map();
|
tag_srcNextPage = new Map();
|
||||||
tag_loadingSourceSearch = true;
|
tag_loadingSourceSearch = true;
|
||||||
|
|
||||||
// Fan out to ALL deduped sources — no arbitrary cap.
|
|
||||||
// Concurrency (6) + per-page caching keeps this fast without hammering connections.
|
|
||||||
const sources = dedupeSources(allSources, preferredLang);
|
const sources = dedupeSources(allSources, preferredLang);
|
||||||
const primaryTag = activeTags[0];
|
const primaryTag = activeTags[0];
|
||||||
|
|
||||||
for (const src of sources) tag_srcNextPage.set(src.id, -1);
|
for (const src of sources) tag_srcNextPage.set(src.id, -1);
|
||||||
|
|
||||||
runConcurrent(sources, async (src) => {
|
runConcurrent(sources, async (src) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const ps = getPageSet(src.id, "SEARCH", activeTags);
|
const ps = getPageSet(src.id, "SEARCH", activeTags);
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
|
||||||
|
|
||||||
const result = await cache
|
const result = await cache
|
||||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
pageKey,
|
pageKey,
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: primaryTag }, ctrl.signal,
|
||||||
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
|
|
||||||
ctrl.signal,
|
|
||||||
).then((d) => d.fetchSourceManga),
|
).then((d) => d.fetchSourceManga),
|
||||||
)
|
)
|
||||||
.catch((e: any) => {
|
.catch((e: any) => { if (e?.name !== "AbortError") console.error(e); return null; });
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
ps.add(1);
|
ps.add(1);
|
||||||
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
|
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
|
||||||
|
|
||||||
tag_srcNextPage = new Map(tag_srcNextPage);
|
tag_srcNextPage = new Map(tag_srcNextPage);
|
||||||
|
|
||||||
const matching = activeTags.length > 1
|
const matching = activeTags.length > 1
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
||||||
tag_loadingSourceSearch = false;
|
tag_loadingSourceSearch = false;
|
||||||
}
|
}
|
||||||
}, ctrl.signal).finally(() => {
|
}, ctrl.signal).finally(() => {
|
||||||
@@ -345,13 +305,13 @@
|
|||||||
try {
|
try {
|
||||||
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||||
MANGAS_BY_GENRE,
|
MANGAS_BY_GENRE,
|
||||||
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: ($settings.renderLimit ?? 48), offset: tag_localOffset },
|
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
tag_localResults = [...tag_localResults, ...d.mangas.nodes];
|
tag_localResults = [...tag_localResults, ...d.mangas.nodes];
|
||||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
tag_localOffset += ($settings.renderLimit ?? 48);
|
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -365,43 +325,31 @@
|
|||||||
tag_abortSource?.abort();
|
tag_abortSource?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
tag_abortSource = ctrl;
|
tag_abortSource = ctrl;
|
||||||
|
const sources = dedupeSources(allSources, preferredLang).filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
|
||||||
const sources = dedupeSources(allSources, preferredLang)
|
|
||||||
.filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
|
|
||||||
const primaryTag = tag_activeTags[0];
|
const primaryTag = tag_activeTags[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runConcurrent(sources, async (src) => {
|
await runConcurrent(sources, async (src) => {
|
||||||
const page = tag_srcNextPage.get(src.id)!;
|
const page = tag_srcNextPage.get(src.id)!;
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const ps = getPageSet(src.id, "SEARCH", tag_activeTags);
|
const ps = getPageSet(src.id, "SEARCH", tag_activeTags);
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tag_activeTags);
|
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tag_activeTags);
|
||||||
|
|
||||||
const result = await cache
|
const result = await cache
|
||||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||||
pageKey,
|
pageKey,
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
|
||||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
|
||||||
ctrl.signal,
|
|
||||||
).then((d) => d.fetchSourceManga),
|
).then((d) => d.fetchSourceManga),
|
||||||
)
|
)
|
||||||
.catch((e: any) => {
|
.catch((e: any) => { if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1); return null; });
|
||||||
if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
ps.add(page);
|
ps.add(page);
|
||||||
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
|
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||||
tag_srcNextPage = new Map(tag_srcNextPage);
|
tag_srcNextPage = new Map(tag_srcNextPage);
|
||||||
|
|
||||||
const matching = tag_activeTags.length > 1
|
const matching = tag_activeTags.length > 1
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -424,43 +372,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Source browse ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let src_selectedLang = "all";
|
let src_selectedLang = $state("all");
|
||||||
let src_activeSource: Source | null = null;
|
let src_activeSource: Source | null = $state(null);
|
||||||
let src_browseResults: Manga[] = [];
|
let src_browseResults: Manga[] = $state([]);
|
||||||
let src_loadingBrowse = false;
|
let src_loadingBrowse = $state(false);
|
||||||
let src_browseQuery = "";
|
let src_browseQuery = $state("");
|
||||||
let src_submitted = "";
|
let src_submitted = $state("");
|
||||||
let src_hasNextPage = false;
|
let src_hasNextPage = $state(false);
|
||||||
let src_currentPage = 1;
|
let src_currentPage = $state(1);
|
||||||
let src_abortCtrl: AbortController | null = null;
|
let src_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
$: src_visibleSources = src_selectedLang === "all"
|
const src_visibleSources = $derived(src_selectedLang === "all"
|
||||||
? allSources
|
? allSources
|
||||||
: allSources.filter((s) => s.lang === src_selectedLang);
|
: allSources.filter((s) => s.lang === src_selectedLang));
|
||||||
|
|
||||||
async function srcFetchBrowse(
|
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
src: Source,
|
|
||||||
type: "POPULAR" | "SEARCH",
|
|
||||||
q?: string,
|
|
||||||
page = 1,
|
|
||||||
) {
|
|
||||||
src_abortCtrl?.abort();
|
src_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
src_abortCtrl = ctrl;
|
src_abortCtrl = ctrl;
|
||||||
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
|
||||||
{ source: src.id, type, page, query: q ?? null },
|
|
||||||
ctrl.signal,
|
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
src_browseResults = page === 1
|
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas];
|
||||||
? d.fetchSourceManga.mangas
|
|
||||||
: [...src_browseResults, ...d.fetchSourceManga.mangas];
|
|
||||||
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||||
src_currentPage = page;
|
src_currentPage = page;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -471,9 +409,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function srcSelectSource(src: Source) {
|
function srcSelectSource(src: Source) {
|
||||||
src_activeSource = src;
|
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
|
||||||
src_browseQuery = "";
|
|
||||||
src_submitted = "";
|
|
||||||
srcFetchBrowse(src, "POPULAR");
|
srcFetchBrowse(src, "POPULAR");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,13 +420,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function srcClearSearch() {
|
function srcClearSearch() {
|
||||||
src_browseQuery = "";
|
src_browseQuery = ""; src_submitted = "";
|
||||||
src_submitted = "";
|
|
||||||
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
kw_abortCtrl?.abort();
|
kw_abortCtrl?.abort();
|
||||||
tag_abortLocal?.abort();
|
tag_abortLocal?.abort();
|
||||||
@@ -507,7 +440,7 @@
|
|||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
class:tabActive={tab === "keyword"}
|
class:tabActive={tab === "keyword"}
|
||||||
on:click={() => (tab = "keyword")}
|
onclick={() => (tab = "keyword")}
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
@@ -518,7 +451,7 @@
|
|||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
class:tabActive={tab === "tag"}
|
class:tabActive={tab === "tag"}
|
||||||
on:click={() => (tab = "tag")}
|
onclick={() => (tab = "tag")}
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
@@ -529,7 +462,7 @@
|
|||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
class:tabActive={tab === "source"}
|
class:tabActive={tab === "source"}
|
||||||
on:click={() => (tab = "source")}
|
onclick={() => (tab = "source")}
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
@@ -555,13 +488,13 @@
|
|||||||
autofocus
|
autofocus
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
on:keydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
|
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
|
||||||
/>
|
/>
|
||||||
{#if kw_query}
|
{#if kw_query}
|
||||||
<button
|
<button
|
||||||
class="clearBtn"
|
class="clearBtn"
|
||||||
title="Clear"
|
title="Clear"
|
||||||
on:click={() => { kw_query = ""; kw_inputEl?.focus(); }}
|
onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}
|
||||||
>×</button>
|
>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasMultipleLangs}
|
{#if hasMultipleLangs}
|
||||||
@@ -569,7 +502,7 @@
|
|||||||
class="advancedBtn"
|
class="advancedBtn"
|
||||||
class:advancedBtnActive={kw_showAdvanced}
|
class:advancedBtnActive={kw_showAdvanced}
|
||||||
title="Language & filter options"
|
title="Language & filter options"
|
||||||
on:click={() => (kw_showAdvanced = !kw_showAdvanced)}
|
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
@@ -579,7 +512,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="searchBtn"
|
class="searchBtn"
|
||||||
on:click={() => kwDoSearch(kw_query)}
|
onclick={() => kwDoSearch(kw_query)}
|
||||||
disabled={!kw_query.trim() || loadingSources}
|
disabled={!kw_query.trim() || loadingSources}
|
||||||
>
|
>
|
||||||
{#if loadingSources}
|
{#if loadingSources}
|
||||||
@@ -597,8 +530,8 @@
|
|||||||
<div class="advancedHeader">
|
<div class="advancedHeader">
|
||||||
<span class="advancedTitle">Languages</span>
|
<span class="advancedTitle">Languages</span>
|
||||||
<div class="advancedActions">
|
<div class="advancedActions">
|
||||||
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||||
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="langGrid">
|
<div class="langGrid">
|
||||||
@@ -606,7 +539,7 @@
|
|||||||
<button
|
<button
|
||||||
class="langChip"
|
class="langChip"
|
||||||
class:langChipActive={kw_selectedLangs.has(lang)}
|
class:langChipActive={kw_selectedLangs.has(lang)}
|
||||||
on:click={() => kwToggleLang(lang)}
|
onclick={() => kwToggleLang(lang)}
|
||||||
>
|
>
|
||||||
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
@@ -638,7 +571,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if hasMultipleLangs && !kw_showAdvanced}
|
{#if hasMultipleLangs && !kw_showAdvanced}
|
||||||
<button class="advancedLinkStandalone" on:click={() => (kw_showAdvanced = true)}>
|
<button class="advancedLinkStandalone" onclick={() => (kw_showAdvanced = true)}>
|
||||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -663,7 +596,7 @@
|
|||||||
src={thumbUrl(source.iconUrl)}
|
src={thumbUrl(source.iconUrl)}
|
||||||
alt={source.displayName}
|
alt={source.displayName}
|
||||||
class="sourceIcon"
|
class="sourceIcon"
|
||||||
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
<span class="sourceName">{source.displayName}</span>
|
<span class="sourceName">{source.displayName}</span>
|
||||||
{#if hasMultipleLangs}
|
{#if hasMultipleLangs}
|
||||||
@@ -692,8 +625,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if mangas.length > 0}
|
{:else if mangas.length > 0}
|
||||||
<div class="sourceRow">
|
<div class="sourceRow">
|
||||||
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
|
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(m.thumbnailUrl)}
|
src={thumbUrl(m.thumbnailUrl)}
|
||||||
@@ -737,7 +670,7 @@
|
|||||||
placeholder="Filter tags…"
|
placeholder="Filter tags…"
|
||||||
/>
|
/>
|
||||||
{#if tag_tagFilter}
|
{#if tag_tagFilter}
|
||||||
<button class="splitSearchClear" title="Clear" on:click={() => (tag_tagFilter = "")}>×</button>
|
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="splitList">
|
<div class="splitList">
|
||||||
@@ -745,7 +678,7 @@
|
|||||||
<button
|
<button
|
||||||
class="splitItem"
|
class="splitItem"
|
||||||
class:splitItemActive={tag_activeTags.includes(tag)}
|
class:splitItemActive={tag_activeTags.includes(tag)}
|
||||||
on:click={() => tagToggleTag(tag)}
|
onclick={() => tagToggleTag(tag)}
|
||||||
>
|
>
|
||||||
<span class="splitItemLabel">{tag}</span>
|
<span class="splitItemLabel">{tag}</span>
|
||||||
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
||||||
@@ -774,7 +707,7 @@
|
|||||||
{#each tag_activeTags as tag (tag)}
|
{#each tag_activeTags as tag (tag)}
|
||||||
<span class="tagPill">
|
<span class="tagPill">
|
||||||
{tag}
|
{tag}
|
||||||
<button class="tagPillRemove" title="Remove {tag}" on:click={() => tagToggleTag(tag)}>×</button>
|
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -785,13 +718,13 @@
|
|||||||
class="tagModeBtn"
|
class="tagModeBtn"
|
||||||
class:tagModeBtnActive={tag_tagMode === "AND"}
|
class:tagModeBtnActive={tag_tagMode === "AND"}
|
||||||
title="Match ALL tags"
|
title="Match ALL tags"
|
||||||
on:click={() => (tag_tagMode = "AND")}
|
onclick={() => (tag_tagMode = "AND")}
|
||||||
>AND</button>
|
>AND</button>
|
||||||
<button
|
<button
|
||||||
class="tagModeBtn"
|
class="tagModeBtn"
|
||||||
class:tagModeBtnActive={tag_tagMode === "OR"}
|
class:tagModeBtnActive={tag_tagMode === "OR"}
|
||||||
title="Match ANY tag"
|
title="Match ANY tag"
|
||||||
on:click={() => (tag_tagMode = "OR")}
|
onclick={() => (tag_tagMode = "OR")}
|
||||||
>OR</button>
|
>OR</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -800,7 +733,7 @@
|
|||||||
class:tagModeBtnActive={tag_searchSources}
|
class:tagModeBtnActive={tag_searchSources}
|
||||||
title="Also search across sources (slower, requires network)"
|
title="Also search across sources (slower, requires network)"
|
||||||
disabled={loadingSources}
|
disabled={loadingSources}
|
||||||
on:click={tagToggleSearchSources}
|
onclick={tagToggleSearchSources}
|
||||||
>
|
>
|
||||||
|
|
||||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
||||||
@@ -808,7 +741,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Sources
|
Sources
|
||||||
</button>
|
</button>
|
||||||
<button class="tagClearAll" on:click={() => (tag_activeTags = [])}>Clear all</button>
|
<button class="tagClearAll" onclick={() => (tag_activeTags = [])}>Clear all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -843,7 +776,7 @@
|
|||||||
{:else if tag_mergedResults.length > 0}
|
{:else if tag_mergedResults.length > 0}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each tag_mergedResults as m (m.id)}
|
{#each tag_mergedResults as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
@@ -864,7 +797,7 @@
|
|||||||
{#if tag_localHasNext || tag_sourceHasMore}
|
{#if tag_localHasNext || tag_sourceHasMore}
|
||||||
<div class="showMoreCell">
|
<div class="showMoreCell">
|
||||||
{#if tag_localHasNext}
|
{#if tag_localHasNext}
|
||||||
<button class="showMoreBtn" on:click={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
|
<button class="showMoreBtn" onclick={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
|
||||||
{#if tag_loadingMoreLocal}
|
{#if tag_loadingMoreLocal}
|
||||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
@@ -875,7 +808,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tag_sourceHasMore}
|
{#if tag_sourceHasMore}
|
||||||
<button class="showMoreBtn" on:click={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
|
<button class="showMoreBtn" onclick={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
|
||||||
{#if tag_loadingMoreSource}
|
{#if tag_loadingMoreSource}
|
||||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
|
||||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
@@ -916,7 +849,7 @@
|
|||||||
<button
|
<button
|
||||||
class="langChip"
|
class="langChip"
|
||||||
class:langChipActive={src_selectedLang === lang}
|
class:langChipActive={src_selectedLang === lang}
|
||||||
on:click={() => (src_selectedLang = lang)}
|
onclick={() => (src_selectedLang = lang)}
|
||||||
>
|
>
|
||||||
{lang === "all" ? "All" : lang.toUpperCase()}
|
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
@@ -936,13 +869,13 @@
|
|||||||
<button
|
<button
|
||||||
class="splitItem splitItemSource"
|
class="splitItem splitItemSource"
|
||||||
class:splitItemActive={src_activeSource?.id === src.id}
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
on:click={() => srcSelectSource(src)}
|
onclick={() => srcSelectSource(src)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(src.iconUrl)}
|
src={thumbUrl(src.iconUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
class="splitSourceIcon"
|
class="splitSourceIcon"
|
||||||
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
<span class="splitItemLabel">{src.displayName}</span>
|
<span class="splitItemLabel">{src.displayName}</span>
|
||||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
@@ -972,7 +905,7 @@
|
|||||||
src={thumbUrl(src_activeSource.iconUrl)}
|
src={thumbUrl(src_activeSource.iconUrl)}
|
||||||
alt=""
|
alt=""
|
||||||
class="splitSourceIcon"
|
class="splitSourceIcon"
|
||||||
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||||
{#if src_loadingBrowse}
|
{#if src_loadingBrowse}
|
||||||
@@ -994,15 +927,15 @@
|
|||||||
bind:value={src_browseQuery}
|
bind:value={src_browseQuery}
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
placeholder="Search {src_activeSource.displayName}…"
|
placeholder="Search {src_activeSource.displayName}…"
|
||||||
on:keydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
||||||
/>
|
/>
|
||||||
{#if src_submitted}
|
{#if src_submitted}
|
||||||
<button class="clearBtn" title="Clear search" on:click={srcClearSearch}>×</button>
|
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="searchBtn"
|
class="searchBtn"
|
||||||
on:click={srcHandleSearch}
|
onclick={srcHandleSearch}
|
||||||
disabled={!src_browseQuery.trim() || src_loadingBrowse}
|
disabled={!src_browseQuery.trim() || src_loadingBrowse}
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
@@ -1022,7 +955,7 @@
|
|||||||
{:else if src_browseResults.length > 0}
|
{:else if src_browseResults.length > 0}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each src_browseResults as m (m.id)}
|
{#each src_browseResults as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
@@ -1036,7 +969,7 @@
|
|||||||
<button
|
<button
|
||||||
class="showMoreBtn"
|
class="showMoreBtn"
|
||||||
disabled={src_loadingBrowse}
|
disabled={src_loadingBrowse}
|
||||||
on:click={() => src_activeSource && srcFetchBrowse(
|
onclick={() => src_activeSource && srcFetchBrowse(
|
||||||
src_activeSource,
|
src_activeSource,
|
||||||
src_submitted ? "SEARCH" : "POPULAR",
|
src_submitted ? "SEARCH" : "POPULAR",
|
||||||
src_submitted || undefined,
|
src_submitted || undefined,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
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 } 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 } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||||
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted } from "../../store";
|
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import MigrateModal from "./MigrateModal.svelte";
|
import MigrateModal from "./MigrateModal.svelte";
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
let rangeTo: string = $state("");
|
let rangeTo: string = $state("");
|
||||||
let showRange: boolean = $state(false);
|
let showRange: boolean = $state(false);
|
||||||
let migrateOpen: boolean = $state(false);
|
let migrateOpen: boolean = $state(false);
|
||||||
let dlDropRef: HTMLDivElement;
|
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||||
let folderPickerRef: HTMLDivElement;
|
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
let mangaAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
let chapterAbort: AbortController | null = null;
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
function applyChapters(nodes: Chapter[]) {
|
||||||
chapters = nodes;
|
chapters = nodes;
|
||||||
if (activeManga && nodes.length > 0) checkAndMarkCompleted(activeManga.id, nodes);
|
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortDir = $derived(settings.chapterSortDir);
|
const sortDir = $derived(store.settings.chapterSortDir);
|
||||||
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
|
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
||||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
})());
|
})());
|
||||||
|
|
||||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||||
const assignedFolders = $derived(activeManga ? getMangaFolders(activeManga.id) : []);
|
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
||||||
const hasFolders = $derived(assignedFolders.length > 0);
|
const hasFolders = $derived(assignedFolders.length > 0);
|
||||||
|
|
||||||
function loadManga(id: number) {
|
function loadManga(id: number) {
|
||||||
@@ -141,14 +141,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeManga) { loadManga(activeManga.id); loadChapters(activeManga.id); }
|
const m = store.activeManga;
|
||||||
|
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
|
||||||
});
|
});
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const wasOpen = prevChapterId !== null;
|
const wasOpen = prevChapterId !== null;
|
||||||
prevChapterId = activeChapter?.id ?? null;
|
prevChapterId = store.activeChapter?.id ?? null;
|
||||||
if (wasOpen && !activeChapter && activeManga) { loadChapters(activeManga.id); cache.clear(CACHE_KEYS.LIBRARY); }
|
if (wasOpen && !store.activeChapter && store.activeManga) {
|
||||||
|
const id = store.activeManga.id;
|
||||||
|
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
@@ -174,20 +178,20 @@
|
|||||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
|
||||||
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
||||||
if (activeManga) reloadChapters(activeManga.id);
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
if (!chapterIds.length) return;
|
if (!chapterIds.length) return;
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||||
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
||||||
if (activeManga) reloadChapters(activeManga.id);
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(chapterId: number, isRead: boolean) {
|
async function markRead(chapterId: number, isRead: boolean) {
|
||||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markBulk(ids: number[], isRead: boolean) {
|
async function markBulk(ids: number[], isRead: boolean) {
|
||||||
@@ -195,7 +199,7 @@
|
|||||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
async function deleteDownloaded(chapterId: number) {
|
async function deleteDownloaded(chapterId: number) {
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
|
||||||
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
async function deleteAllDownloads() {
|
||||||
@@ -215,16 +219,16 @@
|
|||||||
deletingAll = true;
|
deletingAll = true;
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
|
||||||
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
|
||||||
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
|
||||||
deletingAll = false;
|
deletingAll = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshChapters() {
|
async function refreshChapters() {
|
||||||
if (!activeManga || refreshing) return;
|
if (!store.activeManga || refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
chapterStore.delete(activeManga.id);
|
chapterStore.delete(store.activeManga.id);
|
||||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
|
||||||
.then(() => reloadChapters(activeManga!.id))
|
.then(() => reloadChapters(store.activeManga!.id))
|
||||||
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
||||||
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
|
||||||
.finally(() => refreshing = false);
|
.finally(() => refreshing = false);
|
||||||
@@ -276,25 +280,25 @@
|
|||||||
|
|
||||||
function createFolder() {
|
function createFolder() {
|
||||||
const name = folderNewName.trim();
|
const name = folderNewName.trim();
|
||||||
if (!name || !activeManga) return;
|
if (!name || !store.activeManga) return;
|
||||||
const id = addFolder(name);
|
const id = addFolder(name);
|
||||||
assignMangaToFolder(id, activeManga.id);
|
assignMangaToFolder(id, store.activeManga.id);
|
||||||
folderNewName = ""; folderCreating = false;
|
folderNewName = ""; folderCreating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if activeManga}
|
{#if store.activeManga}
|
||||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<button class="back" onclick={() => activeManga = null}>
|
<button class="back" onclick={() => setActiveManga(null)}>
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
<ArrowLeft size={13} weight="light" /> Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} class="cover" />
|
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loadingManga}
|
{#if loadingManga}
|
||||||
@@ -314,7 +318,7 @@
|
|||||||
{#if manga?.genre?.length}
|
{#if manga?.genre?.length}
|
||||||
<div class="genres">
|
<div class="genres">
|
||||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
||||||
<button class="genre" onclick={() => { genreFilter = g; navPage = "explore"; activeManga = null; }}>{g}</button>
|
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if manga.genre.length > 5}
|
{#if manga.genre.length > 5}
|
||||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
||||||
@@ -415,13 +419,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if folderPickerOpen}
|
{#if folderPickerOpen}
|
||||||
<div class="fp-menu">
|
<div class="fp-menu">
|
||||||
{#if settings.folders.length === 0 && !folderCreating}
|
{#if store.settings.folders.length === 0 && !folderCreating}
|
||||||
<p class="fp-empty">No folders yet</p>
|
<p class="fp-empty">No folders yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each settings.folders as folder}
|
{#each store.settings.folders as folder}
|
||||||
{@const isIn = activeManga ? folder.mangaIds.includes(activeManga.id) : false}
|
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
|
||||||
<button class="fp-item" class:fp-item-active={isIn}
|
<button class="fp-item" class:fp-item-active={isIn}
|
||||||
onclick={() => activeManga && (isIn ? removeMangaFromFolder(folder.id, activeManga.id) : assignMangaToFolder(folder.id, activeManga.id))}>
|
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
|
||||||
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -429,8 +433,7 @@
|
|||||||
{#if folderCreating}
|
{#if folderCreating}
|
||||||
<div class="fp-create">
|
<div class="fp-create">
|
||||||
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
|
||||||
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }}
|
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
|
||||||
use:focus />
|
|
||||||
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
|
||||||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
|
||||||
<X size={12} weight="light" />
|
<X size={12} weight="light" />
|
||||||
@@ -449,8 +452,7 @@
|
|||||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="jump-row">
|
<div class="jump-row">
|
||||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput}
|
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
|
||||||
use:focus
|
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
if (e.key === "Escape") { jumpOpen = false; return; }
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@@ -499,7 +501,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="dl-range-row">
|
<div class="dl-range-row">
|
||||||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
||||||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focus />
|
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
|
||||||
<span class="dl-range-sep">–</span>
|
<span class="dl-range-sep">–</span>
|
||||||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
|
||||||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
||||||
@@ -571,11 +573,11 @@
|
|||||||
<div class="ch-right">
|
<div class="ch-right">
|
||||||
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||||
{#if ch.isDownloaded}
|
{#if ch.isDownloaded}
|
||||||
<button class="dl-btn" onclick|stopPropagation={() => deleteDownloaded(ch.id)}><Trash size={13} weight="light" /></button>
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
|
||||||
{:else if enqueueing.has(ch.id)}
|
{:else if enqueueing.has(ch.id)}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
|
||||||
{:else}
|
{:else}
|
||||||
<button class="dl-btn" onclick|stopPropagation={(e) => enqueue(ch, e)}><Download size={13} weight="light" /></button>
|
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -602,14 +604,11 @@
|
|||||||
{manga}
|
{manga}
|
||||||
currentChapters={chapters}
|
currentChapters={chapters}
|
||||||
onClose={() => migrateOpen = false}
|
onClose={() => migrateOpen = false}
|
||||||
onMigrated={(newManga) => { activeManga = newManga; migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<script context="module">
|
|
||||||
function focus(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|||||||
+138
-114
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick, untrack } from "svelte";
|
||||||
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||||
import { settings, activeManga, activeChapter, activeChapterList, pageUrls, pageNumber, closeReader, openReader, settingsOpen, addHistory, updateSettings, checkAndMarkCompleted } from "../../store";
|
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||||
import type { FitMode } from "../../store";
|
import type { FitMode } from "../../store/state.svelte";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
@@ -73,17 +73,17 @@
|
|||||||
let sentinelEl: HTMLDivElement;
|
let sentinelEl: HTMLDivElement;
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let loading: boolean = $state(true);
|
let loading = $state(true);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let dlOpen: boolean = $state(false);
|
let dlOpen = $state(false);
|
||||||
let zoomOpen: boolean = $state(false);
|
let zoomOpen = $state(false);
|
||||||
let uiVisible: boolean = $state(true);
|
let uiVisible = $state(true);
|
||||||
let pageReady: boolean = $state(false);
|
let pageReady = $state(false);
|
||||||
let pageGroups: number[][] = $state([]);
|
let pageGroups: number[][] = $state([]);
|
||||||
let stripChapters: StripChapter[] = $state([]);
|
let stripChapters: StripChapter[] = $state([]);
|
||||||
let visibleChapterId: number | null = $state(null);
|
let visibleChapterId: number | null = $state(null);
|
||||||
let nextN: number = $state(5);
|
let nextN = $state(5);
|
||||||
let dlBusy: boolean = $state(false);
|
let dlBusy = $state(false);
|
||||||
let markedRead = new Set<number>();
|
let markedRead = new Set<number>();
|
||||||
let appended = new Set<number>();
|
let appended = new Set<number>();
|
||||||
let appending = false;
|
let appending = false;
|
||||||
@@ -91,32 +91,33 @@
|
|||||||
let loadingId: number | null = null;
|
let loadingId: number | null = null;
|
||||||
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
|
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
|
||||||
|
|
||||||
const rtl = $derived(settings.readingDirection === "rtl");
|
|
||||||
const fit = $derived((settings.fitMode ?? "width") as FitMode);
|
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||||
const style = $derived(settings.pageStyle ?? "single");
|
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||||
const maxW = $derived(settings.maxPageWidth ?? 900);
|
const style = $derived(store.settings.pageStyle ?? "single");
|
||||||
const autoNext = $derived(settings.autoNextChapter ?? false);
|
const maxW = $derived(store.settings.maxPageWidth ?? 900);
|
||||||
const markOnNext = $derived(settings.markReadOnNext ?? true);
|
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||||
const lastPage = $derived(pageUrls.length);
|
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||||
|
const lastPage = $derived(store.pageUrls.length);
|
||||||
|
|
||||||
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
|
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
|
||||||
? (activeChapterList.find(c => c.id === visibleChapterId) ?? activeChapter)
|
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||||
: activeChapter);
|
: store.activeChapter);
|
||||||
|
|
||||||
const adjacent = $derived((() => {
|
const adjacent = $derived((() => {
|
||||||
const ref = displayChapter ?? activeChapter;
|
const ref = displayChapter ?? store.activeChapter;
|
||||||
if (!ref || !activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||||||
const idx = activeChapterList.findIndex(c => c.id === ref.id);
|
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
||||||
return {
|
return {
|
||||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
||||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
||||||
remaining: activeChapterList.slice(idx + 1),
|
remaining: store.activeChapterList.slice(idx + 1),
|
||||||
};
|
};
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const visibleChunkLastPage = $derived((() => {
|
const visibleChunkLastPage = $derived((() => {
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
if (style !== "longstrip" || !autoNext) return lastPage;
|
||||||
const chId = visibleChapterId ?? activeChapter?.id;
|
const chId = visibleChapterId ?? store.activeChapter?.id;
|
||||||
const chunk = stripChapters.find(c => c.chapterId === chId);
|
const chunk = stripChapters.find(c => c.chapterId === chId);
|
||||||
return chunk?.urls.length ?? lastPage;
|
return chunk?.urls.length ?? lastPage;
|
||||||
})());
|
})());
|
||||||
@@ -127,21 +128,21 @@
|
|||||||
fit === "height" && "fit-height",
|
fit === "height" && "fit-height",
|
||||||
fit === "screen" && "fit-screen",
|
fit === "screen" && "fit-screen",
|
||||||
fit === "original" && "fit-original",
|
fit === "original" && "fit-original",
|
||||||
settings.optimizeContrast && "optimize-contrast",
|
store.settings.optimizeContrast && "optimize-contrast",
|
||||||
].filter(Boolean).join(" "));
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||||||
const styleLabel = $derived(style);
|
const styleLabel = $derived(style);
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
function maybeMarkCurrentRead() {
|
||||||
const ch = activeChapter;
|
const ch = store.activeChapter;
|
||||||
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
|
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
|
||||||
markedRead.add(ch.id);
|
markedRead.add(ch.id);
|
||||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
|
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (activeManga) {
|
if (store.activeManga) {
|
||||||
const updated = activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
|
const updated = store.activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
|
||||||
checkAndMarkCompleted(activeManga.id, updated);
|
checkAndMarkCompleted(store.activeManga.id, updated);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => { markedRead.delete(ch.id); console.error(e); });
|
.catch(e => { markedRead.delete(ch.id); console.error(e); });
|
||||||
@@ -153,7 +154,10 @@
|
|||||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => { if (activeChapter) loadChapter(activeChapter.id); });
|
$effect(() => {
|
||||||
|
const ch = store.activeChapter;
|
||||||
|
if (ch) untrack(() => loadChapter(ch.id));
|
||||||
|
});
|
||||||
|
|
||||||
async function loadChapter(id: number) {
|
async function loadChapter(id: number) {
|
||||||
abortCtrl?.abort();
|
abortCtrl?.abort();
|
||||||
@@ -170,15 +174,15 @@
|
|||||||
pageReady = false;
|
pageReady = false;
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
visibleChapterId = null;
|
visibleChapterId = null;
|
||||||
pageUrls = [];
|
store.pageUrls = [];
|
||||||
pageNumber = 1;
|
store.pageNumber = 1;
|
||||||
try {
|
try {
|
||||||
const urls = await fetchPages(id, ctrl.signal);
|
const urls = await fetchPages(id, ctrl.signal);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
pageUrls = urls;
|
store.pageUrls = urls;
|
||||||
pageReady = true;
|
pageReady = true;
|
||||||
if (style === "longstrip" && autoNext) {
|
if (style === "longstrip" && autoNext) {
|
||||||
stripChapters = [{ chapterId: id, chapterName: activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
|
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
|
||||||
visibleChapterId = id;
|
visibleChapterId = id;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -193,7 +197,7 @@
|
|||||||
if (appending) return;
|
if (appending) return;
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||||
if (!lastChunk) return;
|
if (!lastChunk) return;
|
||||||
const list = activeChapterList;
|
const list = store.activeChapterList;
|
||||||
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
|
||||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||||
const next = list[lastIdx + 1];
|
const next = list[lastIdx + 1];
|
||||||
@@ -239,26 +243,26 @@
|
|||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
|
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
|
||||||
if (activeLocalPage !== null) pageNumber = activeLocalPage;
|
if (activeLocalPage !== null) store.pageNumber = activeLocalPage;
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
||||||
if (settings.autoMarkRead && activeLocalPage !== null && activeChId) {
|
if (store.settings.autoMarkRead && activeLocalPage !== null && activeChId) {
|
||||||
const chunk = stripChapters.find(c => c.chapterId === activeChId);
|
const chunk = stripChapters.find(c => c.chapterId === activeChId);
|
||||||
const total = chunk ? chunk.urls.length : pageUrls.length;
|
const total = chunk ? chunk.urls.length : store.pageUrls.length;
|
||||||
if (total > 0 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
|
if (total > 0 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
|
||||||
markedRead.add(activeChId);
|
markedRead.add(activeChId);
|
||||||
const chIdSnap = activeChId;
|
const chIdSnap = activeChId;
|
||||||
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
||||||
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
|
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||||
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
|
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
|
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
|
||||||
const last = stripChapters[stripChapters.length - 1];
|
const last = stripChapters[stripChapters.length - 1];
|
||||||
if (last && settings.autoMarkRead && !markedRead.has(last.chapterId)) {
|
if (last && store.settings.autoMarkRead && !markedRead.has(last.chapterId)) {
|
||||||
markedRead.add(last.chapterId);
|
markedRead.add(last.chapterId);
|
||||||
const lastIdSnap = last.chapterId;
|
const lastIdSnap = last.chapterId;
|
||||||
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
|
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
|
||||||
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
|
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,34 +278,34 @@
|
|||||||
|
|
||||||
function advanceGroup(forward: boolean) {
|
function advanceGroup(forward: boolean) {
|
||||||
if (!pageGroups.length) return;
|
if (!pageGroups.length) return;
|
||||||
const gi = pageGroups.findIndex(g => g.includes(pageNumber));
|
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||||||
if (forward) {
|
if (forward) {
|
||||||
if (gi < pageGroups.length - 1) pageNumber = pageGroups[gi + 1][0];
|
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
|
||||||
else if (adjacent.next) { pageNumber = 1; openReader(adjacent.next, activeChapterList); }
|
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||||
else closeReader();
|
else closeReader();
|
||||||
} else {
|
} else {
|
||||||
if (gi > 0) pageNumber = pageGroups[gi - 1][0];
|
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goForward() {
|
function goForward() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } return; }
|
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
if (!pageUrls.length) return;
|
if (!store.pageUrls.length) return;
|
||||||
if (pageNumber < lastPage) { decodeImage(pageUrls[pageNumber]).then(() => pageNumber++); }
|
if (store.pageNumber < lastPage) { decodeImage(store.pageUrls[store.pageNumber]).then(() => store.pageNumber++); }
|
||||||
else if (adjacent.next) { maybeMarkCurrentRead(); pageNumber = 1; openReader(adjacent.next, activeChapterList); }
|
else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||||
else closeReader();
|
else closeReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, activeChapterList); return; }
|
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||||
if (!pageUrls.length) return;
|
if (!store.pageUrls.length) return;
|
||||||
if (pageNumber > 1) { decodeImage(pageUrls[pageNumber - 2]).then(() => pageNumber--); }
|
if (store.pageNumber > 1) { decodeImage(store.pageUrls[store.pageNumber - 2]).then(() => store.pageNumber--); }
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const goNext = $derived(rtl ? goBack : goForward);
|
const goNext = $derived(rtl ? goBack : goForward);
|
||||||
@@ -319,27 +323,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeChapter && lastPage && activeManga) {
|
if (store.activeChapter && lastPage && store.activeManga) {
|
||||||
addHistory({ mangaId: activeManga.id, mangaTitle: activeManga.title, thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id, chapterName: activeChapter.name, pageNumber, readAt: Date.now() });
|
const chapterId = store.activeChapter.id;
|
||||||
if (style !== "longstrip" && settings.autoMarkRead && pageNumber === lastPage) {
|
const chapterName = store.activeChapter.name;
|
||||||
if (!markedRead.has(activeChapter.id)) {
|
const mangaId = store.activeManga.id;
|
||||||
markedRead.add(activeChapter.id);
|
const mangaTitle = store.activeManga.title;
|
||||||
const chIdSnap = activeChapter.id;
|
const thumbUrl = store.activeManga.thumbnailUrl;
|
||||||
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
const pageNum = store.pageNumber;
|
||||||
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
|
const atLast = store.pageNumber === lastPage;
|
||||||
.catch(console.error);
|
untrack(() => {
|
||||||
|
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumbUrl, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
||||||
|
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) {
|
||||||
|
if (!markedRead.has(chapterId)) {
|
||||||
|
markedRead.add(chapterId);
|
||||||
|
gql(MARK_CHAPTER_READ, { id: chapterId, isRead: true })
|
||||||
|
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chapterId ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "double" && pageUrls.length) {
|
if (style === "double" && store.pageUrls.length) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const snap = pageUrls;
|
const snap = store.pageUrls;
|
||||||
Promise.all(snap.map(measureAspect)).then(aspects => {
|
Promise.all(snap.map(measureAspect)).then(aspects => {
|
||||||
if (cancelled || snap !== pageUrls) return;
|
if (cancelled || snap !== store.pageUrls) return;
|
||||||
const offset = settings.offsetDoubleSpreads;
|
const offset = store.settings.offsetDoubleSpreads;
|
||||||
const groups: number[][] = [[1]];
|
const groups: number[][] = [[1]];
|
||||||
if (offset) groups.push([2]);
|
if (offset) groups.push([2]);
|
||||||
let i = offset ? 3 : 2;
|
let i = offset ? 3 : 2;
|
||||||
@@ -355,36 +367,36 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ahead = settings.preloadPages ?? 3;
|
const ahead = store.settings.preloadPages ?? 3;
|
||||||
for (let i = 1; i <= ahead; i++) { const url = pageUrls[pageNumber - 1 + i]; if (url) decodeImage(url); }
|
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
|
||||||
const behind = pageUrls[pageNumber - 2];
|
const behind = store.pageUrls[store.pageNumber - 2];
|
||||||
if (behind) preloadImage(behind);
|
if (behind) preloadImage(behind);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeChapter && activeChapterList.length) {
|
if (store.activeChapter && store.activeChapterList.length) {
|
||||||
const idx = activeChapterList.findIndex(c => c.id === activeChapter!.id);
|
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const toPin: number[] = [activeChapter.id];
|
const toPin: number[] = [store.activeChapter.id];
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const entry = activeChapterList[idx + i];
|
const entry = store.activeChapterList[idx + i];
|
||||||
if (!entry) break;
|
if (!entry) break;
|
||||||
toPin.push(entry.id);
|
toPin.push(entry.id);
|
||||||
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {});
|
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (idx > 0) { const prev = activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
|
if (idx > 0) { const prev = store.activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
|
||||||
cacheEvict(new Set(toPin));
|
cacheEvict(new Set(toPin));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (style === "longstrip" && pageUrls.length && activeChapter) {
|
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||||
appended = new Set([activeChapter.id]);
|
appended = new Set([store.activeChapter.id]);
|
||||||
appending = false;
|
appending = false;
|
||||||
if (autoNext) {
|
if (autoNext) {
|
||||||
stripChapters = [{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls: pageUrls, startGlobalIdx: 0 }];
|
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls, startGlobalIdx: 0 }];
|
||||||
visibleChapterId = activeChapter.id;
|
visibleChapterId = store.activeChapter.id;
|
||||||
} else {
|
} else {
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
visibleChapterId = null;
|
visibleChapterId = null;
|
||||||
@@ -393,7 +405,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
|
$effect(() => { if (store.activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
@@ -404,9 +416,9 @@
|
|||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
const kb = settings.keybinds ?? DEFAULT_KEYBINDS;
|
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||||
const mW = settings.maxPageWidth ?? 900;
|
const mW = store.settings.maxPageWidth ?? 900;
|
||||||
const r = settings.readingDirection === "rtl";
|
const r = store.settings.readingDirection === "rtl";
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (zoomOpen) { zoomOpen = false; return; }
|
if (zoomOpen) { zoomOpen = false; return; }
|
||||||
@@ -419,24 +431,24 @@
|
|||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); pageNumber = 1; }
|
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); pageNumber = lastPage; }
|
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
else if (matchesKeybind(e, kb.chapterRight)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const list = activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
||||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||||||
}
|
}
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const list = activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
|
||||||
const prev = idx > 0 ? list[idx - 1] : null;
|
const prev = idx > 0 ? list[idx - 1] : null;
|
||||||
if (prev) openReader(prev, list);
|
if (prev) openReader(prev, list);
|
||||||
}
|
}
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); settingsOpen = true; }
|
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTap(e: MouseEvent) {
|
function handleTap(e: MouseEvent) {
|
||||||
@@ -469,31 +481,43 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const _style = style;
|
||||||
|
const _len = store.pageUrls.length;
|
||||||
|
const _auto = autoNext;
|
||||||
|
untrack(() => {
|
||||||
|
scrollCleanup?.();
|
||||||
|
scrollCleanup = setupScrollTracking();
|
||||||
|
});
|
||||||
|
return () => { scrollCleanup?.(); };
|
||||||
|
});
|
||||||
|
|
||||||
const stripToRender = $derived(style === "longstrip"
|
const stripToRender = $derived(style === "longstrip"
|
||||||
? (autoNext && stripChapters.length > 0
|
? (autoNext && stripChapters.length > 0
|
||||||
? stripChapters
|
? stripChapters
|
||||||
: [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0 }])
|
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls, startGlobalIdx: 0 }])
|
||||||
: []);
|
: []);
|
||||||
|
|
||||||
const currentGroup = $derived(style === "double" && pageGroups.length
|
const currentGroup = $derived(style === "double" && pageGroups.length
|
||||||
? (pageGroups.find(g => g.includes(pageNumber)) ?? [pageNumber])
|
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
||||||
: [pageNumber]);
|
: [store.pageNumber]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
||||||
|
|
||||||
<div class="topbar" class:hidden={!uiVisible}>
|
<div class="topbar" class:hidden={!uiVisible}>
|
||||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, activeChapterList); } }} disabled={!adjacent.prev}>
|
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
|
||||||
<CaretLeft size={14} weight="light" />
|
<CaretLeft size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<span class="ch-label">
|
<span class="ch-label">
|
||||||
<span class="ch-title">{activeManga?.title}</span>
|
<span class="ch-title">{store.activeManga?.title}</span>
|
||||||
<span class="ch-sep">/</span>
|
<span class="ch-sep">/</span>
|
||||||
<span>{displayChapter?.name}</span>
|
<span>{displayChapter?.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="page-label">{pageNumber} / {visibleChunkLastPage || "…"}</span>
|
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||||
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } }} disabled={!adjacent.next}>
|
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
|
||||||
<CaretRight size={14} weight="light" />
|
<CaretRight size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<div class="top-sep"></div>
|
<div class="top-sep"></div>
|
||||||
@@ -522,7 +546,7 @@
|
|||||||
<span class="mode-label">{styleLabel}</span>
|
<span class="mode-label">{styleLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if style !== "single"}
|
{#if style !== "single"}
|
||||||
<button class="mode-btn" class:active={settings.pageGap} onclick={() => updateSettings({ pageGap: !settings.pageGap })}>
|
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||||||
<span class="mode-label">Gap</span>
|
<span class="mode-label">Gap</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -562,7 +586,7 @@
|
|||||||
{#if style === "longstrip"}
|
{#if style === "longstrip"}
|
||||||
{#each stripToRender as chunk}
|
{#each stripToRender as chunk}
|
||||||
{#each chunk.urls as url, i}
|
{#each chunk.urls as url, i}
|
||||||
<img src={url} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
|
<img src={url} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
|
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
|
||||||
@@ -570,33 +594,33 @@
|
|||||||
{#if style === "double" && pageGroups.length}
|
{#if style === "double" && pageGroups.length}
|
||||||
<div class="double-wrap">
|
<div class="double-wrap">
|
||||||
{#each currentGroup as pg}
|
{#each currentGroup as pg}
|
||||||
<img src={pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
|
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<img src={pageUrls[pageNumber - 1]} alt="Page {pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
|
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
<div class="bottombar" class:hidden={!uiVisible}>
|
||||||
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (pageNumber === 1 && !adjacent.prev))}>
|
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (pageNumber === lastPage && !adjacent.next))}>
|
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
|
||||||
<ArrowRight size={13} weight="light" />
|
<ArrowRight size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if dlOpen && activeChapter}
|
{#if dlOpen && store.activeChapter}
|
||||||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
||||||
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
|
||||||
<div class="dl-modal" role="presentation" onclick|stopPropagation>
|
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
<p class="dl-title">Download</p>
|
<p class="dl-title">Download</p>
|
||||||
<button class="dl-option" disabled={dlBusy || !!activeChapter.isDownloaded}
|
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
|
||||||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: activeChapter!.id }))}>
|
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
||||||
This chapter
|
This chapter
|
||||||
<span class="dl-sub">{activeChapter.isDownloaded ? "Already downloaded" : activeChapter.name}</span>
|
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
|
||||||
@@ -604,7 +628,7 @@
|
|||||||
Next chapters
|
Next chapters
|
||||||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dl-stepper" role="presentation" onclick|stopPropagation>
|
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
||||||
<span class="dl-step-val">{nextN}</span>
|
<span class="dl-step-val">{nextN}</span>
|
||||||
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { gql } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||||
import { settings, settingsOpen, history, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData } from "../../store";
|
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
|
||||||
import { cache } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode, Theme } from "../../store";
|
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||||
import type { Keybinds } from "../../lib/keybinds";
|
import type { Keybinds } from "../../lib/keybinds";
|
||||||
|
|
||||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||||
@@ -34,19 +34,20 @@
|
|||||||
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
let tab: Tab = "general";
|
let tab: Tab = $state("general");
|
||||||
let contentBodyEl: HTMLDivElement;
|
let contentBodyEl: HTMLDivElement;
|
||||||
|
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
|
||||||
|
|
||||||
$: { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); }
|
function close() { setSettingsOpen(false); }
|
||||||
|
|
||||||
function close() { settingsOpen.set(false); }
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
|
||||||
onMount(() => window.addEventListener("keydown", onKey));
|
$effect(() => {
|
||||||
onDestroy(() => window.removeEventListener("keydown", onKey));
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
let listeningKey: keyof Keybinds | null = null;
|
let listeningKey: keyof Keybinds | null = $state(null);
|
||||||
|
|
||||||
function startListen(key: keyof Keybinds) {
|
function startListen(key: keyof Keybinds) {
|
||||||
listeningKey = listeningKey === key ? null : key;
|
listeningKey = listeningKey === key ? null : key;
|
||||||
@@ -57,23 +58,24 @@
|
|||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
const bind = eventToKeybind(e);
|
const bind = eventToKeybind(e);
|
||||||
if (!bind) return;
|
if (!bind) return;
|
||||||
updateSettings({ keybinds: { ...$settings.keybinds, [listeningKey]: bind } });
|
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
|
||||||
listeningKey = null;
|
listeningKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (listeningKey) {
|
$effect(() => {
|
||||||
window.addEventListener("keydown", onKeyCapture, true);
|
if (listeningKey) {
|
||||||
} else {
|
window.addEventListener("keydown", onKeyCapture, true);
|
||||||
window.removeEventListener("keydown", onKeyCapture, true);
|
return () => window.removeEventListener("keydown", onKeyCapture, true);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||||
let storageInfo: StorageInfo | null = null;
|
let storageInfo: StorageInfo | null = $state(null);
|
||||||
let storageLoading = false;
|
let storageLoading = $state(false);
|
||||||
let storageError: string | null = null;
|
let storageError: string | null = $state(null);
|
||||||
let clearing = false;
|
let clearing = $state(false);
|
||||||
let cleared = false;
|
let cleared = $state(false);
|
||||||
|
|
||||||
async function fetchStorage() {
|
async function fetchStorage() {
|
||||||
storageLoading = true; storageError = null;
|
storageLoading = true; storageError = null;
|
||||||
@@ -83,8 +85,7 @@
|
|||||||
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
|
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
|
||||||
finally { storageLoading = false; }
|
finally { storageLoading = false; }
|
||||||
}
|
}
|
||||||
|
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
|
||||||
$: if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage();
|
|
||||||
|
|
||||||
function handleClearCache() {
|
function handleClearCache() {
|
||||||
clearing = true;
|
clearing = true;
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
newestEntryMs: number | null;
|
newestEntryMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let perfSnapshot: PerfSnapshot | null = null;
|
let perfSnapshot: PerfSnapshot | null = $state(null);
|
||||||
|
|
||||||
function refreshPerfMetrics() {
|
function refreshPerfMetrics() {
|
||||||
// cache.list() isn't exported, but we can probe known keys to build a snapshot
|
// cache.list() isn't exported, but we can probe known keys to build a snapshot
|
||||||
@@ -139,8 +140,7 @@
|
|||||||
|
|
||||||
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
|
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
|
||||||
}
|
}
|
||||||
|
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
|
||||||
$: if (tab === "performance") refreshPerfMetrics();
|
|
||||||
|
|
||||||
function fmtAge(ts: number | null): string {
|
function fmtAge(ts: number | null): string {
|
||||||
if (ts === null) return "—";
|
if (ts === null) return "—";
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Storage limit input state
|
// Storage limit input state
|
||||||
let storageLimitInput = String($settings.storageLimitGb ?? "");
|
let storageLimitInput = $state(String(store.settings.storageLimitGb ?? ""));
|
||||||
|
|
||||||
function applyStorageLimit() {
|
function applyStorageLimit() {
|
||||||
const v = storageLimitInput.trim();
|
const v = storageLimitInput.trim();
|
||||||
@@ -162,9 +162,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let newFolderName = "";
|
let newFolderName = $state("");
|
||||||
let editingId: string | null = null;
|
let editingId: string | null = $state(null);
|
||||||
let editingName = "";
|
let editingName = $state("");
|
||||||
|
|
||||||
function createFolder() {
|
function createFolder() {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let selectOpen: string | null = null;
|
let selectOpen: string | null = $state(null);
|
||||||
|
|
||||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
||||||
|
|
||||||
@@ -188,11 +188,13 @@
|
|||||||
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
|
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => document.addEventListener("mousedown", onSelectOutside));
|
$effect(() => {
|
||||||
onDestroy(() => document.removeEventListener("mousedown", onSelectOutside));
|
document.addEventListener("mousedown", onSelectOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onSelectOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
let splashTriggered = false;
|
let splashTriggered = $state(false);
|
||||||
function triggerSplash() {
|
function triggerSplash() {
|
||||||
splashTriggered = true;
|
splashTriggered = true;
|
||||||
setTimeout(() => splashTriggered = false, 200);
|
setTimeout(() => splashTriggered = false, 200);
|
||||||
@@ -200,14 +202,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}>
|
<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="Settings">
|
<div class="modal" role="dialog" aria-label="Settings">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<p class="modal-title">Settings</p>
|
<p class="modal-title">Settings</p>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
{#each TABS as t}
|
{#each TABS as t}
|
||||||
<button class="nav-item" class:active={tab === t.id} on:click={() => tab = t.id}>
|
<button class="nav-item" class:active={tab === t.id} onclick={() => tab = t.id}>
|
||||||
<svelte:component this={t.icon} size={14} weight="light" />
|
<t.icon size={14} weight="light" />
|
||||||
<span>{t.label}</span>
|
<span>{t.label}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -217,7 +219,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
||||||
<button class="close-btn" aria-label="Close settings" on:click={close}><X size={15} weight="light" /></button>
|
<button class="close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-body" bind:this={contentBodyEl}>
|
<div class="content-body" bind:this={contentBodyEl}>
|
||||||
@@ -228,14 +230,14 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Interface Scale</p>
|
<p class="section-title">Interface Scale</p>
|
||||||
<div class="scale-row">
|
<div class="scale-row">
|
||||||
<input type="range" min={70} max={150} step={5} value={$settings.uiScale}
|
<input type="range" min={70} max={150} step={5} value={store.settings.uiScale}
|
||||||
on:input={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
|
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
|
||||||
<span class="scale-val">{$settings.uiScale}%</span>
|
<span class="scale-val">{store.settings.uiScale}%</span>
|
||||||
<button class="step-btn" on:click={() => updateSettings({ uiScale: 100 })} disabled={$settings.uiScale === 100} title="Reset">↺</button>
|
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset">↺</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="scale-hint">
|
<p class="scale-hint">
|
||||||
{#each [70,80,90,100,110,125,150] as v}
|
{#each [70,80,90,100,110,125,150] as v}
|
||||||
<button class="scale-preset" class:active={$settings.uiScale === v} on:click={() => updateSettings({ uiScale: v })}>{v}%</button>
|
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,15 +245,15 @@
|
|||||||
<p class="section-title">Server</p>
|
<p class="section-title">Server</p>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
|
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
|
||||||
<input class="text-input" value={$settings.serverUrl ?? "http://localhost:4567"} on:input={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
<input class="text-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
||||||
</div>
|
</div>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Server binary</span><span class="toggle-desc">Path or command to launch tachidesk-server</span></div>
|
<div class="toggle-info"><span class="toggle-label">Server binary</span><span class="toggle-desc">Path or command to launch tachidesk-server</span></div>
|
||||||
<input class="text-input" value={$settings.serverBinary} on:input={(e) => updateSettings({ serverBinary: e.currentTarget.value })} placeholder="tachidesk-server" spellcheck="false" />
|
<input class="text-input" value={store.settings.serverBinary} oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })} placeholder="tachidesk-server" spellcheck="false" />
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={$settings.autoStartServer} on:click={() => updateSettings({ autoStartServer: !$settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -259,14 +261,14 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
|
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
|
||||||
<div class="select-wrap" id="idle-timeout">
|
<div class="select-wrap" id="idle-timeout">
|
||||||
<button class="select-btn" on:click={() => toggleSelect("idle-timeout")}>
|
<button class="select-btn" onclick={() => toggleSelect("idle-timeout")}>
|
||||||
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String($settings.idleTimeoutMin ?? 5)] ?? `${$settings.idleTimeoutMin} min`}</span>
|
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
|
||||||
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === "idle-timeout"}
|
{#if selectOpen === "idle-timeout"}
|
||||||
<div class="select-menu">
|
<div class="select-menu">
|
||||||
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
|
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
|
||||||
<button class="select-option" class:active={String($settings.idleTimeoutMin ?? 5) === v} on:click={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
|
<button class="select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -282,8 +284,8 @@
|
|||||||
<p class="section-title">Theme</p>
|
<p class="section-title">Theme</p>
|
||||||
<div class="theme-grid">
|
<div class="theme-grid">
|
||||||
{#each THEMES as theme}
|
{#each THEMES as theme}
|
||||||
{@const active = ($settings.theme ?? "dark") === theme.id}
|
{@const active = (store.settings.theme ?? "dark") === theme.id}
|
||||||
<button class="theme-card" class:active on:click={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
<button class="theme-card" class:active onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
||||||
<div class="theme-preview">
|
<div class="theme-preview">
|
||||||
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
|
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
|
||||||
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
||||||
@@ -313,14 +315,14 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
|
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
|
||||||
<div class="select-wrap" id="page-style">
|
<div class="select-wrap" id="page-style">
|
||||||
<button class="select-btn" on:click={() => toggleSelect("page-style")}>
|
<button class="select-btn" onclick={() => toggleSelect("page-style")}>
|
||||||
<span>{{ "single":"Single page","longstrip":"Long strip" }[$settings.pageStyle === "double" ? "single" : $settings.pageStyle]}</span>
|
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
|
||||||
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === "page-style"}
|
{#if selectOpen === "page-style"}
|
||||||
<div class="select-menu">
|
<div class="select-menu">
|
||||||
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
||||||
<button class="select-option" class:active={($settings.pageStyle === "double" ? "single" : $settings.pageStyle) === v} on:click={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
|
<button class="select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -329,14 +331,14 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
||||||
<div class="select-wrap" id="reading-dir">
|
<div class="select-wrap" id="reading-dir">
|
||||||
<button class="select-btn" on:click={() => toggleSelect("reading-dir")}>
|
<button class="select-btn" onclick={() => toggleSelect("reading-dir")}>
|
||||||
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[$settings.readingDirection]}</span>
|
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
|
||||||
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === "reading-dir"}
|
{#if selectOpen === "reading-dir"}
|
||||||
<div class="select-menu">
|
<div class="select-menu">
|
||||||
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
||||||
<button class="select-option" class:active={$settings.readingDirection === v} on:click={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
|
<button class="select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -344,7 +346,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
||||||
<button role="switch" aria-checked={$settings.pageGap} aria-label="Page gap" class="toggle" class:on={$settings.pageGap} on:click={() => updateSettings({ pageGap: !$settings.pageGap })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -352,14 +354,14 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
|
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
|
||||||
<div class="select-wrap" id="fit-mode">
|
<div class="select-wrap" id="fit-mode">
|
||||||
<button class="select-btn" on:click={() => toggleSelect("fit-mode")}>
|
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
|
||||||
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[$settings.fitMode ?? "width"]}</span>
|
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
|
||||||
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === "fit-mode"}
|
{#if selectOpen === "fit-mode"}
|
||||||
<div class="select-menu">
|
<div class="select-menu">
|
||||||
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
||||||
<button class="select-option" class:active={($settings.fitMode ?? "width") === v} on:click={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
|
<button class="select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -368,38 +370,38 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
|
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
|
||||||
<div class="step-controls">
|
<div class="step-controls">
|
||||||
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.max(200, ($settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
||||||
<span class="step-val">{$settings.maxPageWidth ?? 900}px</span>
|
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span>
|
||||||
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.min(2400, ($settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
||||||
<button role="switch" aria-checked={$settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={$settings.optimizeContrast} on:click={() => updateSettings({ optimizeContrast: !$settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Behaviour</p>
|
<p class="section-title">Behaviour</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={$settings.autoMarkRead} on:click={() => updateSettings({ autoMarkRead: !$settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={$settings.autoNextChapter} on:click={() => updateSettings({ autoNextChapter: !($settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
{#if !($settings.autoNextChapter ?? false)}
|
{#if !(store.settings.autoNextChapter ?? false)}
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
||||||
<button role="switch" aria-checked={$settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={$settings.markReadOnNext ?? true} on:click={() => updateSettings({ markReadOnNext: !($settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
|
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
|
||||||
<div class="step-controls">
|
<div class="step-controls">
|
||||||
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.max(0, $settings.preloadPages - 1) })} disabled={$settings.preloadPages <= 0}>−</button>
|
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}>−</button>
|
||||||
<span class="step-val">{$settings.preloadPages}</span>
|
<span class="step-val">{store.settings.preloadPages}</span>
|
||||||
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.min(10, $settings.preloadPages + 1) })} disabled={$settings.preloadPages >= 10}>+</button>
|
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,11 +414,11 @@
|
|||||||
<p class="section-title">Display</p>
|
<p class="section-title">Display</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
||||||
<button role="switch" aria-checked={$settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={$settings.libraryCropCovers} on:click={() => updateSettings({ libraryCropCovers: !$settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
||||||
<button role="switch" aria-checked={$settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={$settings.showNsfw} on:click={() => updateSettings({ showNsfw: !$settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -424,14 +426,14 @@
|
|||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
|
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
|
||||||
<div class="select-wrap" id="sort-dir">
|
<div class="select-wrap" id="sort-dir">
|
||||||
<button class="select-btn" on:click={() => toggleSelect("sort-dir")}>
|
<button class="select-btn" onclick={() => toggleSelect("sort-dir")}>
|
||||||
<span>{{ "desc":"Newest first","asc":"Oldest first" }[$settings.chapterSortDir]}</span>
|
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
|
||||||
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === "sort-dir"}
|
{#if selectOpen === "sort-dir"}
|
||||||
<div class="select-menu">
|
<div class="select-menu">
|
||||||
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
||||||
<button class="select-option" class:active={$settings.chapterSortDir === v} on:click={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
|
<button class="select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -441,15 +443,15 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">History</p>
|
<p class="section-title">History</p>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{$history.length} entries stored</span></div>
|
<div class="toggle-info"><span class="toggle-label">Reading store.history</span><span class="toggle-desc">{store.history.length} entries stored</span></div>
|
||||||
<button class="danger-btn" on:click={clearHistory} disabled={$history.length === 0}>Clear activity</button>
|
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info">
|
<div class="toggle-info">
|
||||||
<span class="toggle-label">Full data cleanse</span>
|
<span class="toggle-label">Full data cleanse</span>
|
||||||
<span class="toggle-desc">Removes history, stats, completed list, hero pins, and manga links</span>
|
<span class="toggle-desc">Removes store.history, stats, completed list, hero pins, and manga links</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="danger-btn" on:click={wipeAllData}>Wipe all data</button>
|
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,14 +468,14 @@
|
|||||||
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
|
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-controls">
|
<div class="step-controls">
|
||||||
<button class="step-btn" on:click={() => updateSettings({ renderLimit: Math.max(12, ($settings.renderLimit ?? 48) - 12) })} disabled={($settings.renderLimit ?? 48) <= 12}>−</button>
|
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}>−</button>
|
||||||
<span class="step-val">{$settings.renderLimit ?? 48}</span>
|
<span class="step-val">{store.settings.renderLimit ?? 48}</span>
|
||||||
<button class="step-btn" on:click={() => updateSettings({ renderLimit: Math.min(200, ($settings.renderLimit ?? 48) + 12) })} disabled={($settings.renderLimit ?? 48) >= 200}>+</button>
|
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="scale-hint">
|
<p class="scale-hint">
|
||||||
{#each [12, 24, 48, 96, 200] as v}
|
{#each [12, 24, 48, 96, 200] as v}
|
||||||
<button class="scale-preset" class:active={($settings.renderLimit ?? 48) === v} on:click={() => updateSettings({ renderLimit: v })}>{v}</button>
|
<button class="scale-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +484,7 @@
|
|||||||
<p class="section-title">Rendering</p>
|
<p class="section-title">Rendering</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
||||||
<button role="switch" aria-checked={$settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={$settings.gpuAcceleration} on:click={() => updateSettings({ gpuAcceleration: !$settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -490,7 +492,7 @@
|
|||||||
<p class="section-title">Idle / Splash Screen</p>
|
<p class="section-title">Idle / Splash Screen</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
||||||
<button role="switch" aria-checked={$settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={$settings.splashCards ?? true} on:click={() => updateSettings({ splashCards: !($settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -498,7 +500,7 @@
|
|||||||
<p class="section-title">Interface</p>
|
<p class="section-title">Interface</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
||||||
<button role="switch" aria-checked={$settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={$settings.compactSidebar} on:click={() => updateSettings({ compactSidebar: !$settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -511,7 +513,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="perf-stat-group">
|
<div class="perf-stat-group">
|
||||||
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
|
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
|
||||||
<button class="kb-reset" on:click={refreshPerfMetrics} title="Refresh">↺</button>
|
<button class="kb-reset" onclick={refreshPerfMetrics} title="Refresh">↺</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
||||||
@@ -540,21 +542,21 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="kb-header">
|
<div class="kb-header">
|
||||||
<p class="section-title">Keyboard shortcuts</p>
|
<p class="section-title">Keyboard shortcuts</p>
|
||||||
<button class="reset-all-btn" on:click={resetKeybinds}>Reset all</button>
|
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
|
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
|
||||||
<div class="kb-list">
|
<div class="kb-list">
|
||||||
{#each Object.keys(KEYBIND_LABELS) as key}
|
{#each Object.keys(KEYBIND_LABELS) as key}
|
||||||
{@const k = key as keyof Keybinds}
|
{@const k = key as keyof Keybinds}
|
||||||
{@const isListening = listeningKey === k}
|
{@const isListening = listeningKey === k}
|
||||||
{@const isDefault = $settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
|
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
|
||||||
<div class="kb-row">
|
<div class="kb-row">
|
||||||
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
|
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
|
||||||
<div class="kb-right">
|
<div class="kb-right">
|
||||||
<button class="kb-bind" class:listening={isListening} on:click={() => startListen(k)}>
|
<button class="kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
|
||||||
{isListening ? "Press key…" : $settings.keybinds[k]}
|
{isListening ? "Press key…" : store.settings.keybinds[k]}
|
||||||
</button>
|
</button>
|
||||||
<button class="kb-reset" on:click={() => updateSettings({ keybinds: { ...$settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset">↺</button>
|
<button class="kb-reset" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset">↺</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -573,7 +575,7 @@
|
|||||||
{@const mangaBytes = storageInfo.manga_bytes}
|
{@const mangaBytes = storageInfo.manga_bytes}
|
||||||
{@const totalBytes = storageInfo.total_bytes}
|
{@const totalBytes = storageInfo.total_bytes}
|
||||||
{@const freeBytes = storageInfo.free_bytes}
|
{@const freeBytes = storageInfo.free_bytes}
|
||||||
{@const limitGb = $settings.storageLimitGb ?? null}
|
{@const limitGb = store.settings.storageLimitGb ?? null}
|
||||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||||
{@const available = mangaBytes + freeBytes}
|
{@const available = mangaBytes + freeBytes}
|
||||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||||
@@ -599,7 +601,7 @@
|
|||||||
<p class="section-title">Cache</p>
|
<p class="section-title">Cache</p>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
|
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
|
||||||
<button class="danger-btn" on:click={handleClearCache} disabled={clearing}>
|
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
|
||||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -610,35 +612,35 @@
|
|||||||
<div class="toggle-info">
|
<div class="toggle-info">
|
||||||
<span class="toggle-label">Limit download storage</span>
|
<span class="toggle-label">Limit download storage</span>
|
||||||
<span class="toggle-desc">
|
<span class="toggle-desc">
|
||||||
{$settings.storageLimitGb === null
|
{store.settings.storageLimitGb === null
|
||||||
? "No limit — uses full drive capacity"
|
? "No limit — uses full drive capacity"
|
||||||
: `Warn when downloads exceed ${$settings.storageLimitGb} GB`}
|
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if $settings.storageLimitGb === null}
|
{#if store.settings.storageLimitGb === null}
|
||||||
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
||||||
on:click={() => updateSettings({ storageLimitGb: 10 })}>
|
onclick={() => updateSettings({ storageLimitGb: 10 })}>
|
||||||
Set limit
|
Set limit
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="step-controls">
|
<div class="step-controls">
|
||||||
<button class="step-btn"
|
<button class="step-btn"
|
||||||
on:click={() => updateSettings({ storageLimitGb: Math.max(1, ($settings.storageLimitGb ?? 10) - 1) })}
|
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })}
|
||||||
disabled={($settings.storageLimitGb ?? 10) <= 1}>−</button>
|
disabled={(store.settings.storageLimitGb ?? 10) <= 1}>−</button>
|
||||||
<input
|
<input
|
||||||
type="number" min="1" step="1"
|
type="number" min="1" step="1"
|
||||||
class="storage-limit-input"
|
class="storage-limit-input"
|
||||||
value={$settings.storageLimitGb}
|
value={store.settings.storageLimitGb}
|
||||||
on:input={(e) => {
|
oninput={(e) => {
|
||||||
const n = parseFloat(e.currentTarget.value);
|
const n = parseFloat(e.currentTarget.value);
|
||||||
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
|
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span class="storage-limit-unit">GB</span>
|
<span class="storage-limit-unit">GB</span>
|
||||||
<button class="step-btn"
|
<button class="step-btn"
|
||||||
on:click={() => updateSettings({ storageLimitGb: ($settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
||||||
<button class="kb-reset" title="Remove limit"
|
<button class="kb-reset" title="Remove limit"
|
||||||
on:click={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -653,31 +655,31 @@
|
|||||||
<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">Assign manga to folders from the series detail page.</p>
|
||||||
<div class="folder-create-row">
|
<div class="folder-create-row">
|
||||||
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
|
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
|
||||||
on:keydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
|
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
|
||||||
<button class="folder-create-btn" on:click={createFolder} disabled={!newFolderName.trim()}>
|
<button class="folder-create-btn" onclick={createFolder} disabled={!newFolderName.trim()}>
|
||||||
<Plus size={13} weight="bold" /> Create
|
<Plus size={13} weight="bold" /> Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if $settings.folders.length === 0}
|
{#if store.settings.folders.length === 0}
|
||||||
<p class="storage-loading">No folders yet. Create one above.</p>
|
<p class="storage-loading">No folders yet. Create one above.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="folder-list">
|
<div class="folder-list">
|
||||||
{#each $settings.folders as folder}
|
{#each store.settings.folders as folder}
|
||||||
<div class="folder-row">
|
<div class="folder-row">
|
||||||
{#if editingId === folder.id}
|
{#if editingId === folder.id}
|
||||||
<input class="text-input" bind:value={editingName}
|
<input class="text-input" bind:value={editingName}
|
||||||
on:keydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
||||||
on:blur={commitEdit} style="flex:1;width:auto" use:focusInput />
|
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
|
||||||
<button class="kb-reset" on:click={commitEdit} title="Save">✓</button>
|
<button class="kb-reset" onclick={commitEdit} title="Save">✓</button>
|
||||||
{:else}
|
{:else}
|
||||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
<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-name">{folder.name}</span>
|
||||||
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
|
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
|
||||||
<button class="folder-tab-toggle" class:on={folder.showTab} on:click={() => toggleFolderTab(folder.id)}>
|
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}>
|
||||||
{folder.showTab ? "Tab on" : "Tab off"}
|
{folder.showTab ? "Tab on" : "Tab off"}
|
||||||
</button>
|
</button>
|
||||||
<button class="kb-reset" on:click={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></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" on:click={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -705,7 +707,7 @@
|
|||||||
<p class="section-title">Splash Screen</p>
|
<p class="section-title">Splash Screen</p>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
|
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
|
||||||
<button class="danger-btn" on:click={triggerSplash}
|
<button class="danger-btn" onclick={triggerSplash}
|
||||||
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
|
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
|
||||||
Show idle
|
Show idle
|
||||||
</button>
|
</button>
|
||||||
@@ -725,7 +727,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script context="module">
|
<script module>
|
||||||
function focusInput(node: HTMLElement) { node.focus(); }
|
function focusInput(node: HTMLElement) { node.focus(); }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,13 @@
|
|||||||
let focused = $state(-1);
|
let focused = $state(-1);
|
||||||
let el = $state<HTMLDivElement | undefined>(undefined);
|
let el = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
|
||||||
const actionable = items
|
const actionable = $derived(
|
||||||
.map((_, i) => i)
|
items
|
||||||
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled);
|
.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 pos = $derived.by(() => {
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
|
||||||
@@ -37,8 +41,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (actionable.length) focused = actionable[0];
|
|
||||||
|
|
||||||
function onMouseDown(e: MouseEvent) {
|
function onMouseDown(e: MouseEvent) {
|
||||||
if (el && !el.contains(e.target as Node)) onClose();
|
if (el && !el.contains(e.target as Node)) onClose();
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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()}>
|
oncontextmenu={(e) => e.preventDefault()}>
|
||||||
{#each items as item, i}
|
{#each items as item, i}
|
||||||
{#if "separator" in item}
|
{#if "separator" in item}
|
||||||
|
|||||||
@@ -1,54 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
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 } from "../../lib/queries";
|
||||||
import { GET_ALL_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
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";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
let manga: Manga | null = $state(null);
|
let manga: Manga | null = $state(null);
|
||||||
let chapters: Chapter[] = $state([]);
|
let chapters: Chapter[] = $state([]);
|
||||||
let loadingDetail = $state(false);
|
let loadingDetail = $state(false);
|
||||||
let loadingChapters = $state(false);
|
let loadingChapters = $state(false);
|
||||||
let togglingLib = $state(false);
|
let togglingLib = $state(false);
|
||||||
let descExpanded = $state(false);
|
let descExpanded = $state(false);
|
||||||
let folderOpen = $state(false);
|
let folderOpen = $state(false);
|
||||||
let newFolderName = $state("");
|
let newFolderName = $state("");
|
||||||
let creatingFolder = $state(false);
|
let creatingFolder = $state(false);
|
||||||
let queueingAll = $state(false);
|
let queueingAll = $state(false);
|
||||||
let fetchError: string | null = $state(null);
|
let fetchError: string|null = $state(null);
|
||||||
let folderRef = $state<HTMLDivElement | undefined>(undefined);
|
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||||
|
|
||||||
let linkPickerOpen = $state(false);
|
let linkPickerOpen = $state(false);
|
||||||
let linkSearch = $state("");
|
let linkSearch = $state("");
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
let loadingLinkList = $state(false);
|
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 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 q = linkSearch.trim().toLowerCase();
|
||||||
const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others;
|
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||||
const linked = filtered.filter((m) => linkedIds.includes(m.id));
|
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||||
const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30);
|
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||||
return [...linked, ...rest];
|
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 totalCount = $derived(chapters.length);
|
||||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
||||||
const unreadCount = $derived(totalCount - readCount);
|
const unreadCount = $derived(totalCount - readCount);
|
||||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
||||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).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 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 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 firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...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 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(() => {
|
const continueChapter = $derived.by(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
@@ -59,61 +90,19 @@
|
|||||||
return { ch: chapters[0], label: "Read again" };
|
return { ch: chapters[0], label: "Read again" };
|
||||||
});
|
});
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(id: number) {
|
async function load(id: number) {
|
||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort(); chapterAbort?.abort();
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
detailAbort = dCtrl; chapterAbort = cCtrl;
|
||||||
manga = previewManga as Manga;
|
manga = store.previewManga as Manga;
|
||||||
chapters = []; descExpanded = false; fetchError = null;
|
chapters = []; descExpanded = false; fetchError = null;
|
||||||
loadingDetail = true; loadingChapters = true;
|
loadingDetail = true; loadingChapters = true;
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
(async (): Promise<Manga> => {
|
||||||
const key = CACHE_KEYS.MANGA(id);
|
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 {
|
try {
|
||||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
||||||
return d.fetchManga.manga;
|
return d.fetchManga.manga;
|
||||||
@@ -129,8 +118,8 @@
|
|||||||
manga = fullManga; loadingDetail = false;
|
manga = fullManga; loadingDetail = false;
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
if (e?.name === "AbortError") return;
|
if (e?.name === "AbortError") return;
|
||||||
manga = previewManga as Manga;
|
manga = store.previewManga as Manga;
|
||||||
fetchError = "Could not load full details — showing cached data";
|
fetchError = "Could not load full details — showing cached data";
|
||||||
loadingDetail = false;
|
loadingDetail = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +145,7 @@
|
|||||||
async function toggleLibrary() {
|
async function toggleLibrary() {
|
||||||
if (!manga) return;
|
if (!manga) return;
|
||||||
togglingLib = true;
|
togglingLib = true;
|
||||||
const next = !manga.inLibrary;
|
const next = !manga.inLibrary;
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||||
manga = { ...manga, inLibrary: next };
|
manga = { ...manga, inLibrary: next };
|
||||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||||
@@ -177,79 +166,58 @@
|
|||||||
|
|
||||||
function openSeriesDetail() {
|
function openSeriesDetail() {
|
||||||
if (!displayManga) return;
|
if (!displayManga) return;
|
||||||
activeManga = displayManga;
|
setActiveManga(displayManga);
|
||||||
navPage = "library";
|
setNavPage("library");
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderCreate() {
|
function handleFolderCreate() {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
if (!name || !previewManga) return;
|
if (!name || !store.previewManga) return;
|
||||||
const id = addFolder(name);
|
const id = addFolder(name);
|
||||||
assignMangaToFolder(id, previewManga.id);
|
assignMangaToFolder(id, store.previewManga.id);
|
||||||
newFolderName = ""; creatingFolder = false;
|
newFolderName = ""; creatingFolder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderOutside(e: MouseEvent) {
|
function handleFolderOutside(e: MouseEvent) {
|
||||||
if (folderRef && !folderRef.contains(e.target as Node)) {
|
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
||||||
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(); }
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
||||||
|
onMount(() => window.addEventListener("keydown", onKey));
|
||||||
async function openLinkPicker() {
|
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if previewManga}
|
{#if store.previewManga}
|
||||||
<div class="backdrop" role="presentation"
|
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
|
|
||||||
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
|
|
||||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
<div class="modal" role="dialog" aria-label="Manga preview">
|
||||||
|
|
||||||
<!-- Cover column -->
|
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<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}
|
{#if loadingDetail}
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-actions">
|
<div class="cover-actions">
|
||||||
|
|
||||||
<!-- Library -->
|
|
||||||
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
<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-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
||||||
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Series Detail -->
|
|
||||||
<button class="action-btn" onclick={openSeriesDetail}>
|
<button class="action-btn" onclick={openSeriesDetail}>
|
||||||
<span class="action-icon"><Books size={13} weight="light" /></span>
|
<span class="action-icon"><Books size={13} weight="light" /></span>
|
||||||
<span class="action-label">Series Detail</span>
|
<span class="action-label">Series Detail</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Folders -->
|
|
||||||
<div class="folder-wrap" bind:this={folderRef}>
|
<div class="folder-wrap" bind:this={folderRef}>
|
||||||
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
|
<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>
|
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
||||||
@@ -257,11 +225,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if folderOpen}
|
{#if folderOpen}
|
||||||
<div class="folder-menu">
|
<div class="folder-menu">
|
||||||
{#if settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
||||||
{#each settings.folders as f}
|
{#each store.settings.folders as f}
|
||||||
{@const isIn = previewManga ? f.mangaIds.includes(previewManga.id) : false}
|
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
|
||||||
<button class="folder-item" class:folder-item-on={isIn}
|
<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}
|
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -269,8 +237,8 @@
|
|||||||
{#if creatingFolder}
|
{#if creatingFolder}
|
||||||
<div class="folder-create-row">
|
<div class="folder-create-row">
|
||||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
<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>
|
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -280,7 +248,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series Link -->
|
|
||||||
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
|
<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-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>
|
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
||||||
@@ -289,7 +256,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content column -->
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
@@ -306,7 +272,6 @@
|
|||||||
<div class="content-body">
|
<div class="content-body">
|
||||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
||||||
|
|
||||||
<!-- Badges -->
|
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -319,7 +284,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Chapter box -->
|
|
||||||
<div class="chapter-box">
|
<div class="chapter-box">
|
||||||
{#if loadingChapters}
|
{#if loadingChapters}
|
||||||
<div class="chapter-loading">
|
<div class="chapter-loading">
|
||||||
@@ -351,7 +315,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="sk-desc">
|
<div class="sk-desc">
|
||||||
<div class="sk-line" style="width:100%"></div>
|
<div class="sk-line" style="width:100%"></div>
|
||||||
@@ -370,16 +333,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Genres -->
|
|
||||||
{#if !loadingDetail && displayManga?.genre?.length}
|
{#if !loadingDetail && displayManga?.genre?.length}
|
||||||
<div class="genres">
|
<div class="genres">
|
||||||
{#each displayManga.genre as g}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Meta table -->
|
|
||||||
{#if !loadingDetail}
|
{#if !loadingDetail}
|
||||||
<div class="meta-table">
|
<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}
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link picker modal -->
|
|
||||||
{#if linkPickerOpen}
|
{#if linkPickerOpen}
|
||||||
<div class="link-backdrop" role="presentation"
|
<div class="link-backdrop" role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||||
@@ -417,7 +377,7 @@
|
|||||||
Click a linked entry again to unlink.
|
Click a linked entry again to unlink.
|
||||||
</p>
|
</p>
|
||||||
<div class="link-search-wrap">
|
<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>
|
||||||
<div class="link-list">
|
<div class="link-list">
|
||||||
{#if loadingLinkList}
|
{#if loadingLinkList}
|
||||||
@@ -444,6 +404,10 @@
|
|||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<script module>
|
||||||
|
function focusAction(node: HTMLElement) { node.focus(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<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); }
|
.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); }
|
.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 { 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-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); }
|
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.action-btn {
|
.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); }
|
||||||
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: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:disabled { opacity: 0.4; cursor: default; }
|
||||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
.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-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-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); }
|
.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 pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(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 { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
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 type { Manga } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
|
||||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||||
|
|
||||||
let mangas: Manga[] = $state([]);
|
let mangas: Manga[] = [];
|
||||||
let loading = $state(true);
|
let loading = true;
|
||||||
let page = $state(1);
|
let page = 1;
|
||||||
let hasNextPage = $state(false);
|
let hasNextPage = false;
|
||||||
let browseType: BrowseType = $state("POPULAR");
|
let browseType: BrowseType = "POPULAR";
|
||||||
let search = $state("");
|
let search = "";
|
||||||
let searchInput = $state("");
|
let searchInput = "";
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||||
|
|
||||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||||
if (!activeSource) return;
|
if (!$store.activeSource) return;
|
||||||
loading = true; mangas = [];
|
loading = true; mangas = [];
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
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; })
|
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||||
.catch(console.error)
|
.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() {
|
function submitSearch() {
|
||||||
search = searchInput.trim();
|
search = searchInput.trim();
|
||||||
browseType = "SEARCH";
|
browseType = "SEARCH";
|
||||||
page = 1;
|
page = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode: BrowseType) {
|
function setMode(mode: BrowseType) {
|
||||||
@@ -42,48 +42,36 @@
|
|||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
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 })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => { mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
|
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||||
.catch(console.error),
|
.catch(console.error) },
|
||||||
},
|
...($store.settings.folders.length > 0 ? [
|
||||||
...(settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
...settings.folders.map((f): MenuEntry => ({
|
...$store.settings.folders.map((f): MenuEntry => ({
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||||
icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||||
})),
|
})),
|
||||||
] : []),
|
] : []),
|
||||||
{ separator: true },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
{#if activeSource}
|
{#if $store.activeSource}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="header">
|
<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>
|
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="source-name">{activeSource.displayName}</span>
|
<span class="source-name">{$store.activeSource.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
{#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()}
|
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -92,7 +80,7 @@
|
|||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||||
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
|
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,9 +95,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each mangas as m (m.id)}
|
{#each mangas as m (m.id)}
|
||||||
<button class="card"
|
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
|
||||||
onclick={() => { activeManga = m; navPage = "library"; }}
|
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||||
@@ -122,11 +109,11 @@
|
|||||||
|
|
||||||
{#if !loading && (page > 1 || hasNextPage)}
|
{#if !loading && (page > 1 || hasNextPage)}
|
||||||
<div class="pagination">
|
<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
|
<Prev size={13} weight="light" /> Prev
|
||||||
</button>
|
</button>
|
||||||
<span class="page-num">{page}</span>
|
<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" />
|
Next <Next size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
import { GET_SOURCES } from "../../lib/queries";
|
||||||
import { activeSource } from "../../store";
|
import { store } from "../../store/state.svelte";
|
||||||
import type { Source } from "../../lib/types";
|
import type { Source } from "../../lib/types";
|
||||||
|
|
||||||
let sources: Source[] = $state([]);
|
let sources: Source[] = $state([]);
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
{@const single = g.sources.length === 1}
|
{@const single = g.sources.length === 1}
|
||||||
{@const open = expanded.has(g.name)}
|
{@const open = expanded.has(g.name)}
|
||||||
<div>
|
<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"
|
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
|
||||||
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if !single && open}
|
{#if !single && open}
|
||||||
{#each g.sources as src}
|
{#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="indent-spacer"></div>
|
||||||
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
|
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
|
||||||
<span class="arrow">→</span>
|
<span class="arrow">→</span>
|
||||||
|
|||||||
+8
-4
@@ -58,7 +58,11 @@ export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleFullscreen(): Promise<void> {
|
export async function toggleFullscreen(): Promise<void> {
|
||||||
const win = getCurrentWindow();
|
try {
|
||||||
const isFs = await win.isFullscreen();
|
const win = getCurrentWindow();
|
||||||
await win.setFullscreen(!isFs);
|
const isFs = await win.isFullscreen();
|
||||||
}
|
await win.setFullscreen(!isFs);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("toggleFullscreen unavailable:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,433 +0,0 @@
|
|||||||
import type { Manga, Chapter, Source } from "../lib/types";
|
|
||||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
|
||||||
|
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
|
||||||
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
|
||||||
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
|
||||||
|
|
||||||
export const COMPLETED_FOLDER_ID = "completed";
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
pageNumber: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadingStats {
|
|
||||||
totalChaptersRead: number;
|
|
||||||
totalMangaRead: number;
|
|
||||||
totalMinutesRead: number;
|
|
||||||
firstReadAt: number;
|
|
||||||
lastReadAt: number;
|
|
||||||
currentStreakDays: number;
|
|
||||||
longestStreakDays: number;
|
|
||||||
lastStreakDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AVG_MIN_PER_CHAPTER = 5;
|
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
|
||||||
totalChaptersRead: 0,
|
|
||||||
totalMangaRead: 0,
|
|
||||||
totalMinutesRead: 0,
|
|
||||||
firstReadAt: 0,
|
|
||||||
lastReadAt: 0,
|
|
||||||
currentStreakDays: 0,
|
|
||||||
longestStreakDays: 0,
|
|
||||||
lastStreakDate: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: string;
|
|
||||||
kind: "success" | "error" | "info" | "download";
|
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActiveDownload {
|
|
||||||
chapterId: number;
|
|
||||||
mangaId: number;
|
|
||||||
progress: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Folder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mangaIds: number[];
|
|
||||||
showTab: boolean;
|
|
||||||
system?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Settings {
|
|
||||||
pageStyle: PageStyle;
|
|
||||||
readingDirection: ReadingDirection;
|
|
||||||
fitMode: FitMode;
|
|
||||||
maxPageWidth: number;
|
|
||||||
pageGap: boolean;
|
|
||||||
optimizeContrast: boolean;
|
|
||||||
offsetDoubleSpreads: boolean;
|
|
||||||
preloadPages: number;
|
|
||||||
autoMarkRead: boolean;
|
|
||||||
autoNextChapter: boolean;
|
|
||||||
libraryCropCovers: boolean;
|
|
||||||
libraryPageSize: number;
|
|
||||||
showNsfw: boolean;
|
|
||||||
chapterSortDir: ChapterSortDir;
|
|
||||||
chapterPageSize: number;
|
|
||||||
uiScale: number;
|
|
||||||
compactSidebar: boolean;
|
|
||||||
gpuAcceleration: boolean;
|
|
||||||
serverUrl: string;
|
|
||||||
serverBinary: string;
|
|
||||||
autoStartServer: boolean;
|
|
||||||
preferredExtensionLang: string;
|
|
||||||
keybinds: Keybinds;
|
|
||||||
idleTimeoutMin?: number;
|
|
||||||
splashCards?: boolean;
|
|
||||||
storageLimitGb: number | null;
|
|
||||||
folders: Folder[];
|
|
||||||
markReadOnNext: boolean;
|
|
||||||
readerDebounceMs: number;
|
|
||||||
theme: Theme;
|
|
||||||
libraryBranches: boolean;
|
|
||||||
renderLimit: number;
|
|
||||||
heroSlots: (number | null)[];
|
|
||||||
mangaLinks: Record<number, number[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
|
||||||
id: COMPLETED_FOLDER_ID,
|
|
||||||
name: "Completed",
|
|
||||||
mangaIds: [],
|
|
||||||
showTab: true,
|
|
||||||
system: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
|
||||||
pageStyle: "longstrip",
|
|
||||||
readingDirection: "ltr",
|
|
||||||
fitMode: "width",
|
|
||||||
maxPageWidth: 900,
|
|
||||||
pageGap: true,
|
|
||||||
optimizeContrast: false,
|
|
||||||
offsetDoubleSpreads: false,
|
|
||||||
preloadPages: 3,
|
|
||||||
autoMarkRead: true,
|
|
||||||
autoNextChapter: true,
|
|
||||||
libraryCropCovers: true,
|
|
||||||
libraryPageSize: 48,
|
|
||||||
showNsfw: false,
|
|
||||||
chapterSortDir: "desc",
|
|
||||||
chapterPageSize: 25,
|
|
||||||
uiScale: 100,
|
|
||||||
compactSidebar: false,
|
|
||||||
gpuAcceleration: true,
|
|
||||||
serverUrl: "http://localhost:4567",
|
|
||||||
serverBinary: "tachidesk-server",
|
|
||||||
autoStartServer: true,
|
|
||||||
preferredExtensionLang: "en",
|
|
||||||
keybinds: DEFAULT_KEYBINDS,
|
|
||||||
idleTimeoutMin: 5,
|
|
||||||
splashCards: true,
|
|
||||||
storageLimitGb: null,
|
|
||||||
folders: [COMPLETED_FOLDER_DEFAULT],
|
|
||||||
markReadOnNext: true,
|
|
||||||
readerDebounceMs: 120,
|
|
||||||
theme: "dark",
|
|
||||||
libraryBranches: true,
|
|
||||||
renderLimit: 48,
|
|
||||||
heroSlots: [null, null, null, null],
|
|
||||||
mangaLinks: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function loadPersisted(): any {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("moku-store");
|
|
||||||
return raw ? JSON.parse(raw) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persist(patch: Record<string, unknown>) {
|
|
||||||
try {
|
|
||||||
const current = loadPersisted() ?? {};
|
|
||||||
localStorage.setItem("moku-store", JSON.stringify({ ...current, ...patch }));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = loadPersisted();
|
|
||||||
|
|
||||||
function mergeSettings(saved: any): Settings {
|
|
||||||
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
|
||||||
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
|
||||||
const completedFolder: Folder = existingCompleted
|
|
||||||
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
|
||||||
: COMPLETED_FOLDER_DEFAULT;
|
|
||||||
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
|
||||||
return {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
...saved?.settings,
|
|
||||||
folders: [completedFolder, ...otherFolders],
|
|
||||||
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
|
||||||
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
|
||||||
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeStats(saved: any): ReadingStats {
|
|
||||||
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export let navPage: NavPage = $state(saved?.navPage ?? "home");
|
|
||||||
export let libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
|
||||||
export let history: HistoryEntry[] = $state(saved?.history ?? []);
|
|
||||||
export let readingStats: ReadingStats = $state(mergeStats(saved));
|
|
||||||
export let settings: Settings = $state(mergeSettings(saved));
|
|
||||||
|
|
||||||
export let genreFilter: string = $state("");
|
|
||||||
export let searchPrefill: string = $state("");
|
|
||||||
export let activeManga: Manga | null = $state(null);
|
|
||||||
export let previewManga: Manga | null = $state(null);
|
|
||||||
export let activeSource: Source | null = $state(null);
|
|
||||||
export let pageUrls: string[] = $state([]);
|
|
||||||
export let pageNumber: number = $state(1);
|
|
||||||
export let libraryTagFilter: string[] = $state([]);
|
|
||||||
export let settingsOpen: boolean = $state(false);
|
|
||||||
export let activeDownloads: ActiveDownload[] = $state([]);
|
|
||||||
export let toasts: Toast[] = $state([]);
|
|
||||||
export let activeChapter: Chapter | null = $state(null);
|
|
||||||
export let activeChapterList: Chapter[] = $state([]);
|
|
||||||
|
|
||||||
// ── Persistence effects ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$effect.root(() => {
|
|
||||||
$effect(() => { persist({ navPage }); });
|
|
||||||
$effect(() => { persist({ libraryFilter }); });
|
|
||||||
$effect(() => { persist({ history }); });
|
|
||||||
$effect(() => { persist({ readingStats }); });
|
|
||||||
$effect(() => { persist({ settings }); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) {
|
|
||||||
activeChapter = chapter;
|
|
||||||
activeChapterList = chapterList;
|
|
||||||
pageUrls = [];
|
|
||||||
pageNumber = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeReader() {
|
|
||||||
activeChapter = null;
|
|
||||||
activeChapterList = [];
|
|
||||||
pageUrls = [];
|
|
||||||
pageNumber = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── History ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function todayStr(): string {
|
|
||||||
const d = new Date();
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addHistory(entry: HistoryEntry) {
|
|
||||||
const isNewChapter = !history.some(x => x.chapterId === entry.chapterId);
|
|
||||||
|
|
||||||
if (history[0]?.chapterId === entry.chapterId) {
|
|
||||||
history[0] = { ...history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
|
||||||
} else {
|
|
||||||
history = [entry, ...history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueChapters = new Set(history.map(e => e.chapterId));
|
|
||||||
const uniqueManga = new Set(history.map(e => e.mangaId));
|
|
||||||
|
|
||||||
const today = todayStr();
|
|
||||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = readingStats;
|
|
||||||
if (lastStreakDate !== today) {
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
|
||||||
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
|
||||||
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
|
||||||
lastStreakDate = today;
|
|
||||||
}
|
|
||||||
|
|
||||||
readingStats = {
|
|
||||||
totalChaptersRead: Math.max(readingStats.totalChaptersRead, uniqueChapters.size),
|
|
||||||
totalMangaRead: Math.max(readingStats.totalMangaRead, uniqueManga.size),
|
|
||||||
totalMinutesRead: readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
|
||||||
firstReadAt: readingStats.firstReadAt === 0 ? entry.readAt : readingStats.firstReadAt,
|
|
||||||
lastReadAt: entry.readAt,
|
|
||||||
currentStreakDays,
|
|
||||||
longestStreakDays,
|
|
||||||
lastStreakDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearHistory() {
|
|
||||||
history = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearHistoryForManga(mangaId: number) {
|
|
||||||
history = history.filter(x => x.mangaId !== mangaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wipeAllData() {
|
|
||||||
history = [];
|
|
||||||
readingStats = { ...DEFAULT_READING_STATS };
|
|
||||||
settings = { ...settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Completed manga ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function markMangaCompleted(mangaId: number) {
|
|
||||||
let folders = settings.folders.map(f => {
|
|
||||||
if (f.id !== COMPLETED_FOLDER_ID) return f;
|
|
||||||
if (f.mangaIds.includes(mangaId)) return f;
|
|
||||||
return { ...f, mangaIds: [...f.mangaIds, mangaId] };
|
|
||||||
});
|
|
||||||
if (!settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)) {
|
|
||||||
folders = [{ ...COMPLETED_FOLDER_DEFAULT, mangaIds: [mangaId] }, ...folders];
|
|
||||||
}
|
|
||||||
settings = { ...settings, folders };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unmarkMangaCompleted(mangaId: number) {
|
|
||||||
settings = {
|
|
||||||
...settings,
|
|
||||||
folders: settings.folders.map(f =>
|
|
||||||
f.id === COMPLETED_FOLDER_ID
|
|
||||||
? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCompleted(mangaId: number): boolean {
|
|
||||||
return settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
|
||||||
if (!chapters.length) return;
|
|
||||||
if (chapters.every(c => c.isRead)) markMangaCompleted(mangaId);
|
|
||||||
else unmarkMangaCompleted(mangaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Manga links ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function linkManga(idA: number, idB: number) {
|
|
||||||
if (idA === idB) return;
|
|
||||||
const links = { ...settings.mangaLinks };
|
|
||||||
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
|
||||||
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
|
||||||
settings = { ...settings, mangaLinks: links };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unlinkManga(idA: number, idB: number) {
|
|
||||||
const links = { ...settings.mangaLinks };
|
|
||||||
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
|
||||||
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
|
||||||
if (!links[idA].length) delete links[idA];
|
|
||||||
if (!links[idB].length) delete links[idB];
|
|
||||||
settings = { ...settings, mangaLinks: links };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLinkedMangaIds(mangaId: number): number[] {
|
|
||||||
return settings.mangaLinks[mangaId] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hero slots ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
|
||||||
const slots = [...(settings.heroSlots ?? [null, null, null, null])];
|
|
||||||
slots[index] = mangaId;
|
|
||||||
settings = { ...settings, heroSlots: slots };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toasts ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function addToast(toast: Omit<Toast, "id">) {
|
|
||||||
toasts = [...toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismissToast(id: string) {
|
|
||||||
toasts = toasts.filter(x => x.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function updateSettings(patch: Partial<Settings>) {
|
|
||||||
settings = { ...settings, ...patch };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetKeybinds() {
|
|
||||||
settings = { ...settings, keybinds: DEFAULT_KEYBINDS };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const genId = () => Math.random().toString(36).slice(2, 10);
|
|
||||||
|
|
||||||
export function addFolder(name: string): string {
|
|
||||||
const id = genId();
|
|
||||||
settings = { ...settings, folders: [...settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeFolder(id: string) {
|
|
||||||
settings = { ...settings, folders: settings.folders.filter(f => f.id !== id || f.system) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renameFolder(id: string, name: string) {
|
|
||||||
settings = {
|
|
||||||
...settings,
|
|
||||||
folders: settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleFolderTab(id: string) {
|
|
||||||
settings = {
|
|
||||||
...settings,
|
|
||||||
folders: settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assignMangaToFolder(folderId: string, mangaId: number) {
|
|
||||||
settings = {
|
|
||||||
...settings,
|
|
||||||
folders: settings.folders.map(f =>
|
|
||||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
|
||||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeMangaFromFolder(folderId: string, mangaId: number) {
|
|
||||||
settings = {
|
|
||||||
...settings,
|
|
||||||
folders: settings.folders.map(f =>
|
|
||||||
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMangaFolders(mangaId: number): Folder[] {
|
|
||||||
return settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import type { Manga, Chapter, Source } from "../lib/types";
|
||||||
|
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||||
|
|
||||||
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
|
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||||
|
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||||
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
|
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||||
|
|
||||||
|
export const COMPLETED_FOLDER_ID = "completed";
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
pageNumber: number;
|
||||||
|
readAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadingStats {
|
||||||
|
totalChaptersRead: number;
|
||||||
|
totalMangaRead: number;
|
||||||
|
totalMinutesRead: number;
|
||||||
|
firstReadAt: number;
|
||||||
|
lastReadAt: number;
|
||||||
|
currentStreakDays: number;
|
||||||
|
longestStreakDays: number;
|
||||||
|
lastStreakDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVG_MIN_PER_CHAPTER = 5;
|
||||||
|
|
||||||
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
|
totalChaptersRead: 0,
|
||||||
|
totalMangaRead: 0,
|
||||||
|
totalMinutesRead: 0,
|
||||||
|
firstReadAt: 0,
|
||||||
|
lastReadAt: 0,
|
||||||
|
currentStreakDays: 0,
|
||||||
|
longestStreakDays: 0,
|
||||||
|
lastStreakDate: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
kind: "success" | "error" | "info" | "download";
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDownload {
|
||||||
|
chapterId: number;
|
||||||
|
mangaId: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Folder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mangaIds: number[];
|
||||||
|
showTab: boolean;
|
||||||
|
system?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
pageStyle: PageStyle;
|
||||||
|
readingDirection: ReadingDirection;
|
||||||
|
fitMode: FitMode;
|
||||||
|
maxPageWidth: number;
|
||||||
|
pageGap: boolean;
|
||||||
|
optimizeContrast: boolean;
|
||||||
|
offsetDoubleSpreads: boolean;
|
||||||
|
preloadPages: number;
|
||||||
|
autoMarkRead: boolean;
|
||||||
|
autoNextChapter: boolean;
|
||||||
|
libraryCropCovers: boolean;
|
||||||
|
libraryPageSize: number;
|
||||||
|
showNsfw: boolean;
|
||||||
|
chapterSortDir: ChapterSortDir;
|
||||||
|
chapterPageSize: number;
|
||||||
|
uiScale: number;
|
||||||
|
compactSidebar: boolean;
|
||||||
|
gpuAcceleration: boolean;
|
||||||
|
serverUrl: string;
|
||||||
|
serverBinary: string;
|
||||||
|
autoStartServer: boolean;
|
||||||
|
preferredExtensionLang: string;
|
||||||
|
keybinds: Keybinds;
|
||||||
|
idleTimeoutMin?: number;
|
||||||
|
splashCards?: boolean;
|
||||||
|
storageLimitGb: number | null;
|
||||||
|
folders: Folder[];
|
||||||
|
markReadOnNext: boolean;
|
||||||
|
readerDebounceMs: number;
|
||||||
|
theme: Theme;
|
||||||
|
libraryBranches: boolean;
|
||||||
|
renderLimit: number;
|
||||||
|
heroSlots: (number | null)[];
|
||||||
|
mangaLinks: Record<number, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
||||||
|
id: COMPLETED_FOLDER_ID,
|
||||||
|
name: "Completed",
|
||||||
|
mangaIds: [],
|
||||||
|
showTab: true,
|
||||||
|
system: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
pageStyle: "longstrip",
|
||||||
|
readingDirection: "ltr",
|
||||||
|
fitMode: "width",
|
||||||
|
maxPageWidth: 900,
|
||||||
|
pageGap: true,
|
||||||
|
optimizeContrast: false,
|
||||||
|
offsetDoubleSpreads: false,
|
||||||
|
preloadPages: 3,
|
||||||
|
autoMarkRead: true,
|
||||||
|
autoNextChapter: true,
|
||||||
|
libraryCropCovers: true,
|
||||||
|
libraryPageSize: 48,
|
||||||
|
showNsfw: false,
|
||||||
|
chapterSortDir: "desc",
|
||||||
|
chapterPageSize: 25,
|
||||||
|
uiScale: 100,
|
||||||
|
compactSidebar: false,
|
||||||
|
gpuAcceleration: true,
|
||||||
|
serverUrl: "http://localhost:4567",
|
||||||
|
serverBinary: "tachidesk-server",
|
||||||
|
autoStartServer: true,
|
||||||
|
preferredExtensionLang: "en",
|
||||||
|
keybinds: DEFAULT_KEYBINDS,
|
||||||
|
idleTimeoutMin: 5,
|
||||||
|
splashCards: true,
|
||||||
|
storageLimitGb: null,
|
||||||
|
folders: [COMPLETED_FOLDER_DEFAULT],
|
||||||
|
markReadOnNext: true,
|
||||||
|
readerDebounceMs: 120,
|
||||||
|
theme: "dark",
|
||||||
|
libraryBranches: true,
|
||||||
|
renderLimit: 48,
|
||||||
|
heroSlots: [null, null, null, null],
|
||||||
|
mangaLinks: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadPersisted(): any {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku-store");
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(patch: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const current = loadPersisted() ?? {};
|
||||||
|
localStorage.setItem("moku-store", JSON.stringify({ ...current, ...patch }));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = loadPersisted();
|
||||||
|
|
||||||
|
function mergeSettings(saved: any): Settings {
|
||||||
|
const userFolders: Folder[] = saved?.settings?.folders ?? [];
|
||||||
|
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||||
|
const completedFolder: Folder = existingCompleted
|
||||||
|
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
|
||||||
|
: COMPLETED_FOLDER_DEFAULT;
|
||||||
|
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...saved?.settings,
|
||||||
|
folders: [completedFolder, ...otherFolders],
|
||||||
|
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||||
|
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
|
||||||
|
mangaLinks: saved?.settings?.mangaLinks ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStats(saved: any): ReadingStats {
|
||||||
|
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genId = () => Math.random().toString(36).slice(2, 10);
|
||||||
|
|
||||||
|
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||||
|
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
||||||
|
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||||
|
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||||
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
|
|
||||||
|
genreFilter: string = $state("");
|
||||||
|
searchPrefill: string = $state("");
|
||||||
|
activeManga: Manga | null = $state(null);
|
||||||
|
previewManga: Manga | null = $state(null);
|
||||||
|
activeSource: Source | null = $state(null);
|
||||||
|
pageUrls: string[] = $state([]);
|
||||||
|
pageNumber: number = $state(1);
|
||||||
|
libraryTagFilter: string[] = $state([]);
|
||||||
|
settingsOpen: boolean = $state(false);
|
||||||
|
activeDownloads: ActiveDownload[] = $state([]);
|
||||||
|
toasts: Toast[] = $state([]);
|
||||||
|
activeChapter: Chapter | null = $state(null);
|
||||||
|
activeChapterList: Chapter[] = $state([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => { persist({ navPage: this.navPage }); });
|
||||||
|
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||||
|
$effect(() => { persist({ history: this.history }); });
|
||||||
|
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||||
|
$effect(() => { persist({ settings: this.settings }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openReader(chapter: Chapter, chapterList: Chapter[]) {
|
||||||
|
this.activeChapter = chapter;
|
||||||
|
this.activeChapterList = chapterList;
|
||||||
|
this.pageUrls = [];
|
||||||
|
this.pageNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReader() {
|
||||||
|
this.activeChapter = null;
|
||||||
|
this.activeChapterList = [];
|
||||||
|
this.pageUrls = [];
|
||||||
|
this.pageNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
addHistory(entry: HistoryEntry) {
|
||||||
|
const isNewChapter = !this.history.some(x => x.chapterId === entry.chapterId);
|
||||||
|
|
||||||
|
if (this.history[0]?.chapterId === entry.chapterId) {
|
||||||
|
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
|
} else {
|
||||||
|
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueChapters = new Set(this.history.map(e => e.chapterId));
|
||||||
|
const uniqueManga = new Set(this.history.map(e => e.mangaId));
|
||||||
|
|
||||||
|
const today = todayStr();
|
||||||
|
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
||||||
|
if (lastStreakDate !== today) {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
|
||||||
|
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
|
||||||
|
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
|
||||||
|
lastStreakDate = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readingStats = {
|
||||||
|
totalChaptersRead: Math.max(this.readingStats.totalChaptersRead, uniqueChapters.size),
|
||||||
|
totalMangaRead: Math.max(this.readingStats.totalMangaRead, uniqueManga.size),
|
||||||
|
totalMinutesRead: this.readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
||||||
|
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
||||||
|
lastReadAt: entry.readAt,
|
||||||
|
currentStreakDays,
|
||||||
|
longestStreakDays,
|
||||||
|
lastStreakDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistory() { this.history = []; }
|
||||||
|
clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); }
|
||||||
|
|
||||||
|
wipeAllData() {
|
||||||
|
this.history = [];
|
||||||
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
|
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
markMangaCompleted(mangaId: number) {
|
||||||
|
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||||
|
if (!folder) return;
|
||||||
|
if (!folder.mangaIds.includes(mangaId))
|
||||||
|
folder.mangaIds = [...folder.mangaIds, mangaId];
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarkMangaCompleted(mangaId: number) {
|
||||||
|
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
|
||||||
|
if (!folder) return;
|
||||||
|
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCompleted(mangaId: number): boolean {
|
||||||
|
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
||||||
|
if (!chapters.length) return;
|
||||||
|
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
|
||||||
|
else this.unmarkMangaCompleted(mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
linkManga(idA: number, idB: number) {
|
||||||
|
if (idA === idB) return;
|
||||||
|
const links = { ...this.settings.mangaLinks };
|
||||||
|
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
||||||
|
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
||||||
|
this.settings = { ...this.settings, mangaLinks: links };
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkManga(idA: number, idB: number) {
|
||||||
|
const links = { ...this.settings.mangaLinks };
|
||||||
|
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
||||||
|
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
||||||
|
if (!links[idA].length) delete links[idA];
|
||||||
|
if (!links[idB].length) delete links[idB];
|
||||||
|
this.settings = { ...this.settings, mangaLinks: links };
|
||||||
|
}
|
||||||
|
|
||||||
|
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
|
||||||
|
|
||||||
|
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
|
||||||
|
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
|
||||||
|
slots[index] = mangaId;
|
||||||
|
this.settings = { ...this.settings, heroSlots: slots };
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(toast: Omit<Toast, "id">) {
|
||||||
|
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
|
||||||
|
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
|
||||||
|
setNavPage(next: NavPage) { this.navPage = next; }
|
||||||
|
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
|
||||||
|
setGenreFilter(next: string) { this.genreFilter = next; }
|
||||||
|
setSearchPrefill(next: string) { this.searchPrefill = next; }
|
||||||
|
setActiveManga(next: Manga | null) { this.activeManga = next; }
|
||||||
|
setPreviewManga(next: Manga | null) { this.previewManga = next; }
|
||||||
|
setActiveSource(next: Source | null) { this.activeSource = next; }
|
||||||
|
setPageUrls(next: string[]) { this.pageUrls = next; }
|
||||||
|
setPageNumber(next: number) { this.pageNumber = next; }
|
||||||
|
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
|
||||||
|
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
|
||||||
|
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
|
||||||
|
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
|
||||||
|
|
||||||
|
addFolder(name: string): string {
|
||||||
|
const id = genId();
|
||||||
|
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFolder(id: string) {
|
||||||
|
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
|
||||||
|
}
|
||||||
|
|
||||||
|
renameFolder(id: string, name: string) {
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFolderTab(id: string) {
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assignMangaToFolder(folderId: string, mangaId: number) {
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
folders: this.settings.folders.map(f =>
|
||||||
|
f.id === folderId && !f.mangaIds.includes(mangaId)
|
||||||
|
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMangaFromFolder(folderId: string, mangaId: number) {
|
||||||
|
this.settings = {
|
||||||
|
...this.settings,
|
||||||
|
folders: this.settings.folders.map(f =>
|
||||||
|
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getMangaFolders(mangaId: number): Folder[] {
|
||||||
|
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = new Store();
|
||||||
|
|
||||||
|
// ── Function re-exports — zero call-site changes for actions ──────────────────
|
||||||
|
|
||||||
|
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
||||||
|
export function closeReader() { store.closeReader(); }
|
||||||
|
export function addHistory(entry: HistoryEntry) { store.addHistory(entry); }
|
||||||
|
export function clearHistory() { store.clearHistory(); }
|
||||||
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
|
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
|
||||||
|
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
|
||||||
|
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
|
||||||
|
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
|
||||||
|
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
|
||||||
|
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
|
||||||
|
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
|
||||||
|
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
|
||||||
|
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
|
||||||
|
export function dismissToast(id: string) { store.dismissToast(id); }
|
||||||
|
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
|
||||||
|
export function setNavPage(next: NavPage) { store.setNavPage(next); }
|
||||||
|
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
|
||||||
|
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
|
||||||
|
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
|
||||||
|
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
|
||||||
|
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
|
||||||
|
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
|
||||||
|
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
|
||||||
|
export function setPageNumber(next: number) { store.setPageNumber(next); }
|
||||||
|
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
|
||||||
|
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||||
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
|
export function addFolder(name: string) { return store.addFolder(name); }
|
||||||
|
export function removeFolder(id: string) { store.removeFolder(id); }
|
||||||
|
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
|
||||||
|
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
|
||||||
|
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
|
||||||
|
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
|
||||||
|
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
|
||||||
Reference in New Issue
Block a user