mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
chore: init svelte rewrite scaffold
This commit is contained in:
+3
-3
@@ -6,7 +6,7 @@
|
|||||||
<title>Moku</title>
|
<title>Moku</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+9
-26
@@ -1,41 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "moku",
|
"name": "moku",
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri"
|
||||||
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
|
||||||
"tauri:build": "tauri build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "~2",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"svelte-spa-router": "^4.0.1"
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-router-dom": "^6.26.0",
|
|
||||||
"zustand": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^18.3.3",
|
"svelte": "^5.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"svelte-check": "^3.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"typescript": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"vite": "^5.0.0"
|
||||||
"postcss": "^8.4.40",
|
|
||||||
"tailwindcss": "^3.4.7",
|
|
||||||
"typescript": "^5.5.3",
|
|
||||||
"vite": "^5.4.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+523
-1902
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { gql } from "./lib/client";
|
||||||
|
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||||
|
import {
|
||||||
|
activeChapter, settingsOpen, settings,
|
||||||
|
activeDownloads, addToast,
|
||||||
|
} from "./store";
|
||||||
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
|
import Layout from "./components/layout/Layout.svelte";
|
||||||
|
import Reader from "./components/reader/Reader.svelte";
|
||||||
|
import Settings from "./components/settings/Settings.svelte";
|
||||||
|
import TitleBar from "./components/layout/TitleBar.svelte";
|
||||||
|
import Toaster from "./components/layout/Toaster.svelte";
|
||||||
|
import SplashScreen, { EXIT_MS } from "./components/layout/SplashScreen.svelte";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
|
let serverProbeOk = !$settings.autoStartServer;
|
||||||
|
let appReady = !$settings.autoStartServer;
|
||||||
|
let failed = false;
|
||||||
|
let retryKey = 0;
|
||||||
|
let idle = false;
|
||||||
|
let devSplash = false;
|
||||||
|
|
||||||
|
let prevQueue: DownloadQueueItem[] = [];
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
||||||
|
for (const item of prev) {
|
||||||
|
if (item.state !== "DOWNLOADING") continue;
|
||||||
|
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
||||||
|
const manga = item.chapter.manga;
|
||||||
|
addToast({ kind: "success", title: "Chapter downloaded",
|
||||||
|
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
||||||
|
duration: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQueue(next: DownloadQueueItem[]) {
|
||||||
|
detectCompletions(prevQueue, next);
|
||||||
|
prevQueue = next;
|
||||||
|
activeDownloads.set(next.map(item => ({
|
||||||
|
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetIdle() {
|
||||||
|
if (idle) return;
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
const ms = ($settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
||||||
|
if (ms === 0) return;
|
||||||
|
idleTimer = setTimeout(() => idle = true, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleEvents = ["mousemove","mousedown","keydown","touchstart","wheel"] as const;
|
||||||
|
|
||||||
|
$: if (appReady) {
|
||||||
|
idleEvents.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
||||||
|
resetIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: document.documentElement.style.zoom = `${$settings.uiScale * 1.5}%`;
|
||||||
|
$: document.documentElement.setAttribute("data-theme", $settings.theme ?? "dark");
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval>;
|
||||||
|
$: if (appReady) {
|
||||||
|
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
||||||
|
poll();
|
||||||
|
pollInterval = setInterval(poll, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let unlistenDownload: (() => void) | undefined;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
document.addEventListener("contextmenu", e => e.preventDefault());
|
||||||
|
|
||||||
|
(window as any).__mokuShowSplash = () => devSplash = true;
|
||||||
|
|
||||||
|
if ($settings.autoStartServer) {
|
||||||
|
invoke("spawn_server", { binary: $settings.serverBinary }).catch(err =>
|
||||||
|
console.warn("Could not start server:", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverProbeOk) {
|
||||||
|
let cancelled = false, tries = 0;
|
||||||
|
async function probe() {
|
||||||
|
if (cancelled) return;
|
||||||
|
tries++;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${$settings.serverUrl}/api/graphql`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if (res.ok && !cancelled) { serverProbeOk = true; return; }
|
||||||
|
} catch {}
|
||||||
|
if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; }
|
||||||
|
if (!cancelled) setTimeout(probe, 800);
|
||||||
|
}
|
||||||
|
setTimeout(probe, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
type P = { chapterId: number; mangaId: number; progress: number }[];
|
||||||
|
unlistenDownload = await listen<P>("download-progress", e =>
|
||||||
|
activeDownloads.set(e.payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($settings.autoStartServer) invoke("kill_server").catch(() => {});
|
||||||
|
idleEvents.forEach(e => window.removeEventListener(e, resetIdle));
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
unlistenDownload?.();
|
||||||
|
delete (window as any).__mokuShowSplash;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
failed = false;
|
||||||
|
serverProbeOk = false;
|
||||||
|
retryKey++;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if devSplash}
|
||||||
|
<SplashScreen mode="idle" showFps showCards={$settings.splashCards ?? true}
|
||||||
|
onDismiss={() => setTimeout(() => devSplash = false, EXIT_MS + 20)} />
|
||||||
|
{:else if !appReady}
|
||||||
|
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed}
|
||||||
|
showCards={$settings.splashCards ?? true}
|
||||||
|
onReady={() => appReady = true}
|
||||||
|
onRetry={handleRetry} />
|
||||||
|
{:else}
|
||||||
|
<div class="root">
|
||||||
|
{#if idle && !$activeChapter}
|
||||||
|
<SplashScreen mode="idle" showCards={$settings.splashCards ?? true}
|
||||||
|
onDismiss={() => setTimeout(() => idle = false, EXIT_MS + 20)} />
|
||||||
|
{/if}
|
||||||
|
{#if !$activeChapter}<TitleBar />{/if}
|
||||||
|
<div class="content">
|
||||||
|
{#if $activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
|
</div>
|
||||||
|
{#if $settingsOpen}<Settings />{/if}
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
-199
@@ -1,199 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { gql } from "./lib/client";
|
|
||||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
|
||||||
import "./styles/global.css";
|
|
||||||
import { useStore } from "./store";
|
|
||||||
import Layout from "./components/layout/Layout";
|
|
||||||
import Reader from "./components/pages/Reader";
|
|
||||||
import Settings from "./components/settings/Settings";
|
|
||||||
import MangaPreview from "./components/explore/MangaPreview";
|
|
||||||
import TitleBar from "./components/layout/TitleBar";
|
|
||||||
import Toaster from "./components/layout/Toaster";
|
|
||||||
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
|
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
|
||||||
import s from "./App.module.css";
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 30;
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const activeChapter = useStore((s) => s.activeChapter);
|
|
||||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
const addToast = useStore((s) => s.addToast);
|
|
||||||
|
|
||||||
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
|
|
||||||
const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
|
|
||||||
// appReady = ring filled + transition done, show main UI
|
|
||||||
const [appReady, setAppReady] = useState(!settings.autoStartServer);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
|
||||||
const [idle, setIdle] = useState(false);
|
|
||||||
// dev tools: force show splash
|
|
||||||
const [devSplash, setDevSplash] = useState(false);
|
|
||||||
|
|
||||||
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
|
|
||||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const idleRef = useRef(false);
|
|
||||||
|
|
||||||
// expose devSplash trigger via window for settings
|
|
||||||
useEffect(() => {
|
|
||||||
(window as any).__mokuShowSplash = () => setDevSplash(true);
|
|
||||||
return () => { delete (window as any).__mokuShowSplash; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep idleRef in sync so resetIdle can check it without a stale closure
|
|
||||||
useEffect(() => { idleRef.current = idle; }, [idle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
function resetIdle() {
|
|
||||||
// While the idle splash is visible, don't reset — let SplashScreen's own
|
|
||||||
// dismiss flow handle teardown so the exit animation plays fully.
|
|
||||||
if (idleRef.current) return;
|
|
||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
||||||
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
|
|
||||||
if (idleTimeoutMs === 0) return;
|
|
||||||
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
|
|
||||||
}
|
|
||||||
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
|
|
||||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
|
|
||||||
resetIdle();
|
|
||||||
return () => {
|
|
||||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
|
||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
||||||
};
|
|
||||||
}, [appReady, settings.idleTimeoutMin]);
|
|
||||||
|
|
||||||
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
|
|
||||||
for (const item of prev) {
|
|
||||||
if (item.state !== "DOWNLOADING") continue;
|
|
||||||
if (!next.some(q => q.chapter.id === item.chapter.id)) {
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
addToast({ kind:"success", title:"Chapter downloaded",
|
|
||||||
body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name,
|
|
||||||
duration: 4000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQueue(next: DownloadQueueItem[]) {
|
|
||||||
detectCompletions(prevQueueRef.current, next);
|
|
||||||
prevQueueRef.current = next;
|
|
||||||
setActiveDownloads(next.map(item => ({
|
|
||||||
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
|
||||||
}, [settings.uiScale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const theme = settings.theme ?? "dark";
|
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
|
||||||
}, [settings.theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const p = (e: MouseEvent) => e.preventDefault();
|
|
||||||
document.addEventListener("contextmenu", p);
|
|
||||||
return () => document.removeEventListener("contextmenu", p);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settings.autoStartServer) return;
|
|
||||||
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
|
|
||||||
console.warn("Could not start server:", err));
|
|
||||||
return () => { invoke("kill_server").catch(() => {}); };
|
|
||||||
}, [settings.autoStartServer, settings.serverBinary]);
|
|
||||||
|
|
||||||
// Poll until server responds
|
|
||||||
useEffect(() => {
|
|
||||||
if (serverProbeOk) return;
|
|
||||||
let cancelled = false, tries = 0;
|
|
||||||
async function probe() {
|
|
||||||
if (cancelled) return;
|
|
||||||
tries++;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
|
|
||||||
method:"POST", headers:{"Content-Type":"application/json"},
|
|
||||||
body: JSON.stringify({ query:"{ __typename }" }),
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
});
|
|
||||||
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
|
|
||||||
} catch {}
|
|
||||||
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
|
|
||||||
if (!cancelled) setTimeout(probe, 800);
|
|
||||||
}
|
|
||||||
const t = setTimeout(probe, 800);
|
|
||||||
return () => { cancelled = true; clearTimeout(t); };
|
|
||||||
}, [serverProbeOk, settings.serverUrl, retryKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!appReady) return;
|
|
||||||
function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
|
|
||||||
}
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [appReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
|
||||||
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
|
|
||||||
return () => { unsub.then(fn => fn()); };
|
|
||||||
}, [setActiveDownloads]);
|
|
||||||
|
|
||||||
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
|
|
||||||
if (devSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
mode="idle"
|
|
||||||
showFps
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading splash — shown until ring fills + transition completes
|
|
||||||
if (!appReady) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
mode="loading"
|
|
||||||
ringFull={serverProbeOk}
|
|
||||||
failed={failed}
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onReady={() => setAppReady(true)}
|
|
||||||
onRetry={() => {
|
|
||||||
setFailed(false);
|
|
||||||
setServerProbeOk(false);
|
|
||||||
setRetryKey(k => k+1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
{idle && !activeChapter && (
|
|
||||||
<SplashScreen
|
|
||||||
mode="idle"
|
|
||||||
showCards={settings.splashCards ?? true}
|
|
||||||
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!activeChapter && <TitleBar/>}
|
|
||||||
<div className={s.content}>
|
|
||||||
{activeChapter ? <Reader/> : <Layout/>}
|
|
||||||
</div>
|
|
||||||
{settingsOpen && <Settings/>}
|
|
||||||
<MangaPreview/>
|
|
||||||
<Toaster/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
.menu {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 200;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--sp-1);
|
|
||||||
min-width: 190px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(0,0,0,0.08),
|
|
||||||
0 4px 12px rgba(0,0,0,0.35),
|
|
||||||
0 16px 40px rgba(0,0,0,0.25);
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover:not(:disabled),
|
|
||||||
.itemFocused:not(:disabled) {
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon area — fixed-width column so labels align */
|
|
||||||
.itemIconWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-fast);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:hover .itemIconWrap,
|
|
||||||
.itemFocused .itemIconWrap {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.itemLabel {
|
|
||||||
flex: 1;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Danger variant */
|
|
||||||
.itemDanger { color: var(--color-error); }
|
|
||||||
.itemDanger:hover:not(:disabled),
|
|
||||||
.itemDanger.itemFocused:not(:disabled) {
|
|
||||||
background: var(--color-error-bg);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Disabled */
|
|
||||||
.itemDisabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
margin: 3px var(--sp-1);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import s from "./ContextMenu.module.css";
|
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
|
||||||
label: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
separator?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuSeparator {
|
|
||||||
separator: true;
|
|
||||||
label?: never;
|
|
||||||
icon?: never;
|
|
||||||
onClick?: never;
|
|
||||||
danger?: never;
|
|
||||||
disabled?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
items: ContextMenuEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [focused, setFocused] = useState<number>(-1);
|
|
||||||
|
|
||||||
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
|
||||||
const actionable = items
|
|
||||||
.map((_, i) => i)
|
|
||||||
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onDown(e: MouseEvent) {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setFocused((prev) => {
|
|
||||||
const cur = actionable.indexOf(prev);
|
|
||||||
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setFocused((prev) => {
|
|
||||||
const cur = actionable.indexOf(prev);
|
|
||||||
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" && focused >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
const item = items[focused] as ContextMenuItem;
|
|
||||||
if (item && !item.disabled) { item.onClick(); onClose(); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onDown, true);
|
|
||||||
document.addEventListener("keydown", onKey, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onDown, true);
|
|
||||||
document.removeEventListener("keydown", onKey, true);
|
|
||||||
};
|
|
||||||
}, [onClose, focused, actionable, items]);
|
|
||||||
|
|
||||||
// Focus first item on open
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionable.length) setFocused(actionable[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getPosition = useCallback(() => {
|
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
|
||||||
const scaledX = x / zoom;
|
|
||||||
const scaledY = y / zoom;
|
|
||||||
const menuW = 200;
|
|
||||||
const menuH = items.length * 34;
|
|
||||||
const vw = window.innerWidth / zoom;
|
|
||||||
const vh = window.innerHeight / zoom;
|
|
||||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
|
||||||
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
|
||||||
return { left: Math.max(4, left), top: Math.max(4, top) };
|
|
||||||
}, [x, y, items.length]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className={s.menu}
|
|
||||||
style={getPosition()}
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{items.map((item, i) => {
|
|
||||||
if ("separator" in item && item.separator) {
|
|
||||||
return <div key={i} className={s.separator} />;
|
|
||||||
}
|
|
||||||
const mi = item as ContextMenuItem;
|
|
||||||
const isFocused = focused === i;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={[
|
|
||||||
s.item,
|
|
||||||
mi.danger ? s.itemDanger : "",
|
|
||||||
mi.disabled ? s.itemDisabled : "",
|
|
||||||
isFocused ? s.itemFocused : "",
|
|
||||||
].filter(Boolean).join(" ")}
|
|
||||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
|
||||||
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
|
||||||
onMouseLeave={() => setFocused(-1)}
|
|
||||||
disabled={mi.disabled}
|
|
||||||
>
|
|
||||||
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
|
||||||
{mi.icon ?? null}
|
|
||||||
</span>
|
|
||||||
<span className={s.itemLabel}>{mi.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerActions { display: flex; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.iconBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
/* Loading state — accent tint so it's visually distinct */
|
|
||||||
.iconBtnLoading {
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
.iconBtnLoading:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDotActive {
|
|
||||||
background: var(--accent);
|
|
||||||
animation: pulse 1.6s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusText {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex: 1;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: border-color var(--t-fast), opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
/* Fade out rows being removed */
|
|
||||||
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
|
||||||
|
|
||||||
/* Thumbnail */
|
|
||||||
.thumb {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbImg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Info block */
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mangaTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterName {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagesLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressWrap {
|
|
||||||
height: 2px;
|
|
||||||
background: var(--border-base);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressBar {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right side */
|
|
||||||
.rowRight {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stateLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 160px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER,
|
|
||||||
CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { DownloadStatus } from "../../lib/types";
|
|
||||||
import s from "./DownloadQueue.module.css";
|
|
||||||
|
|
||||||
export default function DownloadQueue() {
|
|
||||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
|
||||||
const [clearing, setClearing] = useState(false);
|
|
||||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
|
||||||
|
|
||||||
// Apply status to local state + global store.
|
|
||||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
|
||||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
|
||||||
setStatus(ds);
|
|
||||||
setActiveDownloads(
|
|
||||||
ds.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}, [setActiveDownloads]);
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
|
||||||
.then((d) => applyStatus(d.downloadStatus))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 2000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function togglePlay() {
|
|
||||||
if (togglingPlay) return;
|
|
||||||
setTogglingPlay(true);
|
|
||||||
const wasRunning = status?.state === "STARTED";
|
|
||||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
|
||||||
try {
|
|
||||||
if (wasRunning) {
|
|
||||||
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
|
||||||
applyStatus(d.stopDownloader.downloadStatus);
|
|
||||||
} else {
|
|
||||||
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
|
||||||
applyStatus(d.startDownloader.downloadStatus);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
poll();
|
|
||||||
} finally {
|
|
||||||
setTogglingPlay(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clear() {
|
|
||||||
if (clearing) return;
|
|
||||||
setClearing(true);
|
|
||||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
|
||||||
setActiveDownloads([]);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
|
||||||
applyStatus(d.clearDownloader.downloadStatus);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
poll();
|
|
||||||
} finally {
|
|
||||||
setClearing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dequeue(chapterId: number) {
|
|
||||||
if (dequeueing.has(chapterId)) return;
|
|
||||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
|
||||||
setStatus((prev) =>
|
|
||||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
|
||||||
poll();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
poll();
|
|
||||||
} finally {
|
|
||||||
setDequeueing((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(chapterId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue = status?.queue ?? [];
|
|
||||||
const isRunning = status?.state === "STARTED";
|
|
||||||
|
|
||||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
|
||||||
return Math.round(progress * pageCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Downloads</h1>
|
|
||||||
<div className={s.headerActions}>
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
|
||||||
onClick={togglePlay}
|
|
||||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)}
|
|
||||||
title={isRunning ? "Pause" : "Resume"}
|
|
||||||
>
|
|
||||||
{togglingPlay ? (
|
|
||||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
|
||||||
) : isRunning ? (
|
|
||||||
<Pause size={14} weight="fill" />
|
|
||||||
) : (
|
|
||||||
<Play size={14} weight="fill" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
|
||||||
onClick={clear}
|
|
||||||
disabled={clearing || queue.length === 0}
|
|
||||||
title="Clear queue"
|
|
||||||
>
|
|
||||||
{clearing ? (
|
|
||||||
<CircleNotch size={14} weight="light" className="anim-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash size={14} weight="regular" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.statusBar}>
|
|
||||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
|
||||||
<span className={s.statusText}>
|
|
||||||
{togglingPlay
|
|
||||||
? (isRunning ? "Pausing…" : "Starting…")
|
|
||||||
: isRunning ? "Downloading" : "Paused"}
|
|
||||||
</span>
|
|
||||||
<span className={s.statusCount}>{queue.length} queued</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : queue.length === 0 ? (
|
|
||||||
<div className={s.empty}>Queue is empty.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{queue.map((item, i) => {
|
|
||||||
const isActive = i === 0 && isRunning;
|
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
|
||||||
const done = pagesDownloaded(item.progress, pages);
|
|
||||||
const manga = item.chapter.manga;
|
|
||||||
const isRemoving = dequeueing.has(item.chapter.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.chapter.id}
|
|
||||||
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
|
||||||
>
|
|
||||||
{manga?.thumbnailUrl && (
|
|
||||||
<div className={s.thumb}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.thumbImg}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.info}>
|
|
||||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
|
||||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
|
||||||
{pages > 0 && (
|
|
||||||
<span className={s.pagesLabel}>
|
|
||||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<div className={s.progressWrap}>
|
|
||||||
<div
|
|
||||||
className={s.progressBar}
|
|
||||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.rowRight}>
|
|
||||||
<span className={s.stateLabel}>{item.state}</span>
|
|
||||||
{!isActive && (
|
|
||||||
<button
|
|
||||||
className={s.removeBtn}
|
|
||||||
onClick={() => dequeue(item.chapter.id)}
|
|
||||||
disabled={isRemoving}
|
|
||||||
title="Remove from queue"
|
|
||||||
>
|
|
||||||
{isRemoving
|
|
||||||
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
|
||||||
: <X size={12} weight="light" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>Downloads.svelte</div>
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
|
||||||
.header {
|
|
||||||
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;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.tabActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* Source picker */
|
|
||||||
.sourcePicker {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourcePickerLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceSelect {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceSelect:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-5) 0 var(--sp-6);
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section ─────────────────────────────────────────────────────────────── */
|
|
||||||
.section {
|
|
||||||
margin-bottom: var(--sp-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitleIcon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.seeAll {
|
|
||||||
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: 2px 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.seeAll:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 0 var(--sp-6);
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
|
||||||
.card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 110px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inLibraryBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
left: var(--sp-1);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressBar {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressFill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent-fg);
|
|
||||||
border-radius: 0 2px 0 0;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-top: 2px;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ghost card — invisible placeholder to fill row trailing space */
|
|
||||||
.ghostCard {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 110px;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
|
||||||
.skeletonRow {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 0 var(--sp-6);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
|
||||||
|
|
||||||
.coverSkeleton {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSkeleton {
|
|
||||||
height: 11px;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
|
||||||
.drillRoot {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drillHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.drillTitle {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drillGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-5) var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
align-content: start;
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drillCard {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drillCard:hover .cover { filter: brightness(1.06); }
|
|
||||||
.drillCard:hover .title { color: var(--text-primary); }
|
|
||||||
|
|
||||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-8) var(--sp-6);
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
gap: var(--sp-2);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyHint {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── No source state ─────────────────────────────────────────────────────── */
|
|
||||||
.noSource {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
|
||||||
.exploreMoreCard {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 110px;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px dashed var(--border-strong);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: border-color var(--t-base), background var(--t-base);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.exploreMoreCard:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
|
||||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.exploreMoreInner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploreMoreIcon {
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploreMoreLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploreMoreGenre {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0.6;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
|
||||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
|
||||||
import GenreDrillPage from "./GenreDrillPage";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { UPDATE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import SourceList from "../sources/SourceList";
|
|
||||||
import SourceBrowse from "../sources/SourceBrowse";
|
|
||||||
import s from "./Explore.module.css";
|
|
||||||
|
|
||||||
// ── Frecency score ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function frecencyScore(readAt: number, count: number): number {
|
|
||||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
|
||||||
return count / Math.log(hoursSince + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ghost / Skeleton ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
|
||||||
const GHOST_COUNT = 3;
|
|
||||||
const ROW_CAP = 25;
|
|
||||||
|
|
||||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
|
||||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
const el = e.currentTarget;
|
|
||||||
const canScrollLeft = el.scrollLeft > 0;
|
|
||||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
|
||||||
if (!canScrollLeft && !canScrollRight) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
el.scrollLeft += e.deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<div className={s.skeletonRow}>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
|
||||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cover image with fade-in ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src} alt={alt} className={className}
|
|
||||||
loading="lazy" decoding="async"
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mini card ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MiniCard = memo(function MiniCard({
|
|
||||||
manga, onClick, onContextMenu, subtitle, progress,
|
|
||||||
}: {
|
|
||||||
manga: Manga;
|
|
||||||
onClick: () => void;
|
|
||||||
onContextMenu?: (e: React.MouseEvent) => void;
|
|
||||||
subtitle?: string;
|
|
||||||
progress?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
|
||||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
|
||||||
{progress !== undefined && progress > 0 && (
|
|
||||||
<div className={s.progressBar}>
|
|
||||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{manga.title}</p>
|
|
||||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
|
||||||
genre, onClick,
|
|
||||||
}: { genre: string; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
|
||||||
<div className={s.exploreMoreInner}>
|
|
||||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
|
||||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
|
||||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Section ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Section({
|
|
||||||
title, icon, onSeeAll, loading, children,
|
|
||||||
}: {
|
|
||||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
|
||||||
loading?: boolean; children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={s.section}>
|
|
||||||
<div className={s.sectionHeader}>
|
|
||||||
<span className={s.sectionTitle}>
|
|
||||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
|
||||||
</span>
|
|
||||||
{onSeeAll && (
|
|
||||||
<button className={s.seeAll} onClick={onSeeAll}>
|
|
||||||
See all <ArrowRight size={11} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{loading ? <SkeletonRow /> : children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type ExploreMode = "explore" | "sources";
|
|
||||||
|
|
||||||
export default function Explore() {
|
|
||||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
|
||||||
const activeSource = useStore((s) => s.activeSource);
|
|
||||||
const genreFilter = useStore((s) => s.genreFilter);
|
|
||||||
|
|
||||||
if (activeSource) return <SourceBrowse />;
|
|
||||||
if (genreFilter) return <GenreDrillPage />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<div className={s.headerLeft}>
|
|
||||||
<h1 className={s.heading}>Explore</h1>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
<button
|
|
||||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setMode("explore")}
|
|
||||||
>
|
|
||||||
<Compass size={11} weight="bold" /> Explore
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setMode("sources")}
|
|
||||||
>
|
|
||||||
<List size={11} weight="bold" /> Sources
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
|
||||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
|
||||||
{mode === "sources" && <SourceList />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
|
||||||
|
|
||||||
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary genre status
|
|
||||||
source { id displayName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Fast genre row query against the local DB
|
|
||||||
const MANGAS_BY_GENRE_EXPLORE = `
|
|
||||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
|
||||||
mangas(
|
|
||||||
filter: { genre: { includesInsensitive: $genre } }
|
|
||||||
first: $first
|
|
||||||
orderBy: IN_LIBRARY_AT
|
|
||||||
orderByType: DESC
|
|
||||||
) {
|
|
||||||
nodes {
|
|
||||||
id title thumbnailUrl inLibrary genre status
|
|
||||||
source { id displayName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function ExploreFeed() {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [loadingLib, setLoadingLib] = useState(true);
|
|
||||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
|
||||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
|
||||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
|
||||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
|
||||||
const [loadError, setLoadError] = useState(false);
|
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const fetchedGenresRef = useRef<string>("");
|
|
||||||
|
|
||||||
const history = useStore((s) => s.history);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const setPreviewManga = useStore((s) => s.setPreviewManga);
|
|
||||||
const setGenreFilter = useStore((s) => s.setGenreFilter);
|
|
||||||
const folders = useStore((s) => s.settings.folders);
|
|
||||||
const addFolder = useStore((s) => s.addFolder);
|
|
||||||
const assignMangaToFolder = useStore((s) => s.assignMangaToFolder);
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => { abortRef.current?.abort(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => { cache.clear(CACHE_KEYS.LIBRARY); })
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
...(folders.length > 0 ? [
|
|
||||||
{ separator: true } as ContextMenuEntry,
|
|
||||||
...folders.map((f): ContextMenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add",
|
|
||||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Data load ─────────────────────────────────────────────────────────────
|
|
||||||
// Library + genre rows: single local DB query each — instant, no source calls.
|
|
||||||
// Popular: still needs fetchSourceManga since there's no local equivalent.
|
|
||||||
useEffect(() => {
|
|
||||||
const alreadyLoaded = allManga.length > 0;
|
|
||||||
if (alreadyLoaded) return;
|
|
||||||
|
|
||||||
setLoadingLib(true);
|
|
||||||
setLoadingPopular(true);
|
|
||||||
setLoadError(false);
|
|
||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
|
||||||
if (retryCount > 0) {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
cache.clear(CACHE_KEYS.SOURCES);
|
|
||||||
fetchedGenresRef.current = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single query for all manga — library flag included
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
|
|
||||||
.then((d) => d.mangas.nodes)
|
|
||||||
).then(setAllManga)
|
|
||||||
.catch((e) => { console.error(e); setLoadError(true); })
|
|
||||||
.finally(() => setLoadingLib(false));
|
|
||||||
|
|
||||||
// Sources — only needed for Popular section
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
|
||||||
).then((allSources) => {
|
|
||||||
if (allSources.length === 0) { setLoadingPopular(false); return; }
|
|
||||||
const topSources = getTopSources(allSources).slice(0, 2);
|
|
||||||
setSources(allSources);
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
|
||||||
Promise.allSettled(
|
|
||||||
topSources.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "POPULAR", page: 1, query: null,
|
|
||||||
}).then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)
|
|
||||||
).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results)
|
|
||||||
if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
|
||||||
})
|
|
||||||
).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false));
|
|
||||||
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [retryCount]);
|
|
||||||
|
|
||||||
// ── Frecency genres (derived from history + library) ──────────────────────
|
|
||||||
const frecencyGenres = useMemo(() => {
|
|
||||||
const mangaScores = new Map<number, number>();
|
|
||||||
const mangaReadAt = new Map<number, number>();
|
|
||||||
for (const entry of history) {
|
|
||||||
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
|
||||||
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
|
||||||
mangaReadAt.set(entry.mangaId, entry.readAt);
|
|
||||||
}
|
|
||||||
const genreWeights = new Map<string, number>();
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
for (const [mangaId, count] of mangaScores.entries()) {
|
|
||||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
|
||||||
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
|
||||||
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
|
||||||
}
|
|
||||||
if (genreWeights.size === 0)
|
|
||||||
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
|
||||||
(m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
|
||||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
return Array.from(genreWeights.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([g]) => g);
|
|
||||||
}, [allManga, history]);
|
|
||||||
|
|
||||||
// ── Genre rows: query local DB directly ─────────────────────────────────
|
|
||||||
// One query per genre against the local mangas table — instant, no source I/O.
|
|
||||||
useEffect(() => {
|
|
||||||
if (frecencyGenres.length === 0 || allManga.length === 0) return;
|
|
||||||
|
|
||||||
const genreKey = frecencyGenres.join(",");
|
|
||||||
if (fetchedGenresRef.current === genreKey) return;
|
|
||||||
fetchedGenresRef.current = genreKey;
|
|
||||||
|
|
||||||
setLoadingGenres(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
const streamingMap = new Map<string, Manga[]>();
|
|
||||||
|
|
||||||
Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(
|
|
||||||
MANGAS_BY_GENRE_EXPLORE,
|
|
||||||
{ genre, first: 25 },
|
|
||||||
ctrl.signal,
|
|
||||||
).then((d) => d.mangas.nodes)
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
streamingMap.set(genre, mangas);
|
|
||||||
setGenreResults(new Map(streamingMap));
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
|
||||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
|
|
||||||
}, [frecencyGenres, allManga]);
|
|
||||||
|
|
||||||
function openManga(m: Manga) { setPreviewManga(m); }
|
|
||||||
|
|
||||||
// ── Continue reading ──────────────────────────────────────────────────────
|
|
||||||
const continueReading = useMemo(() => {
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
|
||||||
for (const entry of history) {
|
|
||||||
if (seen.has(entry.mangaId)) continue;
|
|
||||||
seen.add(entry.mangaId);
|
|
||||||
const manga = mangaMap.get(entry.mangaId);
|
|
||||||
if (!manga) continue;
|
|
||||||
result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 });
|
|
||||||
if (result.length >= 12) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [history, allManga]);
|
|
||||||
|
|
||||||
// ── Recommended ───────────────────────────────────────────────────────────
|
|
||||||
const recommended = useMemo(() => {
|
|
||||||
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
|
||||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
|
||||||
return allManga
|
|
||||||
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
|
||||||
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
|
||||||
.slice(0, 20);
|
|
||||||
}, [allManga, frecencyGenres, continueReading]);
|
|
||||||
|
|
||||||
const genresLoading = loadingGenres;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.body}>
|
|
||||||
|
|
||||||
{(continueReading.length > 0 || loadingLib) && (
|
|
||||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
|
||||||
<div className={s.row} onWheel={handleRowWheel}>
|
|
||||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
|
||||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
|
||||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(recommended.length > 0 || loadingLib) && (
|
|
||||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
|
||||||
<div className={s.row} onWheel={handleRowWheel}>
|
|
||||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(popularManga.length > 0 || loadingPopular) && (
|
|
||||||
<Section
|
|
||||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
|
||||||
icon={<Fire size={11} weight="bold" />}
|
|
||||||
loading={loadingPopular}
|
|
||||||
>
|
|
||||||
{sources.length === 0 ? (
|
|
||||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.row} onWheel={handleRowWheel}>
|
|
||||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
|
||||||
))}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{frecencyGenres.map((genre) => {
|
|
||||||
const items = genreResults.get(genre) ?? [];
|
|
||||||
const isLoading = genresLoading && items.length === 0;
|
|
||||||
if (!isLoading && items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
|
||||||
<div className={s.row} onWheel={handleRowWheel}>
|
|
||||||
{items.slice(0, ROW_CAP).map((m) => (
|
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
|
||||||
))}
|
|
||||||
{items.length >= ROW_CAP && (
|
|
||||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
|
||||||
)}
|
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
|
||||||
continueReading.length === 0 && recommended.length === 0 &&
|
|
||||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
|
||||||
<div className={s.empty}>
|
|
||||||
{loadError ? (
|
|
||||||
<>
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
|
||||||
<button
|
|
||||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
|
||||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>Nothing to explore yet</span>
|
|
||||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingHint {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid fills entire remaining height, no show-more needed */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
align-content: start;
|
|
||||||
/* Smooth GPU-accelerated scrolling */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .cardTitle { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
/* Solid bg shown while image fades in — matches skeleton color */
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inLibraryBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
left: var(--sp-1);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeletons */
|
|
||||||
.cardSkeleton { padding: 0; }
|
|
||||||
.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); }
|
|
||||||
.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultCount {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Show more — spans full grid width */
|
|
||||||
.showMoreCell {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-2) 0 var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.showMoreBtn {
|
|
||||||
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 20px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.showMoreBtn:hover:not(:disabled) {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
.showMoreBtn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
|
||||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
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 { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import s from "./GenreDrillPage.module.css";
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
const INITIAL_PAGES = 3;
|
|
||||||
const MAX_SOURCES = 12;
|
|
||||||
const CONCURRENCY = 4;
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
|
|
||||||
* multi-tag string ("Action+Romance"). Parse it into an array.
|
|
||||||
*
|
|
||||||
* Callers set multi-tag filters via:
|
|
||||||
* setGenreFilter("Action+Romance")
|
|
||||||
*
|
|
||||||
* The Explore feed's "See all" button continues to pass single strings and
|
|
||||||
* requires no change.
|
|
||||||
*/
|
|
||||||
function parseTags(genreFilter: string): string[] {
|
|
||||||
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
|
|
||||||
function tagsLabel(tags: string[]): string {
|
|
||||||
if (tags.length === 1) return tags[0];
|
|
||||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side AND filter.
|
|
||||||
* Sources only accept a single query string, so we send the first tag and
|
|
||||||
* drop results that don't also have the remaining tags in their genre list.
|
|
||||||
*/
|
|
||||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
|
||||||
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
|
||||||
return tags.every((t) => genres.includes(t.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
|
||||||
items: T[],
|
|
||||||
fn: (item: T) => Promise<void>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
let i = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
const item = items[i++];
|
|
||||||
await fn(item).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
|
||||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src} alt={alt} className={className}
|
|
||||||
loading="lazy" decoding="async"
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
|
||||||
export default function GenreDrillPage() {
|
|
||||||
const genreFilter = useStore((st) => st.genreFilter);
|
|
||||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
|
||||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
|
||||||
const settings = useStore((st) => st.settings);
|
|
||||||
const folders = useStore((st) => st.settings.folders);
|
|
||||||
const addFolder = useStore((st) => st.addFolder);
|
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
|
||||||
|
|
||||||
// Parse the filter string into individual tags
|
|
||||||
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
|
|
||||||
// First tag is sent as the source query string (sources accept only one term)
|
|
||||||
const primaryTag = tags[0] ?? "";
|
|
||||||
|
|
||||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
|
||||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
|
||||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
|
|
||||||
// Per-source next-page tracker; -1 means exhausted
|
|
||||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
|
||||||
const sourcesRef = useRef<Source[]>([]);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
// ── Initial load ─────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (tags.length === 0) return;
|
|
||||||
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
setLoadingInitial(true);
|
|
||||||
setSourceManga([]);
|
|
||||||
setLibraryManga([]);
|
|
||||||
setVisibleCount(PAGE_SIZE);
|
|
||||||
nextPageRef.current = new Map();
|
|
||||||
|
|
||||||
const preferredLang = settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
// ── Library (local DB, instant) ───────────────────────────────────────
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
Promise.all([
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
|
||||||
]).then(([all, lib]) => {
|
|
||||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
|
||||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
|
||||||
|
|
||||||
// ── Sources: stream results as each source responds ───────────────────
|
|
||||||
// Source list is stable within a session — cache indefinitely.
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
|
|
||||||
Infinity,
|
|
||||||
).then(async (allSources) => {
|
|
||||||
const sources = allSources.slice(0, MAX_SOURCES);
|
|
||||||
sourcesRef.current = sources;
|
|
||||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
|
||||||
|
|
||||||
await runConcurrent(sources, async (src) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
|
|
||||||
// On navigation-away → back the pages are still in the TTL store, so fetchPage
|
|
||||||
// returns the cached promise immediately without hitting the network.
|
|
||||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
|
||||||
const pageItems: Manga[] = [];
|
|
||||||
|
|
||||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
|
||||||
const result = await cache
|
|
||||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
|
||||||
pageKey,
|
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
|
||||||
ctrl.signal,
|
|
||||||
).then((d) => d.fetchSourceManga),
|
|
||||||
)
|
|
||||||
.catch((e: any) => {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) break;
|
|
||||||
|
|
||||||
ps.add(page);
|
|
||||||
|
|
||||||
// For multi-tag searches: client-side AND filter for tags beyond the first.
|
|
||||||
// Sources only support a single query string, so we send primaryTag and
|
|
||||||
// drop results that don't contain the remaining tags in their genre array.
|
|
||||||
const matching = tags.length > 1
|
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
|
||||||
: result.mangas;
|
|
||||||
|
|
||||||
pageItems.push(...matching);
|
|
||||||
|
|
||||||
if (!result.hasNextPage) {
|
|
||||||
nextPageRef.current.set(src.id, -1);
|
|
||||||
break;
|
|
||||||
} else if (page === INITIAL_PAGES) {
|
|
||||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
|
||||||
setLoadingInitial(false);
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
|
|
||||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
|
||||||
}).catch((e) => {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { ctrl.abort(); };
|
|
||||||
// genreFilter (not tags) as the dep — tags is derived from it and would
|
|
||||||
// cause an extra render on every parse; genreFilter is the stable identity.
|
|
||||||
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// ── Derived merged list ───────────────────────────────────────────────────
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
// For multi-tag: library results must match ALL tags
|
|
||||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
|
||||||
const libIds = new Set(libMatches.map((m) => m.id));
|
|
||||||
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
|
|
||||||
return dedupeMangaById([...libMatches, ...srcOnly]);
|
|
||||||
}, [libraryManga, sourceManga, tags]);
|
|
||||||
|
|
||||||
// ── Load more ─────────────────────────────────────────────────────────────
|
|
||||||
const hasMoreVisible = visibleCount < filtered.length;
|
|
||||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
|
||||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
|
||||||
if (loadingMore) return;
|
|
||||||
|
|
||||||
// Fast path: buffered results already in memory
|
|
||||||
if (hasMoreVisible) {
|
|
||||||
setVisibleCount((v) => v + PAGE_SIZE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path: fetch next pages from sources
|
|
||||||
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
|
||||||
if (!sources.length) return;
|
|
||||||
|
|
||||||
setLoadingMore(true);
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runConcurrent(sources, async (src) => {
|
|
||||||
const page = nextPageRef.current.get(src.id)!;
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
|
||||||
|
|
||||||
const result = await cache
|
|
||||||
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
|
||||||
pageKey,
|
|
||||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type: "SEARCH", page, query: primaryTag },
|
|
||||||
ctrl.signal,
|
|
||||||
).then((d) => d.fetchSourceManga),
|
|
||||||
)
|
|
||||||
.catch((e: any) => {
|
|
||||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
ps.add(page);
|
|
||||||
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
|
|
||||||
|
|
||||||
const matching = tags.length > 1
|
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, tags))
|
|
||||||
: result.mangas;
|
|
||||||
|
|
||||||
if (matching.length > 0)
|
|
||||||
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
|
|
||||||
}, ctrl.signal);
|
|
||||||
} finally {
|
|
||||||
if (!ctrl.signal.aborted) {
|
|
||||||
setVisibleCount((v) => v + PAGE_SIZE);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
|
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => {
|
|
||||||
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
})
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
...(folders.length > 0 ? [
|
|
||||||
{ separator: true } as ContextMenuEntry,
|
|
||||||
...folders.map((f): ContextMenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add",
|
|
||||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleItems = filtered.slice(0, visibleCount);
|
|
||||||
const label = tagsLabel(tags);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
|
||||||
<span className={s.title}>{label}</span>
|
|
||||||
{loadingInitial && filtered.length === 0 ? null : (
|
|
||||||
<span className={s.resultCount}>
|
|
||||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!loadingInitial && hasMoreNetwork && (
|
|
||||||
<span className={s.loadingHint}>More loading…</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingInitial && filtered.length === 0 ? (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{Array.from({ length: 50 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
|
||||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className={s.empty}>No manga found for "{label}".</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{visibleItems.map((m) => (
|
|
||||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
|
||||||
</div>
|
|
||||||
<p className={s.cardTitle}>{m.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{hasMore && (
|
|
||||||
<div className={s.showMoreCell}>
|
|
||||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
|
||||||
{loadingMore
|
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading…</>
|
|
||||||
: "Show more"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
/* ── Animations ──────────────────────────────────────────────────────────── */
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
|
||||||
|
|
||||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
|
||||||
.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 shell ─────────────────────────────────────────────────────────── */
|
|
||||||
.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), 0 8px 24px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cover column ────────────────────────────────────────────────────────── */
|
|
||||||
.coverCol {
|
|
||||||
width: 190px; flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
|
||||||
gap: var(--sp-3);
|
|
||||||
overflow-y: auto; overflow-x: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.coverCol::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%; aspect-ratio: 2 / 3; object-fit: cover;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coverSpinner {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coverActions {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cover action buttons ────────────────────────────────────────────────── */
|
|
||||||
.actionBtn {
|
|
||||||
display: flex; align-items: center; justify-content: 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;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.actionBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.actionBtnActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
|
|
||||||
.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.actionBtnLabel {
|
|
||||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
|
||||||
.folderWrap { position: relative; width: 100%; }
|
|
||||||
|
|
||||||
.folderMenu {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 4px); left: 0; right: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-1);
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
||||||
z-index: 10;
|
|
||||||
animation: scaleIn 0.1s ease both;
|
|
||||||
transform-origin: bottom center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderEmpty {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); padding: var(--sp-2) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderItem {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: none; border: none; cursor: pointer; text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.folderItemOn { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
|
||||||
|
|
||||||
.folderCreateRow {
|
|
||||||
display: flex; gap: var(--sp-1); padding: var(--sp-1);
|
|
||||||
}
|
|
||||||
.folderInput {
|
|
||||||
flex: 1; background: var(--bg-overlay);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm); padding: 4px 8px;
|
|
||||||
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
outline: none; min-width: 0;
|
|
||||||
}
|
|
||||||
.folderInput:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.folderOkBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
padding: 4px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.folderOkBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
|
||||||
|
|
||||||
.folderNewBtn {
|
|
||||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); background: none; border: none;
|
|
||||||
cursor: pointer; text-align: left; width: 100%;
|
|
||||||
transition: color var(--t-fast);
|
|
||||||
}
|
|
||||||
.folderNewBtn:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Content column ──────────────────────────────────────────────────────── */
|
|
||||||
.content {
|
|
||||||
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
|
||||||
.contentHeader {
|
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
|
||||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleBlock {
|
|
||||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: var(--text-lg); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary); letter-spacing: var(--tracking-tight);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.byline {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skByline {
|
|
||||||
height: 14px; width: 55%;
|
|
||||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
|
||||||
animation: pulse 1.4s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); border: none; background: none;
|
|
||||||
cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
|
||||||
.contentBody {
|
|
||||||
flex: 1; overflow-y: auto;
|
|
||||||
padding: var(--sp-5) var(--sp-6);
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error banner ────────────────────────────────────────────────────────── */
|
|
||||||
.errorBanner {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--color-warn, #f59e0b);
|
|
||||||
background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent);
|
|
||||||
border-radius: var(--radius-sm); padding: 6px var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton rows ───────────────────────────────────────────────────────── */
|
|
||||||
.skRow {
|
|
||||||
display: flex; gap: var(--sp-2); align-items: center;
|
|
||||||
}
|
|
||||||
.skBadge {
|
|
||||||
height: 20px; width: 54px;
|
|
||||||
background: var(--bg-overlay); border-radius: var(--radius-sm);
|
|
||||||
animation: pulse 1.4s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skDesc {
|
|
||||||
display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0;
|
|
||||||
}
|
|
||||||
.skLine {
|
|
||||||
height: 13px; background: var(--bg-overlay);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
animation: pulse 1.4s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Badges ──────────────────────────────────────────────────────────────── */
|
|
||||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
|
||||||
padding: 3px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.badgeGreen {
|
|
||||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
|
||||||
border-color: color-mix(in srgb, #22c55e 30%, transparent);
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.badgeDim { /* default */ }
|
|
||||||
.badgeAccent {
|
|
||||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.badgeUnread {
|
|
||||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
|
||||||
border-color: color-mix(in srgb, #f59e0b 30%, transparent);
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
.badgeNsfw {
|
|
||||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
|
||||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chapter box — clearly separated from description ────────────────────── */
|
|
||||||
.chapterBox {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterLoading {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.chapterLoadingLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterMeta {
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapterLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlAllBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
|
||||||
cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.dlAllBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.progressTrack {
|
|
||||||
height: 3px; background: var(--bg-overlay);
|
|
||||||
border-radius: var(--radius-full); overflow: hidden;
|
|
||||||
}
|
|
||||||
.progressFill {
|
|
||||||
height: 100%; background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.readBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 8px var(--sp-4);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
|
||||||
cursor: pointer; align-self: flex-start;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.readBtn:hover { filter: brightness(1.1); }
|
|
||||||
|
|
||||||
/* ── Description block ───────────────────────────────────────────────────── */
|
|
||||||
.descBlock {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;
|
|
||||||
}
|
|
||||||
.descOpen {
|
|
||||||
display: block; -webkit-line-clamp: unset; overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.descToggle {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); background: none; border: none;
|
|
||||||
cursor: pointer; padding: 0; align-self: flex-start;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.descToggle:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Genre tags ──────────────────────────────────────────────────────────── */
|
|
||||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
|
||||||
|
|
||||||
.genreTag {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genreTagClickable {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.genreTagClickable:hover {
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
|
||||||
.metaTable {
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaRow {
|
|
||||||
display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0;
|
|
||||||
}
|
|
||||||
.metaKey {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase; min-width: 56px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.metaVal {
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
.metaLink {
|
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
|
||||||
font-size: var(--text-sm); color: var(--accent-fg);
|
|
||||||
text-decoration: none; transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.metaLink:hover { opacity: 0.75; }
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
X, BookmarkSimple, ArrowSquareOut, Play,
|
|
||||||
CircleNotch, Books, CaretDown, FolderSimplePlus, Folder,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import s from "./MangaPreview.module.css";
|
|
||||||
|
|
||||||
export default function MangaPreview() {
|
|
||||||
const previewManga = useStore((st) => st.previewManga);
|
|
||||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
|
||||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
|
||||||
const setNavPage = useStore((st) => st.setNavPage);
|
|
||||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
|
||||||
const openReader = useStore((st) => st.openReader);
|
|
||||||
const addToast = useStore((st) => st.addToast);
|
|
||||||
const folders = useStore((st) => st.settings.folders);
|
|
||||||
const addFolder = useStore((st) => st.addFolder);
|
|
||||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
|
||||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
|
||||||
|
|
||||||
const [manga, setManga] = useState<Manga | null>(null);
|
|
||||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
||||||
const [loadingChapters, setLoadingChapters] = useState(false);
|
|
||||||
const [togglingLib, setTogglingLib] = useState(false);
|
|
||||||
const [descExpanded, setDescExpanded] = useState(false);
|
|
||||||
const [folderOpen, setFolderOpen] = useState(false);
|
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
|
||||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
||||||
const [queueingAll, setQueueingAll] = useState(false);
|
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
const detailAbort = useRef<AbortController | null>(null);
|
|
||||||
const chapterAbort = useRef<AbortController | null>(null);
|
|
||||||
const folderRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
detailAbort.current?.abort();
|
|
||||||
chapterAbort.current?.abort();
|
|
||||||
setPreviewManga(null);
|
|
||||||
setManga(null);
|
|
||||||
setChapters([]);
|
|
||||||
setDescExpanded(false);
|
|
||||||
setFolderOpen(false);
|
|
||||||
setCreatingFolder(false);
|
|
||||||
setNewFolderName("");
|
|
||||||
setFetchError(null);
|
|
||||||
}, [setPreviewManga]);
|
|
||||||
|
|
||||||
// ── Fetch detail + chapters on open ──────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!previewManga) return;
|
|
||||||
|
|
||||||
// Abort any in-flight requests from previous manga
|
|
||||||
detailAbort.current?.abort();
|
|
||||||
chapterAbort.current?.abort();
|
|
||||||
|
|
||||||
const dCtrl = new AbortController();
|
|
||||||
const cCtrl = new AbortController();
|
|
||||||
detailAbort.current = dCtrl;
|
|
||||||
chapterAbort.current = cCtrl;
|
|
||||||
|
|
||||||
setManga(null);
|
|
||||||
setChapters([]);
|
|
||||||
setDescExpanded(false);
|
|
||||||
setFetchError(null);
|
|
||||||
setLoadingDetail(true);
|
|
||||||
setLoadingChapters(true);
|
|
||||||
|
|
||||||
const id = previewManga.id;
|
|
||||||
|
|
||||||
// ── Detail fetch strategy ─────────────────────────────────────────────
|
|
||||||
// For source/explore manga we must call FETCH_MANGA (mutation that
|
|
||||||
// hits the source and syncs to the local DB). GET_MANGA only works for
|
|
||||||
// manga already in the local DB with full metadata.
|
|
||||||
//
|
|
||||||
// Fast path: if we already cached a full record, use it directly.
|
|
||||||
// Slow path: always try FETCH_MANGA first — it never fails for valid IDs
|
|
||||||
// and returns the richest data. Fall back to GET_MANGA if it errors.
|
|
||||||
//
|
|
||||||
(async (): Promise<Manga> => {
|
|
||||||
const cacheKey = CACHE_KEYS.MANGA(id);
|
|
||||||
|
|
||||||
// Already have a cached rich record — no network needed
|
|
||||||
if (cache.has(cacheKey)) {
|
|
||||||
return cache.get(cacheKey, () =>
|
|
||||||
Promise.resolve(previewManga as Manga)
|
|
||||||
) as Promise<Manga>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try FETCH_MANGA first — works for all manga regardless of whether
|
|
||||||
// they are in the local DB yet (it fetches from source and syncs).
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchManga: { manga: Manga } }>(
|
|
||||||
FETCH_MANGA, { id }, dCtrl.signal
|
|
||||||
);
|
|
||||||
return d.fetchManga.manga;
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") throw e;
|
|
||||||
// FETCH_MANGA failed (e.g. source offline) — fall back to local DB
|
|
||||||
const local = await gql<{ manga: Manga }>(
|
|
||||||
GET_MANGA, { id }, dCtrl.signal
|
|
||||||
).then((d) => d.manga);
|
|
||||||
if (local) return local;
|
|
||||||
throw new Error("Could not load manga details");
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.then((fullManga) => {
|
|
||||||
if (dCtrl.signal.aborted) return;
|
|
||||||
// Cache the rich record so re-opening is instant
|
|
||||||
if (!cache.has(CACHE_KEYS.MANGA(id))) {
|
|
||||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
|
||||||
}
|
|
||||||
setManga(fullManga);
|
|
||||||
setLoadingDetail(false);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
console.error("MangaPreview detail fetch:", e);
|
|
||||||
// Show whatever sparse data we have from previewManga
|
|
||||||
setManga(previewManga as Manga);
|
|
||||||
setFetchError("Could not load full details — showing cached data");
|
|
||||||
setLoadingDetail(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Chapter fetch — local DB first, fall back to source fetch ────────
|
|
||||||
gql<{ chapters: { nodes: Chapter[] } }>(
|
|
||||||
GET_CHAPTERS, { mangaId: id }, cCtrl.signal
|
|
||||||
)
|
|
||||||
.then(async (d) => {
|
|
||||||
if (cCtrl.signal.aborted) return;
|
|
||||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
// If no local chapters yet (explore/source manga), fetch from source
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
try {
|
|
||||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(
|
|
||||||
FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal
|
|
||||||
);
|
|
||||||
if (!cCtrl.signal.aborted)
|
|
||||||
nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name === "AbortError") return;
|
|
||||||
// Leave nodes empty — not a fatal error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!cCtrl.signal.aborted) setChapters(nodes);
|
|
||||||
})
|
|
||||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
|
||||||
.finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); });
|
|
||||||
|
|
||||||
return () => { dCtrl.abort(); cCtrl.abort(); };
|
|
||||||
}, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// ── Keyboard close ────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!previewManga) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [previewManga, close]);
|
|
||||||
|
|
||||||
// ── Folder outside click ──────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!folderOpen) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (folderRef.current && !folderRef.current.contains(e.target as Node)) {
|
|
||||||
setFolderOpen(false); setCreatingFolder(false); setNewFolderName("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
return () => document.removeEventListener("mousedown", handler);
|
|
||||||
}, [folderOpen]);
|
|
||||||
|
|
||||||
if (!previewManga) return null;
|
|
||||||
|
|
||||||
// Always show title/cover from previewManga immediately; upgrade to fetched manga when ready
|
|
||||||
const displayManga = manga ?? previewManga;
|
|
||||||
const totalCount = chapters.length;
|
|
||||||
const readCount = chapters.filter((c) => c.isRead).length;
|
|
||||||
const unreadCount = totalCount - readCount;
|
|
||||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
|
||||||
const bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
|
||||||
const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false;
|
|
||||||
|
|
||||||
// Scanlators — deduplicated, non-empty
|
|
||||||
const scanlators = [...new Set(
|
|
||||||
chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim())
|
|
||||||
)];
|
|
||||||
|
|
||||||
// Publication date range from chapter upload dates
|
|
||||||
const uploadDates = chapters
|
|
||||||
.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null)
|
|
||||||
.filter((d): d is number => d !== null && !isNaN(d));
|
|
||||||
const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
|
||||||
const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
|
||||||
|
|
||||||
function formatDate(d: Date) {
|
|
||||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel = displayManga.status
|
|
||||||
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const continueChapter = (() => {
|
|
||||||
if (!chapters.length) return null;
|
|
||||||
const asc = [...chapters];
|
|
||||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
|
||||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
|
||||||
const firstUnread = asc.find((c) => !c.isRead);
|
|
||||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
|
||||||
return { ch: asc[0], label: "Read again" };
|
|
||||||
})();
|
|
||||||
|
|
||||||
async function toggleLibrary() {
|
|
||||||
if (!manga) return;
|
|
||||||
setTogglingLib(true);
|
|
||||||
const next = !manga.inLibrary;
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
|
||||||
const updated = { ...manga, inLibrary: next };
|
|
||||||
setManga(updated);
|
|
||||||
// Update cache so subsequent opens reflect new state
|
|
||||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
|
||||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
setTogglingLib(false);
|
|
||||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAll() {
|
|
||||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
setQueueingAll(true);
|
|
||||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
|
||||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
|
||||||
setQueueingAll(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSeriesDetail() {
|
|
||||||
setActiveManga(displayManga);
|
|
||||||
setNavPage("library");
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFolderCreate() {
|
|
||||||
const name = newFolderName.trim();
|
|
||||||
if (!name || !previewManga) return;
|
|
||||||
const newId = addFolder(name);
|
|
||||||
assignMangaToFolder(newId, previewManga.id);
|
|
||||||
setNewFolderName("");
|
|
||||||
setCreatingFolder(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={s.backdrop}
|
|
||||||
ref={backdropRef}
|
|
||||||
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
|
||||||
>
|
|
||||||
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
|
||||||
|
|
||||||
{/* ── Cover column ── */}
|
|
||||||
<div className={s.coverCol}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(previewManga.thumbnailUrl)}
|
|
||||||
alt={displayManga.title}
|
|
||||||
className={s.cover}
|
|
||||||
/>
|
|
||||||
{loadingDetail && (
|
|
||||||
<div className={s.coverSpinner}>
|
|
||||||
<CircleNotch size={18} weight="light" className="anim-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.coverActions}>
|
|
||||||
<button
|
|
||||||
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
|
||||||
onClick={toggleLibrary}
|
|
||||||
disabled={togglingLib || loadingDetail}
|
|
||||||
>
|
|
||||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
|
||||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
|
||||||
<Books size={13} weight="light" />
|
|
||||||
Series Detail
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Folder picker */}
|
|
||||||
<div className={s.folderWrap} ref={folderRef}>
|
|
||||||
<button
|
|
||||||
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
|
||||||
onClick={() => setFolderOpen((p) => !p)}
|
|
||||||
>
|
|
||||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
|
||||||
<span className={s.actionBtnLabel}>
|
|
||||||
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{folderOpen && (
|
|
||||||
<div className={s.folderMenu}>
|
|
||||||
{folders.length === 0 && !creatingFolder && (
|
|
||||||
<p className={s.folderEmpty}>No folders yet</p>
|
|
||||||
)}
|
|
||||||
{folders.map((f) => {
|
|
||||||
const isIn = f.mangaIds.includes(previewManga.id);
|
|
||||||
return (
|
|
||||||
<button key={f.id}
|
|
||||||
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
|
||||||
onClick={() => isIn
|
|
||||||
? removeMangaFromFolder(f.id, previewManga.id)
|
|
||||||
: assignMangaToFolder(f.id, previewManga.id)}
|
|
||||||
>
|
|
||||||
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
|
||||||
{isIn ? "✓ " : ""}{f.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className={s.folderDivider} />
|
|
||||||
{creatingFolder ? (
|
|
||||||
<div className={s.folderCreateRow}>
|
|
||||||
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
|
||||||
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleFolderCreate();
|
|
||||||
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Content column ── */}
|
|
||||||
<div className={s.content}>
|
|
||||||
|
|
||||||
{/* Header — title visible immediately from previewManga */}
|
|
||||||
<div className={s.contentHeader}>
|
|
||||||
<div className={s.titleBlock}>
|
|
||||||
<h2 className={s.title}>{displayManga.title}</h2>
|
|
||||||
{loadingDetail
|
|
||||||
? <div className={s.skByline} />
|
|
||||||
: (displayManga.author || displayManga.artist)
|
|
||||||
? <p className={s.byline}>
|
|
||||||
{[displayManga.author, displayManga.artist]
|
|
||||||
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
|
||||||
</p>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable body */}
|
|
||||||
<div className={s.contentBody}>
|
|
||||||
|
|
||||||
{/* Error banner */}
|
|
||||||
{fetchError && (
|
|
||||||
<div className={s.errorBanner}>{fetchError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Badges ── */}
|
|
||||||
{loadingDetail ? (
|
|
||||||
<div className={s.skRow}>
|
|
||||||
<div className={s.skBadge} />
|
|
||||||
<div className={s.skBadge} style={{ width: 72 }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.badges}>
|
|
||||||
{statusLabel && (
|
|
||||||
<span className={[s.badge,
|
|
||||||
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
|
||||||
].join(" ")}>{statusLabel}</span>
|
|
||||||
)}
|
|
||||||
{displayManga.source && (
|
|
||||||
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
|
||||||
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
|
||||||
{!loadingChapters && unreadCount > 0 && (
|
|
||||||
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
|
||||||
)}
|
|
||||||
{!loadingChapters && bookmarkCount > 0 && (
|
|
||||||
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Chapter section — visually separated box ── */}
|
|
||||||
<div className={s.chapterBox}>
|
|
||||||
{loadingChapters ? (
|
|
||||||
<div className={s.chapterLoading}>
|
|
||||||
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
|
||||||
</div>
|
|
||||||
) : totalCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className={s.chapterMeta}>
|
|
||||||
<span className={s.chapterLabel}>
|
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
|
||||||
{readCount > 0 && ` · ${readCount} read`}
|
|
||||||
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
|
||||||
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
|
||||||
</span>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
|
||||||
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
|
||||||
{queueingAll ? "Queuing…" : "Download unread"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{readCount > 0 && (
|
|
||||||
<div className={s.progressTrack}>
|
|
||||||
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{continueChapter && (
|
|
||||||
<button className={s.readBtn}
|
|
||||||
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
|
||||||
>
|
|
||||||
<Play size={12} weight="fill" />
|
|
||||||
{continueChapter.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : !loadingDetail ? (
|
|
||||||
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
|
||||||
No chapters in local library
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Description — clearly separated from chapter block ── */}
|
|
||||||
{loadingDetail ? (
|
|
||||||
<div className={s.skDesc}>
|
|
||||||
<div className={s.skLine} style={{ width: "100%" }} />
|
|
||||||
<div className={s.skLine} style={{ width: "88%" }} />
|
|
||||||
<div className={s.skLine} style={{ width: "70%" }} />
|
|
||||||
</div>
|
|
||||||
) : displayManga.description ? (
|
|
||||||
<div className={s.descBlock}>
|
|
||||||
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
|
||||||
{displayManga.description}
|
|
||||||
</p>
|
|
||||||
{displayManga.description.length > 220 && (
|
|
||||||
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
|
||||||
{descExpanded ? "Show less" : "Show more"}
|
|
||||||
<CaretDown size={10} weight="light" style={{
|
|
||||||
transform: descExpanded ? "rotate(180deg)" : "none",
|
|
||||||
transition: "transform 0.15s ease",
|
|
||||||
}} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ── Genre tags ── */}
|
|
||||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
|
||||||
<div className={s.genres}>
|
|
||||||
{displayManga.genre.map((g) => (
|
|
||||||
<button
|
|
||||||
key={g}
|
|
||||||
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
|
||||||
title={`Browse "${g}"`}
|
|
||||||
onClick={() => {
|
|
||||||
setGenreFilter(g);
|
|
||||||
setNavPage("explore");
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Metadata table ── */}
|
|
||||||
{!loadingDetail && (
|
|
||||||
<div className={s.metaTable}>
|
|
||||||
{displayManga.author && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Author</span>
|
|
||||||
<span className={s.metaVal}>{displayManga.author}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Artist</span>
|
|
||||||
<span className={s.metaVal}>{displayManga.artist}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{statusLabel && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Status</span>
|
|
||||||
<span className={s.metaVal}>{statusLabel}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayManga.source && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Source</span>
|
|
||||||
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loadingChapters && scanlators.length > 0 && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
|
||||||
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loadingChapters && firstUpload && lastUpload && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Published</span>
|
|
||||||
<span className={s.metaVal}>
|
|
||||||
{firstUpload.getTime() === lastUpload.getTime()
|
|
||||||
? formatDate(firstUpload)
|
|
||||||
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loadingChapters && downloadedCount > 0 && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Downloaded</span>
|
|
||||||
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loadingChapters && bookmarkCount > 0 && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Bookmarks</span>
|
|
||||||
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayManga.realUrl && (
|
|
||||||
<div className={s.metaRow}>
|
|
||||||
<span className={s.metaKey}>Link</span>
|
|
||||||
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
|
||||||
Open <ArrowSquareOut size={11} weight="light" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex; flex-direction: column; height: 100%;
|
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.headerActions { display: flex; gap: var(--sp-1); }
|
|
||||||
.iconBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.4; }
|
|
||||||
.iconBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.iconBtnActive:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); filter: brightness(1.1); }
|
|
||||||
|
|
||||||
.externalPanel {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.externalHeader {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.externalTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.externalRow {
|
|
||||||
display: flex; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.externalInput {
|
|
||||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.externalInput:focus { border-color: var(--border-focus); }
|
|
||||||
.externalInput:disabled { opacity: 0.5; }
|
|
||||||
.externalInputError { border-color: var(--color-error) !important; }
|
|
||||||
.externalError {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.installBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 14px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base), opacity var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.installBtnSuccess {
|
|
||||||
background: var(--color-success, #2d6a3f); border-color: var(--color-success, #2d6a3f);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tabs { display: flex; gap: 2px; }
|
|
||||||
.tab {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md); border: none;
|
|
||||||
background: none; color: var(--text-muted); cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.group { display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 32px; height: 32px; border-radius: var(--radius-md);
|
|
||||||
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.name {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langTag {
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wider);
|
|
||||||
}
|
|
||||||
.nsfwTag {
|
|
||||||
background: transparent; border: 1px solid var(--color-error);
|
|
||||||
border-radius: var(--radius-sm); padding: 1px 5px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wider);
|
|
||||||
}
|
|
||||||
.updateBadge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
|
|
||||||
padding: 2px 6px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.updateBadgeSmall {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--accent-fg); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.actionBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.actionBtn:hover { filter: brightness(1.1); }
|
|
||||||
.actionBtnDim {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px; border-radius: var(--radius-md);
|
|
||||||
background: none; color: var(--text-faint);
|
|
||||||
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
|
|
||||||
.expandBtn {
|
|
||||||
display: flex; align-items: center; gap: 3px;
|
|
||||||
padding: 4px 6px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.expandCount {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.variants {
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
|
|
||||||
padding-left: var(--sp-3);
|
|
||||||
border-left: 1px solid var(--border-dim);
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.variantRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.variantRow:hover { background: var(--bg-raised); }
|
|
||||||
.variantName {
|
|
||||||
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.variantVersion {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.variantActions { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
flex: 1; color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Panel shared styles ── */
|
|
||||||
.externalPanel {
|
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.panelHeader {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.panelTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.panelError {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--color-error); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.externalRow { display: flex; gap: var(--sp-2); }
|
|
||||||
.externalInput {
|
|
||||||
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 6px var(--sp-3);
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.externalInput:focus { border-color: var(--border-focus); }
|
|
||||||
.externalInput:disabled { opacity: 0.5; }
|
|
||||||
.externalInputError { border-color: var(--color-error) !important; }
|
|
||||||
.installBtn {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 14px; border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: filter var(--t-base), opacity var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.installBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.installBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.installBtnSuccess {
|
|
||||||
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
|
||||||
border-color: var(--accent-fg); color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Repo list ── */
|
|
||||||
.repoLoading {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: var(--sp-3);
|
|
||||||
}
|
|
||||||
.repoEmpty {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-1) 2px;
|
|
||||||
}
|
|
||||||
.repoList {
|
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
|
||||||
}
|
|
||||||
.repoRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 5px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.repoUrl {
|
|
||||||
flex: 1; font-family: var(--font-mono, monospace); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
.repoRemoveBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.repoRemoveBtn:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
|
||||||
.repoRemoveBtn:disabled { opacity: 0.4; }
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "@phosphor-icons/react";
|
|
||||||
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 { useStore } from "../../store";
|
|
||||||
import type { Extension } from "../../lib/types";
|
|
||||||
import s from "./ExtensionList.module.css";
|
|
||||||
|
|
||||||
type Filter = "installed" | "available" | "updates" | "all";
|
|
||||||
type Panel = null | "apk" | "repos";
|
|
||||||
|
|
||||||
function baseName(name: string): string {
|
|
||||||
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtGroup {
|
|
||||||
base: string;
|
|
||||||
primary: Extension;
|
|
||||||
variants: Extension[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExtensionList() {
|
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [filter, setFilter] = useState<Filter>("installed");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [working, setWorking] = useState<Set<string>>(new Set());
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
|
||||||
|
|
||||||
// APK install state
|
|
||||||
const [externalUrl, setExternalUrl] = useState("");
|
|
||||||
const [installing, setInstalling] = useState(false);
|
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
|
||||||
const [installSuccess, setInstallSuccess] = useState(false);
|
|
||||||
|
|
||||||
// Repo management state
|
|
||||||
const [repos, setRepos] = useState<string[]>([]);
|
|
||||||
const [reposLoading, setReposLoading] = useState(false);
|
|
||||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
|
||||||
const [repoError, setRepoError] = useState<string | null>(null);
|
|
||||||
const [savingRepos, setSavingRepos] = useState(false);
|
|
||||||
|
|
||||||
const preferredLang = useStore((s) => s.settings.preferredExtensionLang);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
|
||||||
.then((d) => setExtensions(d.extensions.nodes))
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromRepo() {
|
|
||||||
setRefreshing(true);
|
|
||||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
|
||||||
.then((d) => setExtensions(d.fetchExtensions.extensions))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setRefreshing(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepos() {
|
|
||||||
setReposLoading(true);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
|
|
||||||
setRepos(d.settings.extensionRepos ?? []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setReposLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRepos(updated: string[]) {
|
|
||||||
setSavingRepos(true);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
|
||||||
SET_EXTENSION_REPOS, { repos: updated }
|
|
||||||
);
|
|
||||||
setRepos(d.setSettings.settings.extensionRepos);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setRepoError(e instanceof Error ? e.message : "Failed to save");
|
|
||||||
} finally {
|
|
||||||
setSavingRepos(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepo() {
|
|
||||||
const url = newRepoUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
||||||
setRepoError("URL must start with http:// or https://");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (repos.includes(url)) {
|
|
||||||
setRepoError("Repo already added");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRepoError(null);
|
|
||||||
setNewRepoUrl("");
|
|
||||||
saveRepos([...repos, url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepo(url: string) {
|
|
||||||
saveRepos(repos.filter((r) => r !== url));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutate = async (fn: () => Promise<unknown>, pkgName: string) => {
|
|
||||||
setWorking((p) => new Set(p).add(pkgName));
|
|
||||||
await fn().catch(console.error);
|
|
||||||
await load();
|
|
||||||
setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; });
|
|
||||||
};
|
|
||||||
|
|
||||||
async function installExternal() {
|
|
||||||
const url = externalUrl.trim();
|
|
||||||
if (!url) return;
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
||||||
setInstallError("URL must start with http:// or https://");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!url.endsWith(".apk")) {
|
|
||||||
setInstallError("URL must point to an .apk file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInstalling(true);
|
|
||||||
setInstallError(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
try {
|
|
||||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
|
||||||
setInstallSuccess(true);
|
|
||||||
setExternalUrl("");
|
|
||||||
await load();
|
|
||||||
setTimeout(() => {
|
|
||||||
setPanel(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
}, 1500);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setInstallError(e instanceof Error ? e.message : "Install failed");
|
|
||||||
} finally {
|
|
||||||
setInstalling(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(p: Panel) {
|
|
||||||
if (panel === p) {
|
|
||||||
setPanel(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPanel(p);
|
|
||||||
setInstallError(null);
|
|
||||||
setInstallSuccess(false);
|
|
||||||
setExternalUrl("");
|
|
||||||
setRepoError(null);
|
|
||||||
setNewRepoUrl("");
|
|
||||||
if (p === "repos") loadRepos();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFromRepo().finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = extensions.filter((e) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
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;
|
|
||||||
return matchSearch && matchFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups = useMemo<ExtGroup[]>(() => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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 variants = all.filter((v) => v.pkgName !== primary.pkgName);
|
|
||||||
return { base, primary, variants };
|
|
||||||
});
|
|
||||||
}, [filtered, preferredLang]);
|
|
||||||
|
|
||||||
const updateCount = extensions.filter((e) => e.hasUpdate).length;
|
|
||||||
|
|
||||||
const FILTERS: { id: Filter; label: string }[] = [
|
|
||||||
{ id: "installed", label: "Installed" },
|
|
||||||
{ id: "available", label: "Available" },
|
|
||||||
{ id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" },
|
|
||||||
{ id: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleExpand(base: string) {
|
|
||||||
setExpanded((p) => {
|
|
||||||
const n = new Set(p);
|
|
||||||
n.has(base) ? n.delete(base) : n.add(base);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActions(ext: Extension) {
|
|
||||||
if (working.has(ext.pkgName))
|
|
||||||
return <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
|
|
||||||
if (ext.hasUpdate) return (
|
|
||||||
<div className={s.rowActions}>
|
|
||||||
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
|
|
||||||
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (ext.isInstalled)
|
|
||||||
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
|
|
||||||
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Extensions</h1>
|
|
||||||
<div className={s.headerActions}>
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, panel === "repos" ? s.iconBtnActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => openPanel("repos")} title="Manage repos">
|
|
||||||
<GitBranch size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.iconBtn, panel === "apk" ? s.iconBtnActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => openPanel("apk")} title="Install from URL">
|
|
||||||
<Plus size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
|
||||||
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── APK install panel ── */}
|
|
||||||
{panel === "apk" && (
|
|
||||||
<div className={s.externalPanel}>
|
|
||||||
<div className={s.panelHeader}>
|
|
||||||
<span className={s.panelTitle}>Install from APK URL</span>
|
|
||||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div className={s.externalRow}>
|
|
||||||
<input
|
|
||||||
className={[s.externalInput, installError ? s.externalInputError : ""].join(" ").trim()}
|
|
||||||
placeholder="https://example.com/extension.apk"
|
|
||||||
value={externalUrl}
|
|
||||||
onChange={(e) => { setExternalUrl(e.target.value); setInstallError(null); }}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && !installing && installExternal()}
|
|
||||||
autoFocus
|
|
||||||
disabled={installing}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={[s.installBtn, installSuccess ? s.installBtnSuccess : ""].join(" ").trim()}
|
|
||||||
onClick={installExternal}
|
|
||||||
disabled={installing || !externalUrl.trim()}
|
|
||||||
>
|
|
||||||
{installing
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: installSuccess
|
|
||||||
? <><Check size={13} weight="bold" /> Done</>
|
|
||||||
: "Install"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{installError && <div className={s.panelError}>{installError}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Repo management panel ── */}
|
|
||||||
{panel === "repos" && (
|
|
||||||
<div className={s.externalPanel}>
|
|
||||||
<div className={s.panelHeader}>
|
|
||||||
<span className={s.panelTitle}>Extension Repositories</span>
|
|
||||||
<button className={s.iconBtn} onClick={() => setPanel(null)}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reposLoading ? (
|
|
||||||
<div className={s.repoLoading}>
|
|
||||||
<CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{repos.length === 0 ? (
|
|
||||||
<div className={s.repoEmpty}>No repos configured.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.repoList}>
|
|
||||||
{repos.map((url) => (
|
|
||||||
<div key={url} className={s.repoRow}>
|
|
||||||
<span className={s.repoUrl}>{url}</span>
|
|
||||||
<button
|
|
||||||
className={s.repoRemoveBtn}
|
|
||||||
onClick={() => removeRepo(url)}
|
|
||||||
disabled={savingRepos}
|
|
||||||
title="Remove repo"
|
|
||||||
>
|
|
||||||
{savingRepos
|
|
||||||
? <CircleNotch size={12} weight="light" className="anim-spin" />
|
|
||||||
: <X size={12} weight="bold" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.externalRow} style={{ marginTop: "var(--sp-2)" }}>
|
|
||||||
<input
|
|
||||||
className={[s.externalInput, repoError ? s.externalInputError : ""].join(" ").trim()}
|
|
||||||
placeholder="https://example.com/index.min.json"
|
|
||||||
value={newRepoUrl}
|
|
||||||
onChange={(e) => { setNewRepoUrl(e.target.value); setRepoError(null); }}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && !savingRepos && addRepo()}
|
|
||||||
disabled={savingRepos}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={s.installBtn}
|
|
||||||
onClick={addRepo}
|
|
||||||
disabled={savingRepos || !newRepoUrl.trim()}
|
|
||||||
>
|
|
||||||
{savingRepos
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: "Add"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{repoError && <div className={s.panelError}>{repoError}</div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.controls}>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{FILTERS.map((f) => (
|
|
||||||
<button key={f.id} onClick={() => setFilter(f.id)}
|
|
||||||
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input className={s.search} placeholder="Search"
|
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : groups.length === 0 ? (
|
|
||||||
<div className={s.empty}>No extensions found.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map(({ base, primary, variants }) => {
|
|
||||||
const isExpanded = expanded.has(base);
|
|
||||||
const hasVariants = variants.length > 0;
|
|
||||||
return (
|
|
||||||
<div key={base} className={s.group}>
|
|
||||||
<div className={s.row}>
|
|
||||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.name}>{base}</span>
|
|
||||||
<span className={s.meta}>
|
|
||||||
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
|
|
||||||
{" "}v{primary.versionName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
|
|
||||||
{renderActions(primary)}
|
|
||||||
{hasVariants && (
|
|
||||||
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
|
|
||||||
title={`${variants.length + 1} languages`}>
|
|
||||||
{isExpanded ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
|
||||||
<span className={s.expandCount}>{variants.length + 1}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isExpanded && hasVariants && (
|
|
||||||
<div className={s.variants}>
|
|
||||||
{variants.map((v) => (
|
|
||||||
<div key={v.pkgName} className={s.variantRow}>
|
|
||||||
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
|
|
||||||
<span className={s.variantName}>{v.name}</span>
|
|
||||||
<span className={s.variantVersion}>v{v.versionName}</span>
|
|
||||||
{v.hasUpdate && <span className={s.updateBadgeSmall}>↑</span>}
|
|
||||||
<div className={s.variantActions}>{renderActions(v)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>History.svelte</div>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg-base);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
/* GPU layer for main content area */
|
|
||||||
transform: translateZ(0);
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { navPage, activeManga } from "../../store";
|
||||||
|
import Sidebar from "./Sidebar.svelte";
|
||||||
|
import Library from "../pages/Library.svelte";
|
||||||
|
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||||
|
import History from "../history/History.svelte";
|
||||||
|
import Search from "../search/Search.svelte";
|
||||||
|
import Explore from "../pages/Explore.svelte";
|
||||||
|
import Downloads from "../downloads/Downloads.svelte";
|
||||||
|
import Extensions from "../pages/Extensions.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="main">
|
||||||
|
{#if $activeManga}
|
||||||
|
<SeriesDetail />
|
||||||
|
{:else if $navPage === "library"}
|
||||||
|
<Library />
|
||||||
|
{:else if $navPage === "search"}
|
||||||
|
<Search />
|
||||||
|
{:else if $navPage === "history"}
|
||||||
|
<History />
|
||||||
|
{:else if $navPage === "explore" || $navPage === "sources"}
|
||||||
|
<Explore />
|
||||||
|
{:else if $navPage === "downloads"}
|
||||||
|
<Downloads />
|
||||||
|
{:else if $navPage === "extensions"}
|
||||||
|
<Extensions />
|
||||||
|
{:else}
|
||||||
|
<Library />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; height: 100%; background: var(--bg-base); overflow: hidden; }
|
||||||
|
.main {
|
||||||
|
flex: 1; overflow: hidden;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
transform: translateZ(0);
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useStore } from "../../store";
|
|
||||||
import Sidebar from "./Sidebar";
|
|
||||||
import Library from "../pages/Library";
|
|
||||||
import SeriesDetail from "../pages/SeriesDetail";
|
|
||||||
import History from "../pages/History";
|
|
||||||
import Search from "../pages/Search";
|
|
||||||
import Explore from "../explore/Explore";
|
|
||||||
import DownloadQueue from "../downloads/DownloadQueue";
|
|
||||||
import ExtensionList from "../extensions/ExtensionList";
|
|
||||||
import s from "./Layout.module.css";
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
const navPage = useStore((s) => s.navPage);
|
|
||||||
const activeManga = useStore((s) => s.activeManga);
|
|
||||||
|
|
||||||
function renderContent() {
|
|
||||||
if (activeManga) return <SeriesDetail />;
|
|
||||||
switch (navPage) {
|
|
||||||
case "library": return <Library />;
|
|
||||||
case "search": return <Search />;
|
|
||||||
case "history": return <History />;
|
|
||||||
case "sources": return <Explore />;
|
|
||||||
case "explore": return <Explore />;
|
|
||||||
case "downloads": return <DownloadQueue />;
|
|
||||||
case "extensions": return <ExtensionList />;
|
|
||||||
default: return <Library />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<Sidebar />
|
|
||||||
<main className={s.main}>{renderContent()}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
.root {
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-void);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--sp-4) 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: var(--sp-3);
|
|
||||||
overflow: visible;
|
|
||||||
/* Explicit reset — prevents browser from injecting a default button background */
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
padding: 0;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
|
||||||
.logo:active { transform: scale(0.92); }
|
|
||||||
/* Kill the focus ring that can render as a coloured glow on some GTK themes */
|
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.logoIcon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: contain;
|
|
||||||
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-position: center;
|
|
||||||
-webkit-mask-size: contain;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
/* Explicit resets — the green overlay was browser default button styles bleeding through */
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); }
|
|
||||||
|
|
||||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
/* Prevent hover state from overriding active colour */
|
|
||||||
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.bottom {
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
margin-top: var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsBtn {
|
|
||||||
width: 36px; height: 36px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
/* Same explicit resets */
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
|
||||||
}
|
|
||||||
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
|
||||||
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store";
|
||||||
|
import type { NavPage } from "../../store";
|
||||||
|
|
||||||
|
const TABS: { id: NavPage; label: string; path: string }[] = [
|
||||||
|
{ id: "library", label: "Library", path: "M12 2H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h8M12 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-8M12 2v20" },
|
||||||
|
{ id: "search", label: "Search", path: "M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" },
|
||||||
|
{ id: "history", label: "History", path: "M12 8v4l3 3M3.05 11a9 9 0 1 0 .5-3" },
|
||||||
|
{ id: "explore", label: "Explore", path: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 0v20M2 12h20" },
|
||||||
|
{ id: "downloads", label: "Downloads", path: "M12 3v13M7 11l5 5 5-5M5 21h14" },
|
||||||
|
{ id: "extensions", label: "Extensions", path: "M12 2l2 7h7l-5.5 4 2 7L12 16l-5.5 4 2-7L3 9h7z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function navigate(id: NavPage) {
|
||||||
|
navPage.set(id);
|
||||||
|
activeManga.set(null);
|
||||||
|
genreFilter.set("");
|
||||||
|
if (id !== "explore") activeSource.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
navPage.set("library");
|
||||||
|
activeSource.set(null);
|
||||||
|
activeManga.set(null);
|
||||||
|
libraryFilter.set("library");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="root">
|
||||||
|
<button class="logo" on:click={goHome} title="Go to Library" aria-label="Go to Library">
|
||||||
|
<div class="logo-icon" />
|
||||||
|
</button>
|
||||||
|
<nav class="nav">
|
||||||
|
{#each TABS as tab}
|
||||||
|
<button class="tab" class:active={$navPage === tab.id}
|
||||||
|
title={tab.label} on:click={() => navigate(tab.id)}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={tab.path} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<div class="bottom">
|
||||||
|
<button class="settings-btn" on:click={() => settingsOpen.set(true)} title="Settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-void);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-4) 0;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
cursor: pointer; border-radius: var(--radius-lg);
|
||||||
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
|
padding: 0; appearance: none; -webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
|
.logo:active { transform: scale(0.92); }
|
||||||
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.logo-icon {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
background-color: var(--accent);
|
||||||
|
mask-image: url("../../assets/moku-icon.svg");
|
||||||
|
mask-repeat: no-repeat; mask-position: center; mask-size: contain;
|
||||||
|
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
||||||
|
-webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(107,143,107,0.35));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: var(--sp-1); width: 100%; padding: 0 var(--sp-2);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
.tab.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.tab.active:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.bottom {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
.settings-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none; border: none; outline: none;
|
||||||
|
cursor: pointer; padding: 0; appearance: none; -webkit-appearance: none;
|
||||||
|
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||||
|
}
|
||||||
|
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
</style>
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import {
|
|
||||||
Books, DownloadSimple, PuzzlePiece, Compass,
|
|
||||||
GearSix, ClockCounterClockwise, MagnifyingGlass,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useStore, type NavPage } from "../../store";
|
|
||||||
import s from "./Sidebar.module.css";
|
|
||||||
|
|
||||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
|
||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
|
||||||
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const navPage = useStore((state) => state.navPage);
|
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
|
||||||
const openSettings = useStore((state) => state.openSettings);
|
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
|
||||||
setNavPage(id);
|
|
||||||
setActiveManga(null);
|
|
||||||
setGenreFilter("");
|
|
||||||
if (id !== "explore") setActiveSource(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goHome() {
|
|
||||||
setNavPage("library");
|
|
||||||
setActiveSource(null);
|
|
||||||
setActiveManga(null);
|
|
||||||
setLibraryFilter("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={s.root}>
|
|
||||||
{/* Logo click → back to library root */}
|
|
||||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
|
||||||
<div className={s.logoIcon} />
|
|
||||||
</button>
|
|
||||||
<nav className={s.nav}>
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button key={tab.id} title={tab.label}
|
|
||||||
onClick={() => navigate(tab.id)}
|
|
||||||
className={[s.tab, navPage === tab.id ? s.tabActive : ""].join(" ")}>
|
|
||||||
{tab.icon}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<div className={s.bottom}>
|
|
||||||
<button className={s.settingsBtn} onClick={openSettings} title="Settings">
|
|
||||||
<GearSix size={18} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export const EXIT_MS = 320;
|
||||||
|
export let mode: "loading" | "idle" = "loading";
|
||||||
|
export let ringFull = false;
|
||||||
|
export let failed = false;
|
||||||
|
export let showCards = true;
|
||||||
|
export let showFps = false;
|
||||||
|
export let onReady: (() => void) | undefined = undefined;
|
||||||
|
export let onRetry: (() => void) | undefined = undefined;
|
||||||
|
export let onDismiss: (() => void) | undefined = undefined;
|
||||||
|
</script>
|
||||||
|
<div>SplashScreen stub</div>
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import logoUrl from "../../assets/moku-icon.svg";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
|
|
||||||
export type SplashMode = "loading" | "idle";
|
|
||||||
export const EXIT_MS = 320;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: SplashMode;
|
|
||||||
ringFull?: boolean;
|
|
||||||
failed?: boolean;
|
|
||||||
showCards?: boolean;
|
|
||||||
showFps?: boolean;
|
|
||||||
onReady?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hash ──────────────────────────────────────────────────────────────────────
|
|
||||||
function hash(n: number): number {
|
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Card definition ───────────────────────────────────────────────────────────
|
|
||||||
interface CardDef {
|
|
||||||
layer: 0 | 1 | 2;
|
|
||||||
cx: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
lines: number;
|
|
||||||
alpha: number;
|
|
||||||
speed: number;
|
|
||||||
cycleSec: number;
|
|
||||||
phase: number;
|
|
||||||
travel: number;
|
|
||||||
yStart: number;
|
|
||||||
angleStart: number;
|
|
||||||
tilt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
|
||||||
|
|
||||||
const LAYER_CFG = [
|
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const BUF = 80;
|
|
||||||
const COLS = 14;
|
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
|
||||||
const cards: CardDef[] = [];
|
|
||||||
const laneW = vw / COLS;
|
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
|
||||||
const cfg = LAYER_CFG[layer];
|
|
||||||
for (let col = 0; col < COLS; col++) {
|
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
|
||||||
const h = w * 1.44;
|
|
||||||
const maxNudge = (laneW - w) / 2 - 2;
|
|
||||||
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
|
||||||
const travel = vh + h + BUF;
|
|
||||||
cards.push({
|
|
||||||
layer: layer as 0 | 1 | 2,
|
|
||||||
cx, w, h,
|
|
||||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
|
||||||
alpha: cfg.alpha,
|
|
||||||
speed,
|
|
||||||
cycleSec: travel / speed,
|
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
|
||||||
travel,
|
|
||||||
yStart: vh + h / 2 + BUF / 2,
|
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
|
||||||
}));
|
|
||||||
return { cards, trigs };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x + r, y);
|
|
||||||
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
|
||||||
const STAMP_PAD = 6;
|
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
|
||||||
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
|
||||||
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(logW * dpr);
|
|
||||||
oc.height = Math.round(logH * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
|
|
||||||
const x0 = STAMP_PAD;
|
|
||||||
const y0 = STAMP_PAD;
|
|
||||||
const coverH = (c.w * 0.72) * 1.05;
|
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
|
||||||
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
|
||||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
|
||||||
|
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
|
||||||
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
|
||||||
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Vignette builder ──────────────────────────────────────────────────────────
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
|
||||||
const oc = document.createElement("canvas");
|
|
||||||
oc.width = Math.round(vw * dpr);
|
|
||||||
oc.height = Math.round(vh * dpr);
|
|
||||||
const ctx = oc.getContext("2d")!;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
|
||||||
g.addColorStop(0.15, "rgba(0,0,0,0)");
|
|
||||||
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.fillRect(0, 0, vw, vh);
|
|
||||||
return oc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draw frame ────────────────────────────────────────────────────────────────
|
|
||||||
function drawFrame(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
t: number,
|
|
||||||
cw: number,
|
|
||||||
ch: number,
|
|
||||||
dpr: number,
|
|
||||||
cards: CardDef[],
|
|
||||||
trigs: CardTrig[],
|
|
||||||
stamps: HTMLCanvasElement[],
|
|
||||||
vignette: HTMLCanvasElement,
|
|
||||||
) {
|
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
|
||||||
|
|
||||||
for (let i = 0; i < cards.length; i++) {
|
|
||||||
const c = cards[i];
|
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
|
||||||
|
|
||||||
const alpha = p < 0.07
|
|
||||||
? (p / 0.07) * c.alpha
|
|
||||||
: p > 0.86
|
|
||||||
? ((1 - p) / 0.14) * c.alpha
|
|
||||||
: c.alpha;
|
|
||||||
|
|
||||||
if (alpha < 0.005) continue;
|
|
||||||
|
|
||||||
const cy = c.yStart - p * c.travel;
|
|
||||||
const tg = trigs[i];
|
|
||||||
const delta = tg.tiltRad * p;
|
|
||||||
const cosDelta = Math.cos(delta);
|
|
||||||
const sinDelta = Math.sin(delta);
|
|
||||||
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
|
||||||
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
|
||||||
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.setTransform(
|
|
||||||
cos * dpr, sin * dpr,
|
|
||||||
-sin * dpr, cos * dpr,
|
|
||||||
c.cx * dpr, cy * dpr,
|
|
||||||
);
|
|
||||||
// Draw stamp at its natural logical size.
|
|
||||||
// The stamp was baked at (logical * dpr) physical pixels.
|
|
||||||
// setTransform already applied dpr scaling, so drawing at logical size
|
|
||||||
// means the stamp maps 1:1 to physical pixels — zero resampling, zero blur.
|
|
||||||
const sw = stamps[i].width / dpr;
|
|
||||||
const sh = stamps[i].height / dpr;
|
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ring ──────────────────────────────────────────────────────────────────────
|
|
||||||
function Ring({ progress }: { progress: number }) {
|
|
||||||
const r = 44, sw = 2, pad = 8;
|
|
||||||
const size = (r + pad) * 2, c = r + pad;
|
|
||||||
const circ = 2 * Math.PI * r;
|
|
||||||
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} style={{
|
|
||||||
position: "absolute", pointerEvents: "none",
|
|
||||||
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
|
||||||
}}>
|
|
||||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
|
||||||
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
|
||||||
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
|
||||||
transform={`rotate(-90 ${c} ${c})`}
|
|
||||||
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FPS counter ───────────────────────────────────────────────────────────────
|
|
||||||
function FpsCounter() {
|
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
|
||||||
const times = useRef<number[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let raf = 0;
|
|
||||||
function tick(now: number) {
|
|
||||||
const arr = times.current;
|
|
||||||
arr.push(now);
|
|
||||||
if (arr.length > 60) arr.shift();
|
|
||||||
if (arr.length > 1 && divRef.current) {
|
|
||||||
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
|
|
||||||
divRef.current.textContent = `${fps} fps`;
|
|
||||||
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={divRef} style={{
|
|
||||||
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
|
||||||
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
|
||||||
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
|
||||||
color: "#4ade80",
|
|
||||||
background: "rgba(0,0,0,0.55)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.12)",
|
|
||||||
borderRadius: 4, padding: "2px 7px",
|
|
||||||
userSelect: "none", pointerEvents: "none",
|
|
||||||
}}>-- fps</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Strategy: best of both worlds.
|
|
||||||
//
|
|
||||||
// LAYOUT → logical pixels (window.innerWidth/Height or Tauri innerSize/scale)
|
|
||||||
// Cards fill the actual window shape correctly at any size.
|
|
||||||
//
|
|
||||||
// QUALITY → physical pixels (Tauri innerSize + scaleFactor)
|
|
||||||
// Canvas buffer = physical pixels, stamps baked at the true OS DPR.
|
|
||||||
// No WebKitGTK lies, no late compositor hints, always pixel-perfect.
|
|
||||||
//
|
|
||||||
// On every resize both are re-derived together so fullscreen, half-split,
|
|
||||||
// monitor switch — all produce crisp, correctly-proportioned cards.
|
|
||||||
//
|
|
||||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
|
||||||
const ref = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = ref.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
|
||||||
if (!ctx) return;
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = "high";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
// ── Live render state ────────────────────────────────────────────────────
|
|
||||||
// The frame loop only ever reads from `live`. syncSize builds a complete
|
|
||||||
// replacement object off-thread then swaps it in one atomic assignment —
|
|
||||||
// no frame ever sees a half-rebuilt state.
|
|
||||||
interface RenderState {
|
|
||||||
cards: ReturnType<typeof buildCards>["cards"];
|
|
||||||
trigs: ReturnType<typeof buildCards>["trigs"];
|
|
||||||
stamps: HTMLCanvasElement[];
|
|
||||||
vignette: HTMLCanvasElement;
|
|
||||||
CW: number; CH: number; scale: number;
|
|
||||||
}
|
|
||||||
let live: RenderState | null = null;
|
|
||||||
|
|
||||||
// Track what we last built so we skip no-op resize events.
|
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0;
|
|
||||||
// Debounce: if a new resize arrives while one is in-flight, we only
|
|
||||||
// want the most recent result. A simple generation counter handles this.
|
|
||||||
let buildGen = 0;
|
|
||||||
|
|
||||||
async function syncSize() {
|
|
||||||
const gen = ++buildGen;
|
|
||||||
|
|
||||||
const [phys, scale] = await Promise.all([
|
|
||||||
win.innerSize(),
|
|
||||||
win.scaleFactor(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Another resize fired while we were awaiting — our result is stale.
|
|
||||||
if (gen !== buildGen) return;
|
|
||||||
|
|
||||||
const physW = phys.width;
|
|
||||||
const physH = phys.height;
|
|
||||||
const logW = physW / scale;
|
|
||||||
const logH = physH / scale;
|
|
||||||
|
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
|
||||||
|
|
||||||
// Build everything into a local staging object — nothing visible changes yet.
|
|
||||||
const built = buildCards(logW, logH);
|
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
|
||||||
const vig = buildVignette(logW, logH, scale);
|
|
||||||
|
|
||||||
// One atomic swap — the frame loop immediately sees the complete new state.
|
|
||||||
// Canvas dimensions are updated here too so they're always in sync with
|
|
||||||
// the render state that uses them.
|
|
||||||
canvas!.width = physW;
|
|
||||||
canvas!.height = physH;
|
|
||||||
live = {
|
|
||||||
cards: built.cards, trigs: built.trigs,
|
|
||||||
stamps, vignette: vig,
|
|
||||||
CW: physW, CH: physH, scale,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[SplashScreen] syncSize: logical ${Math.round(logW)}×${Math.round(logH)}`,
|
|
||||||
`physical ${physW}×${physH} @${scale.toFixed(3)}×`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
|
||||||
ro.observe(canvas);
|
|
||||||
syncSize();
|
|
||||||
|
|
||||||
let raf = 0, t0 = -1;
|
|
||||||
function frame(now: number) {
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (!live) return;
|
|
||||||
if (t0 < 0) t0 = now;
|
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
|
||||||
drawFrame(ctx!, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
|
||||||
}
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
ro.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<canvas ref={ref} style={{
|
|
||||||
position: "absolute", inset: 0, pointerEvents: "none",
|
|
||||||
width: "100%", height: "100%",
|
|
||||||
}} />
|
|
||||||
{showFps && <FpsCounter />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Static CSS ────────────────────────────────────────────────────────────────
|
|
||||||
const STATIC_CSS = `
|
|
||||||
@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} }
|
|
||||||
@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} }
|
|
||||||
@keyframes logoBreathe {
|
|
||||||
0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))}
|
|
||||||
50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))}
|
|
||||||
}
|
|
||||||
@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} }
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
export default function SplashScreen({
|
|
||||||
mode, ringFull = false, failed = false,
|
|
||||||
showCards = true, showFps = false,
|
|
||||||
onReady, onRetry, onDismiss,
|
|
||||||
}: Props) {
|
|
||||||
const [dots, setDots] = useState("");
|
|
||||||
const [ringProg, setRingProg] = useState(0.025);
|
|
||||||
const [exiting, setExiting] = useState(false);
|
|
||||||
const exitLock = useRef(false);
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock.current) return;
|
|
||||||
exitLock.current = true;
|
|
||||||
setExiting(true);
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ringFull) return;
|
|
||||||
setRingProg(1);
|
|
||||||
const t = setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [ringFull]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "idle" || !onDismiss) return;
|
|
||||||
function handler() { triggerExit(onDismiss); }
|
|
||||||
// Delay registering listeners by one frame so the event that triggered
|
|
||||||
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
|
||||||
}, 200);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
window.removeEventListener("keydown", handler);
|
|
||||||
window.removeEventListener("mousedown", handler);
|
|
||||||
window.removeEventListener("touchstart", handler);
|
|
||||||
};
|
|
||||||
}, [mode, onDismiss]);
|
|
||||||
|
|
||||||
const isIdle = mode === "idle";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: "fixed", inset: 0, zIndex: 9999,
|
|
||||||
background: "var(--bg-base)", overflow: "hidden",
|
|
||||||
display: "flex", flexDirection: "column",
|
|
||||||
alignItems: "center", justifyContent: "center",
|
|
||||||
cursor: isIdle ? "pointer" : "default",
|
|
||||||
animation: exiting
|
|
||||||
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
|
||||||
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
|
||||||
}}>
|
|
||||||
<style>{STATIC_CSS}</style>
|
|
||||||
|
|
||||||
{showCards && <CardCanvas showFps={showFps} />}
|
|
||||||
|
|
||||||
{isIdle ? (
|
|
||||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
|
||||||
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", inset: -20, borderRadius: "50%",
|
|
||||||
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
|
||||||
animation: "logoBreathe 4s ease-in-out infinite",
|
|
||||||
}} />
|
|
||||||
<img src={logoUrl} alt="Moku" style={{
|
|
||||||
width: 128, height: 128, borderRadius: 28,
|
|
||||||
display: "block", position: "relative",
|
|
||||||
animation: "logoBreathe 4s ease-in-out infinite",
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<p style={{
|
|
||||||
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
|
||||||
letterSpacing: "0.22em", textTransform: "uppercase",
|
|
||||||
margin: 0, userSelect: "none",
|
|
||||||
animation: "hintFade 3.5s ease-in-out infinite",
|
|
||||||
}}>press any key to continue</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
|
||||||
{!failed && <Ring progress={ringProg} />}
|
|
||||||
<img src={logoUrl} alt="Moku"
|
|
||||||
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
|
||||||
</div>
|
|
||||||
<p style={{
|
|
||||||
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
|
||||||
letterSpacing: "0.26em", textTransform: "uppercase",
|
|
||||||
color: "var(--text-secondary)", margin: "0 0 8px",
|
|
||||||
zIndex: 1, userSelect: "none",
|
|
||||||
}}>moku</p>
|
|
||||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
|
||||||
{failed ? (
|
|
||||||
<>
|
|
||||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
|
||||||
Could not reach Suwayomi
|
|
||||||
</p>
|
|
||||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
|
||||||
Make sure tachidesk-server is on your PATH
|
|
||||||
</p>
|
|
||||||
<button onClick={onRetry} style={{
|
|
||||||
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
|
||||||
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
|
||||||
color: "var(--text-muted)", cursor: "pointer",
|
|
||||||
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
|
||||||
}}>Retry</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
|
||||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 var(--sp-3) 0 var(--sp-4);
|
|
||||||
background: var(--bg-void);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
/* Drag region covers the whole bar */
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
/* Controls must NOT be draggable */
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btnClose:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: #c0392b;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bar" data-tauri-drag-region>
|
||||||
|
<span class="title" data-tauri-drag-region>Moku</span>
|
||||||
|
<div class="controls">
|
||||||
|
<button on:click={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button on:click={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close" on:click={() => win.close()} title="Close" aria-label="Close">
|
||||||
|
<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="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 var(--sp-3) 0 var(--sp-4);
|
||||||
|
background: var(--bg-void);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
button:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.close:hover { color: #fff; background: #c0392b; }
|
||||||
|
</style>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import s from "./TitleBar.module.css";
|
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
|
|
||||||
export default function TitleBar() {
|
|
||||||
return (
|
|
||||||
<div className={s.bar} data-tauri-drag-region>
|
|
||||||
<span className={s.title} data-tauri-drag-region>Moku</span>
|
|
||||||
<div className={s.controls}>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.minimize()}
|
|
||||||
title="Minimize"
|
|
||||||
aria-label="Minimize"
|
|
||||||
>
|
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
|
||||||
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={s.btn}
|
|
||||||
onClick={() => win.toggleMaximize()}
|
|
||||||
title="Maximize"
|
|
||||||
aria-label="Maximize"
|
|
||||||
>
|
|
||||||
<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" strokeWidth="1.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.btn, s.btnClose].join(" ")}
|
|
||||||
onClick={() => win.close()}
|
|
||||||
title="Close"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
.toaster {
|
|
||||||
position: fixed;
|
|
||||||
bottom: var(--sp-5);
|
|
||||||
right: var(--sp-5);
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
|
||||||
pointer-events: all;
|
|
||||||
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Kind variants */
|
|
||||||
.toast_success { border-color: var(--accent-dim); }
|
|
||||||
.toast_success .toastIcon { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.toast_error { border-color: var(--color-error); }
|
|
||||||
.toast_error .toastIcon { color: var(--color-error); }
|
|
||||||
|
|
||||||
.toast_download .toastIcon { color: var(--accent-fg); }
|
|
||||||
.toast_info .toastIcon { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.toastIcon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastBody {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastSub {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastClose {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { toasts, dismissToast } from "../../store";
|
||||||
|
import type { Toast } from "../../store";
|
||||||
|
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function schedule(t: Toast) {
|
||||||
|
if (timers.has(t.id)) return;
|
||||||
|
const dur = t.duration ?? 3500;
|
||||||
|
if (dur === 0) return;
|
||||||
|
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
|
||||||
|
}
|
||||||
|
|
||||||
|
$: $toasts.forEach(schedule);
|
||||||
|
|
||||||
|
onDestroy(() => timers.forEach(clearTimeout));
|
||||||
|
|
||||||
|
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",
|
||||||
|
error: "M12 9v4M12 17h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
info: "M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
|
||||||
|
download: "M12 3v13M7 11l5 5 5-5M5 21h14",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $toasts.length}
|
||||||
|
<div class="toaster" aria-live="polite">
|
||||||
|
{#each $toasts as t (t.id)}
|
||||||
|
<div class="toast toast-{t.kind}" role="alert">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={icons[t.kind]} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{t.title}</p>
|
||||||
|
{#if t.body}<p class="sub">{t.body}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close" on:click={() => dismissToast(t.id)} title="Dismiss">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster {
|
||||||
|
position: fixed; bottom: var(--sp-5); right: var(--sp-5);
|
||||||
|
z-index: 9999; display: flex; flex-direction: column;
|
||||||
|
gap: var(--sp-2); pointer-events: none; max-width: 320px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: flex-start; gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
|
pointer-events: all; min-width: 220px;
|
||||||
|
animation: toastIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(24px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||||
|
}
|
||||||
|
.toast-success { border-color: var(--accent-dim); }
|
||||||
|
.toast-success .icon { color: var(--accent-fg); }
|
||||||
|
.toast-error { border-color: var(--color-error); }
|
||||||
|
.toast-error .icon { color: var(--color-error); }
|
||||||
|
.toast-download .icon, .toast-info .icon { color: var(--accent-fg); }
|
||||||
|
.icon { flex-shrink: 0; margin-top: 2px; color: var(--text-faint); }
|
||||||
|
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.3; }
|
||||||
|
.sub {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); flex-shrink: 0; margin-top: 1px;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||||
|
</style>
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import s from "./Toaster.module.css";
|
|
||||||
|
|
||||||
export type ToastKind = "success" | "error" | "info" | "download";
|
|
||||||
|
|
||||||
export interface Toast {
|
|
||||||
id: string;
|
|
||||||
kind: ToastKind;
|
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
duration?: number; // ms, 0 = persistent
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── icons per kind ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ToastIcon({ kind }: { kind: ToastKind }) {
|
|
||||||
const size = 15;
|
|
||||||
const w = "light" as const;
|
|
||||||
if (kind === "success") return <CheckCircle size={size} weight={w} />;
|
|
||||||
if (kind === "error") return <WarningCircle size={size} weight={w} />;
|
|
||||||
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
|
|
||||||
return <Info size={size} weight={w} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── individual toast ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ToastItem({ toast }: { toast: Toast }) {
|
|
||||||
const dismissToast = useStore((s) => s.dismissToast);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const duration = toast.duration ?? 3500;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration === 0) return;
|
|
||||||
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
|
|
||||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
||||||
}, [toast.id, duration]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
|
|
||||||
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
|
|
||||||
<div className={s.toastBody}>
|
|
||||||
<p className={s.toastTitle}>{toast.title}</p>
|
|
||||||
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
|
|
||||||
</div>
|
|
||||||
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
|
|
||||||
<X size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── toaster container ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function Toaster() {
|
|
||||||
const toasts = useStore((s) => s.toasts);
|
|
||||||
|
|
||||||
if (!toasts.length) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className={s.toaster} aria-live="polite">
|
|
||||||
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>Explore.svelte</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>Extensions.svelte</div>
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex; flex-direction: column; height: 100%;
|
|
||||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.searchClear {
|
|
||||||
position: absolute; right: 7px;
|
|
||||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
|
||||||
background: none; border: none; cursor: pointer; padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchClear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clearBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.statsBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-2) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statVal {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statDivider {
|
|
||||||
width: 1px;
|
|
||||||
height: 12px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
|
||||||
.groupLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.row:hover .playIcon { opacity: 1; }
|
|
||||||
|
|
||||||
/* Thumb with session count badge */
|
|
||||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb {
|
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover; display: block; background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.sessionBadge {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
padding: 1px 4px; border-radius: 6px;
|
|
||||||
line-height: 1.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.mangaTitle {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.chapterName {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
|
||||||
}
|
|
||||||
.chapterRange {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
color: var(--text-muted); font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.rangeSep {
|
|
||||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pageBadge {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.playIcon {
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
opacity: 0; transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1; display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { useStore, type HistoryEntry } from "../../store";
|
|
||||||
import s from "./History.module.css";
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts;
|
|
||||||
const m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yesterday = new Date(now);
|
|
||||||
yesterday.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
|
||||||
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
|
||||||
function formatReadTime(minutes: number): string {
|
|
||||||
if (minutes < 1) return "< 1 min";
|
|
||||||
if (minutes < 60) return `${minutes} min`;
|
|
||||||
const h = Math.floor(minutes / 60);
|
|
||||||
const m = minutes % 60;
|
|
||||||
if (m === 0) return `${h}h`;
|
|
||||||
return `${h}h ${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
|
||||||
|
|
||||||
export interface ReadingSession {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
latestChapterId: number;
|
|
||||||
latestChapterName: string;
|
|
||||||
latestPageNumber: number;
|
|
||||||
firstChapterName: string;
|
|
||||||
chapterCount: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: ReadingSession[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
|
||||||
group.push(next);
|
|
||||||
j++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const latest = group[0];
|
|
||||||
const oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId,
|
|
||||||
mangaTitle: latest.mangaTitle,
|
|
||||||
thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId,
|
|
||||||
latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber,
|
|
||||||
firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length,
|
|
||||||
readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
|
||||||
const groups = new Map<string, ReadingSession[]>();
|
|
||||||
for (const sess of sessions) {
|
|
||||||
const label = dayLabel(sess.readAt);
|
|
||||||
if (!groups.has(label)) groups.set(label, []);
|
|
||||||
groups.get(label)!.push(sess);
|
|
||||||
}
|
|
||||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function History() {
|
|
||||||
const history = useStore((s) => s.history);
|
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
|
||||||
const openReader = useStore((s) => s.openReader);
|
|
||||||
const activeChapterList = useStore((s) => s.activeChapterList);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return history;
|
|
||||||
return history.filter(
|
|
||||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}, [history, search]);
|
|
||||||
|
|
||||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
|
||||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
|
||||||
|
|
||||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
if (!history.length) return null;
|
|
||||||
// Unique chapters read
|
|
||||||
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
|
||||||
// Unique manga read
|
|
||||||
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
|
||||||
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
|
||||||
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
|
||||||
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
function resumeReading(session: ReadingSession) {
|
|
||||||
// If the chapter list is available in store (user already visited this manga),
|
|
||||||
// open the reader directly for a snappier experience
|
|
||||||
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
|
||||||
if (chapterInList && activeChapterList.length > 0) {
|
|
||||||
openReader(chapterInList, activeChapterList);
|
|
||||||
} else {
|
|
||||||
// Fall back to opening SeriesDetail — it will show the continue button
|
|
||||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>History</h1>
|
|
||||||
<div className={s.headerRight}>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input className={s.search} placeholder="Search history…"
|
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
||||||
{search && (
|
|
||||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{history.length > 0 && (
|
|
||||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats && (
|
|
||||||
<div className={s.statsBar}>
|
|
||||||
<span className={s.statItem}>
|
|
||||||
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
|
||||||
<span className={s.statLabel}>chapters read</span>
|
|
||||||
</span>
|
|
||||||
<span className={s.statDivider} />
|
|
||||||
<span className={s.statItem}>
|
|
||||||
<span className={s.statVal}>{stats.uniqueManga}</span>
|
|
||||||
<span className={s.statLabel}>series</span>
|
|
||||||
</span>
|
|
||||||
<span className={s.statDivider} />
|
|
||||||
<span className={s.statItem}>
|
|
||||||
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
|
||||||
<span className={s.statLabel}>est. read time</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No reading history yet</p>
|
|
||||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
) : sessions.length === 0 ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
|
||||||
<p className={s.emptyText}>No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map(({ label, items }) => (
|
|
||||||
<div key={label} className={s.group}>
|
|
||||||
<p className={s.groupLabel}>{label}</p>
|
|
||||||
{items.map((session) => (
|
|
||||||
<button
|
|
||||||
key={`${session.latestChapterId}-${session.readAt}`}
|
|
||||||
className={s.row}
|
|
||||||
onClick={() => resumeReading(session)}
|
|
||||||
>
|
|
||||||
<div className={s.thumbWrap}>
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
|
||||||
{session.chapterCount > 1 && (
|
|
||||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
|
||||||
<span className={s.chapterName}>
|
|
||||||
{session.chapterCount > 1 ? (
|
|
||||||
<span className={s.chapterRange}>
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span className={s.rangeSep}>→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{session.latestChapterName}
|
|
||||||
{session.latestPageNumber > 1 && (
|
|
||||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={12} weight="fill" className={s.playIcon} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
/* GPU acceleration for smooth scrolling */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLeft {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter tabs */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.tabActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.tabCount {
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
.searchWrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 10px 5px 28px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
width: 180px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
|
||||||
.virtualRow {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: 0 var(--sp-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual card fills its flex slot */
|
|
||||||
.card {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghostCard {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 200px;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
/* GPU-accelerated compositing */
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
/* Hint to compositor */
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloadedBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
right: var(--sp-1);
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.unreadBadge {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sp-1);
|
|
||||||
left: var(--sp-1);
|
|
||||||
min-width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
padding: 0 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
background: var(--bg-void);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-6) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton */
|
|
||||||
.cardSkeleton { padding: 0; }
|
|
||||||
|
|
||||||
.coverSkeletonWrap {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSkeleton {
|
|
||||||
height: 12px;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ghost cards fill trailing grid space without taking interaction */
|
|
||||||
.ghostCard {
|
|
||||||
padding: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
visibility: hidden;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 60%;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
gap: var(--sp-2);
|
|
||||||
text-align: center;
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
|
||||||
/* ── Tag filter ── */
|
|
||||||
.tagPanel {
|
|
||||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-6) var(--sp-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagChip {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-faint); cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.tagChipActive {
|
|
||||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.tagClear {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
|
|
||||||
background: none; color: var(--color-error); cursor: pointer;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagClear:hover { background: var(--color-error-bg); }
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>Library.svelte</div>
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import s from "./Library.module.css";
|
|
||||||
|
|
||||||
const CARD_MIN_W = 130;
|
|
||||||
const CARD_GAP = 16;
|
|
||||||
const ROW_HEIGHT = 260;
|
|
||||||
|
|
||||||
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src} alt={alt} className={className}
|
|
||||||
loading="lazy" decoding="async"
|
|
||||||
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MangaCard = memo(function MangaCard({
|
|
||||||
manga, onClick, onContextMenu, cropCovers,
|
|
||||||
}: {
|
|
||||||
manga: Manga;
|
|
||||||
onClick: () => void;
|
|
||||||
onContextMenu: (e: React.MouseEvent) => void;
|
|
||||||
cropCovers: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<FadeImg
|
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
|
||||||
alt={manga.title}
|
|
||||||
className={s.cover}
|
|
||||||
objectFit={cropCovers ? "cover" : "contain"}
|
|
||||||
/>
|
|
||||||
{!!manga.downloadCount && (
|
|
||||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
|
||||||
)}
|
|
||||||
{!!manga.unreadCount && (
|
|
||||||
<span className={s.unreadBadge}>{manga.unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{manga.title}</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function fetchLibrary() {
|
|
||||||
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Library() {
|
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
|
||||||
const settings = useStore((state) => state.settings);
|
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
|
||||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
|
||||||
const folders = useStore((state) => state.settings.folders);
|
|
||||||
const addFolder = useStore((state) => state.addFolder);
|
|
||||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
|
||||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
|
||||||
const activeChapter = useStore((state) => state.activeChapter);
|
|
||||||
|
|
||||||
|
|
||||||
const prevChapterRef = useRef<number | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const wasOpen = prevChapterRef.current !== null;
|
|
||||||
prevChapterRef.current = activeChapter?.id ?? null;
|
|
||||||
if (!wasOpen || activeChapter) return;
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
}, [activeChapter]);
|
|
||||||
|
|
||||||
const loadData = useCallback((showLoading = false) => {
|
|
||||||
if (showLoading) setLoading(true);
|
|
||||||
// Clear a previously failed cache entry so we actually retry the network call
|
|
||||||
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
|
||||||
// cache miss — fresh fetch, nothing to clear
|
|
||||||
}
|
|
||||||
fetchLibrary()
|
|
||||||
.then((nodes) => { setAllManga(nodes); setError(null); })
|
|
||||||
.catch((e) => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initial load — delayed on first mount so the server has time to start.
|
|
||||||
// retryCount bumps force a re-run; manual retries clear the cache first.
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
loadData(false);
|
|
||||||
|
|
||||||
// Re-fetch when library cache is invalidated by other pages
|
|
||||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
|
||||||
return unsub;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [retryCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
|
||||||
}, [libraryFilter, search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let items = allManga;
|
|
||||||
if (libraryFilter === "library") {
|
|
||||||
items = items.filter((m) => m.inLibrary);
|
|
||||||
} else if (libraryFilter === "downloaded") {
|
|
||||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
|
||||||
} else if (!isBuiltinFilter) {
|
|
||||||
const folder = folders.find((f) => f.id === libraryFilter);
|
|
||||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
|
||||||
}
|
|
||||||
if (libraryTagFilter.length > 0)
|
|
||||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
|
||||||
if (search.trim()) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
|
||||||
|
|
||||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
|
||||||
setContainerWidth(entry.contentRect.width);
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const result: Manga[][] = [];
|
|
||||||
for (let i = 0; i < filtered.length; i += cols)
|
|
||||||
result.push(filtered.slice(i, i + cols));
|
|
||||||
return result;
|
|
||||||
}, [filtered, cols]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
estimateSize: () => ROW_HEIGHT,
|
|
||||||
overscan: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
|
||||||
(m: Manga) => () => setActiveManga(m),
|
|
||||||
[setActiveManga]
|
|
||||||
);
|
|
||||||
|
|
||||||
async function removeFromLibrary(manga: Manga) {
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
|
||||||
// Optimistic update first, then invalidate cache
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
|
||||||
try {
|
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
|
||||||
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
|
||||||
const ids = downloadedChapters.map((c) => c.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
|
||||||
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault();
|
|
||||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
|
||||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
|
||||||
setCtx({ x, y, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
|
||||||
const inFolder = f.mangaIds.includes(m.id);
|
|
||||||
return {
|
|
||||||
label: inFolder ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
|
||||||
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "Open",
|
|
||||||
icon: <BookOpen size={13} weight="light" />,
|
|
||||||
onClick: () => setActiveManga(m),
|
|
||||||
},
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
danger: m.inLibrary,
|
|
||||||
onClick: () => m.inLibrary
|
|
||||||
? removeFromLibrary(m)
|
|
||||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => {
|
|
||||||
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
})
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete all downloads",
|
|
||||||
icon: <Trash size={13} weight="light" />,
|
|
||||||
danger: true,
|
|
||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
|
||||||
onClick: () => deleteAllDownloads(m),
|
|
||||||
},
|
|
||||||
...(folders.length > 0 ? [
|
|
||||||
{ separator: true } as ContextMenuEntry,
|
|
||||||
...mangaFolderEntries,
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder",
|
|
||||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (name?.trim()) {
|
|
||||||
const id = addFolder(name.trim());
|
|
||||||
assignMangaToFolder(id, m.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "New folder",
|
|
||||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (name?.trim()) addFolder(name.trim());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const tagSet = new Set<string>();
|
|
||||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
|
||||||
return Array.from(tagSet).sort();
|
|
||||||
}, [allManga]);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const result: Record<string, number> = {
|
|
||||||
all: allManga.length,
|
|
||||||
library: allManga.filter((m) => m.inLibrary).length,
|
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
|
||||||
};
|
|
||||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
|
||||||
return result;
|
|
||||||
}, [allManga, folders]);
|
|
||||||
|
|
||||||
if (error) return (
|
|
||||||
<div className={s.center}>
|
|
||||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
|
||||||
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
|
||||||
<button
|
|
||||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
|
||||||
onClick={() => setRetryCount((c) => c + 1)}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={s.root}
|
|
||||||
ref={scrollRef}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={s.header}>
|
|
||||||
<div className={s.headerLeft}>
|
|
||||||
<h1 className={s.heading}>Library</h1>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(f)}
|
|
||||||
>
|
|
||||||
{f === "library" ? (
|
|
||||||
<><Books size={11} weight="bold" /> Saved</>
|
|
||||||
) : f === "downloaded" ? (
|
|
||||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
|
||||||
) : <>All</>}
|
|
||||||
<span className={s.tabCount}>{counts[f]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{folders.filter((f) => f.showTab).map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => setLibraryFilter(folder.id)}
|
|
||||||
>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{folder.name}
|
|
||||||
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
className={s.search}
|
|
||||||
placeholder="Search"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allTags.length > 0 && (
|
|
||||||
<div className={s.tagPanel}>
|
|
||||||
{libraryTagFilter.length > 0 && (
|
|
||||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
|
||||||
<X size={11} weight="bold" /> Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{allTags.map((tag) => {
|
|
||||||
const active = libraryTagFilter.includes(tag);
|
|
||||||
return (
|
|
||||||
<button key={tag}
|
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
|
||||||
onClick={() => setGenreFilter(tag)}>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
|
||||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className={s.center}>
|
|
||||||
{libraryFilter === "library"
|
|
||||||
? "No manga saved to library, browse sources to add some."
|
|
||||||
: libraryFilter === "downloaded"
|
|
||||||
? "No downloaded manga."
|
|
||||||
: !isBuiltinFilter
|
|
||||||
? "No manga in this folder yet. Right-click manga to assign them."
|
|
||||||
: "No manga found."}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const rowManga = rows[virtualRow.index];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: virtualRow.start,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
className={s.virtualRow}
|
|
||||||
>
|
|
||||||
{rowManga.map((m) => (
|
|
||||||
<MangaCard
|
|
||||||
key={m.id}
|
|
||||||
manga={m}
|
|
||||||
onClick={handleCardClick(m)}
|
|
||||||
onContextMenu={(e) => openCtx(e, m)}
|
|
||||||
cropCovers={settings.libraryCropCovers}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{virtualRow.index === rows.length - 1 &&
|
|
||||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
|
||||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
|
||||||
)}
|
|
||||||
{emptyCtx && (
|
|
||||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
width: 520px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitle {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitleManga {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
/* ── Steps ── */
|
|
||||||
.steps {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-3) var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
opacity: 0.4;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive { opacity: 1; }
|
|
||||||
.stepDone { opacity: 0.6; }
|
|
||||||
|
|
||||||
.stepDot {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepDot {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepActive .stepLabel { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.steps .step + .step::before {
|
|
||||||
content: "›";
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-right: var(--sp-1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Body ── */
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Source list ── */
|
|
||||||
.sourceList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 9px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
|
||||||
|
|
||||||
.sourceIcon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
||||||
|
|
||||||
.sourceName {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceMeta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceArrow {
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.sourceRow:hover .sourceArrow { opacity: 1; }
|
|
||||||
|
|
||||||
/* ── Search step ── */
|
|
||||||
.searchStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBar {
|
|
||||||
flex: 1;
|
|
||||||
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: 0 var(--sp-3) 0 var(--sp-2);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
padding: 7px 0;
|
|
||||||
}
|
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.searchBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.backBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
|
||||||
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.resultCoverWrap {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.resultTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeletons */
|
|
||||||
.skResult {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skCover {
|
|
||||||
width: 36px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
||||||
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
/* ── Confirm step ── */
|
|
||||||
.confirmStep {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4) var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmManga {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex: 1;
|
|
||||||
max-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCoverWrap {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2/3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.confirmTitle {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmSource {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.confirmStats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statVal {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmNote {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
line-height: var(--leading-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migrateBtn {
|
|
||||||
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-md);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
|
||||||
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
|
||||||
}
|
|
||||||
/* ── Source context pill (step 2 header) ── */
|
|
||||||
.searchContext {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchContextIcon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchContextName {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchContextChange {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.searchContextChange:hover { opacity: 0.75; }
|
|
||||||
|
|
||||||
/* ── Result row: updated layout with similarity ── */
|
|
||||||
.resultInfo {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultMeta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bestMatchBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simBar {
|
|
||||||
width: 48px;
|
|
||||||
height: 3px;
|
|
||||||
background: var(--bg-overlay);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simFill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simLabel {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Confirm step additions ── */
|
|
||||||
.confirmDivider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmTag {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmTagNew {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statGood { color: var(--color-success) !important; }
|
|
||||||
.statWarn { color: #d97706 !important; }
|
|
||||||
.statBad { color: var(--color-error) !important; }
|
|
||||||
|
|
||||||
.chapterDiff {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: #d97706;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.warnBox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
background: rgba(217, 119, 6, 0.08);
|
|
||||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: #d97706;
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
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 s from "./MigrateModal.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
manga: Manga;
|
|
||||||
currentChapters: Chapter[];
|
|
||||||
onClose: () => void;
|
|
||||||
onMigrated: (newManga: Manga) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = "source" | "search" | "confirm";
|
|
||||||
|
|
||||||
interface Match {
|
|
||||||
manga: Manga;
|
|
||||||
chapters: Chapter[];
|
|
||||||
readCount: number;
|
|
||||||
similarity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple title similarity: normalise → word overlap / Jaccard
|
|
||||||
function titleSimilarity(a: string, b: string): number {
|
|
||||||
const norm = (s: string) =>
|
|
||||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
|
||||||
const wordsA = new Set(norm(a));
|
|
||||||
const wordsB = new Set(norm(b));
|
|
||||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
||||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
|
||||||
const union = new Set([...wordsA, ...wordsB]).size;
|
|
||||||
return intersection / union;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
|
||||||
const [step, setStep] = useState<Step>("source");
|
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
|
||||||
const [loadingSources, setLoadingSources] = useState(true);
|
|
||||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
|
||||||
const [query, setQuery] = useState(manga.title);
|
|
||||||
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
|
||||||
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
|
||||||
const [migrating, setMigrating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id)))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoadingSources(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchSource = useCallback(async (src: Source, q: string) => {
|
|
||||||
if (!src || !q.trim()) return;
|
|
||||||
setSearching(true);
|
|
||||||
setResults([]);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
|
||||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
|
||||||
});
|
|
||||||
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
|
||||||
manga: m,
|
|
||||||
similarity: titleSimilarity(manga.title, m.title),
|
|
||||||
}));
|
|
||||||
// Sort by similarity desc so best matches float to top
|
|
||||||
scored.sort((a, b) => b.similarity - a.similarity);
|
|
||||||
setResults(scored);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
}, [manga.title]);
|
|
||||||
|
|
||||||
function pickSource(src: Source) {
|
|
||||||
setSelectedSource(src);
|
|
||||||
setStep("search");
|
|
||||||
// Auto-search immediately with original title
|
|
||||||
searchSource(src, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMatch(m: Manga, similarity: number) {
|
|
||||||
setLoadingMatchId(m.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
|
||||||
const chapters = d.fetchChapters.chapters;
|
|
||||||
const readCount = chapters.filter((c) => {
|
|
||||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
|
||||||
return old?.isRead;
|
|
||||||
}).length;
|
|
||||||
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
|
||||||
setStep("confirm");
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setLoadingMatchId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
if (!selectedMatch) return;
|
|
||||||
setMigrating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
|
||||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
|
||||||
|
|
||||||
const toMarkRead: number[] = [];
|
|
||||||
const toMarkBookmarked: number[] = [];
|
|
||||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
|
||||||
|
|
||||||
for (const nc of newChapters) {
|
|
||||||
const key = Math.round(nc.chapterNumber * 100);
|
|
||||||
const old = oldByNum.get(key);
|
|
||||||
if (!old) continue;
|
|
||||||
if (old.isRead) toMarkRead.push(nc.id);
|
|
||||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
|
||||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
|
||||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toMarkRead.length)
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
|
||||||
if (toMarkBookmarked.length)
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
|
||||||
for (const { id, lastPageRead } of progressUpdates)
|
|
||||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
|
||||||
|
|
||||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
|
||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
|
||||||
|
|
||||||
onMigrated({ ...newManga, inLibrary: true });
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
setMigrating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
|
||||||
const totalCount = currentChapters.length;
|
|
||||||
|
|
||||||
const chapterDiff = selectedMatch
|
|
||||||
? selectedMatch.chapters.length - totalCount
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const STEPS: Step[] = ["source", "search", "confirm"];
|
|
||||||
const stepIdx = STEPS.indexOf(step);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
||||||
<div className={s.modal}>
|
|
||||||
|
|
||||||
{/* ── Header ── */}
|
|
||||||
<div className={s.modalHeader}>
|
|
||||||
<div className={s.modalTitle}>
|
|
||||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
|
||||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
|
||||||
</div>
|
|
||||||
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Step indicators ── */}
|
|
||||||
<div className={s.steps}>
|
|
||||||
{STEPS.map((st, i) => (
|
|
||||||
<div key={st}
|
|
||||||
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
|
||||||
<span className={s.stepDot}>
|
|
||||||
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
|
||||||
</span>
|
|
||||||
<span className={s.stepLabel}>
|
|
||||||
{st === "source" ? "Pick source"
|
|
||||||
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
|
||||||
: "Confirm"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.body}>
|
|
||||||
|
|
||||||
{/* ── Step 1: Pick source ── */}
|
|
||||||
{step === "source" && (
|
|
||||||
<div className={s.sourceList}>
|
|
||||||
{loadingSources ? (
|
|
||||||
<div className={s.centered}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : sources.length === 0 ? (
|
|
||||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
|
||||||
) : (
|
|
||||||
sources.map((src) => (
|
|
||||||
<button key={src.id}
|
|
||||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
|
||||||
onClick={() => pickSource(src)}>
|
|
||||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<div className={s.sourceInfo}>
|
|
||||||
<span className={s.sourceName}>{src.displayName}</span>
|
|
||||||
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
|
||||||
</div>
|
|
||||||
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2: Search & pick match ── */}
|
|
||||||
{step === "search" && (
|
|
||||||
<div className={s.searchStep}>
|
|
||||||
|
|
||||||
{/* Source context pill */}
|
|
||||||
{selectedSource && (
|
|
||||||
<div className={s.searchContext}>
|
|
||||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
||||||
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
|
||||||
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
|
||||||
Change
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={s.searchRow}>
|
|
||||||
<div className={s.searchBar}>
|
|
||||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
|
||||||
<input className={s.searchInput} value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
|
||||||
placeholder="Search title…"
|
|
||||||
autoFocus />
|
|
||||||
</div>
|
|
||||||
<button className={s.searchBtn}
|
|
||||||
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
|
||||||
disabled={searching || !selectedSource}>
|
|
||||||
{searching
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
|
||||||
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
|
||||||
|
|
||||||
<div className={s.results}>
|
|
||||||
{searching && Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.skResult}>
|
|
||||||
<div className={["skeleton", s.skCover].join(" ")} />
|
|
||||||
<div className={s.skMeta}>
|
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
|
||||||
<button key={m.id} className={s.resultRow}
|
|
||||||
onClick={() => selectMatch(m, similarity)}
|
|
||||||
disabled={loadingMatchId !== null}>
|
|
||||||
<div className={s.resultCoverWrap}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
|
||||||
</div>
|
|
||||||
<div className={s.resultInfo}>
|
|
||||||
<span className={s.resultTitle}>{m.title}</span>
|
|
||||||
<div className={s.resultMeta}>
|
|
||||||
{idx === 0 && similarity > 0.5 && (
|
|
||||||
<span className={s.bestMatchBadge}>
|
|
||||||
<Sparkle size={9} weight="fill" /> Best match
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={s.simBar}>
|
|
||||||
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
|
||||||
</span>
|
|
||||||
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{loadingMatchId === m.id
|
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
|
||||||
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!searching && results.length === 0 && !error && (
|
|
||||||
<div className={s.centered}>
|
|
||||||
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 3: Confirm ── */}
|
|
||||||
{step === "confirm" && selectedMatch && (
|
|
||||||
<div className={s.confirmStep}>
|
|
||||||
<div className={s.confirmRow}>
|
|
||||||
<div className={s.confirmManga}>
|
|
||||||
<div className={s.confirmCoverWrap}>
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
|
||||||
</div>
|
|
||||||
<p className={s.confirmTitle}>{manga.title}</p>
|
|
||||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
|
||||||
<span className={s.confirmTag}>Current</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.confirmDivider}>
|
|
||||||
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.confirmManga}>
|
|
||||||
<div className={s.confirmCoverWrap}>
|
|
||||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
|
||||||
</div>
|
|
||||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
|
||||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
|
||||||
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.confirmStats}>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Title match</span>
|
|
||||||
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
|
||||||
{Math.round(selectedMatch.similarity * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Chapters on new source</span>
|
|
||||||
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
|
||||||
{selectedMatch.chapters.length}
|
|
||||||
{chapterDiff !== 0 && (
|
|
||||||
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.statRow}>
|
|
||||||
<span className={s.statLabel}>Read progress to carry over</span>
|
|
||||||
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chapterDiff < -5 && (
|
|
||||||
<div className={s.warnBox}>
|
|
||||||
<Warning size={13} weight="light" />
|
|
||||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className={s.confirmNote}>
|
|
||||||
The current entry will be removed from your library. Downloads are not transferred.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
|
||||||
|
|
||||||
<div className={s.confirmActions}>
|
|
||||||
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
|
||||||
{migrating
|
|
||||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
|
||||||
: <><Check size={13} weight="bold" /> Migrate</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
.root {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: #000;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
z-index: var(--z-reader);
|
|
||||||
transform: translateZ(0); will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── UI autohide ── */
|
|
||||||
.uiHidden {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
.topbar, .bottombar {
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Topbar ── */
|
|
||||||
.topbar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-3); height: 40px;
|
|
||||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0; overflow: visible;
|
|
||||||
position: relative; z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.2; cursor: default; }
|
|
||||||
|
|
||||||
.chLabel {
|
|
||||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
|
||||||
.chSep { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.pageLabel {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topSep {
|
|
||||||
width: 1px; height: 16px;
|
|
||||||
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modeBtn {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-muted); flex-shrink: 0;
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.modeBtnLabel { text-transform: capitalize; }
|
|
||||||
|
|
||||||
/* ── Zoom ── */
|
|
||||||
.zoomWrap {
|
|
||||||
position: relative; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
|
||||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
min-width: 36px; text-align: center;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.zoomPopover {
|
|
||||||
position: absolute; top: calc(100% + 6px); left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
||||||
z-index: 100; min-width: 160px;
|
|
||||||
animation: scaleIn 0.1s ease both; transform-origin: top center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomSlider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 140px; height: 3px;
|
|
||||||
background: var(--border-strong);
|
|
||||||
border-radius: 2px; outline: none; cursor: pointer;
|
|
||||||
}
|
|
||||||
.zoomSlider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.zoomSlider::-moz-range-thumb {
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%; border: none;
|
|
||||||
background: var(--accent-fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoomResetBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 2px var(--sp-2); border-radius: var(--radius-sm);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
|
||||||
|
|
||||||
/* ── Viewer ── */
|
|
||||||
.viewer {
|
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.viewerStrip {
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: var(--sp-4) 0;
|
|
||||||
overflow-anchor: auto; /* browser preserves scroll pos when nodes are added/removed above */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Images ── */
|
|
||||||
.img {
|
|
||||||
display: block; user-select: none;
|
|
||||||
image-rendering: auto;
|
|
||||||
}
|
|
||||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
|
||||||
|
|
||||||
/* Fit modes.
|
|
||||||
height: auto on .img is the load-bearing rule: the img element is given
|
|
||||||
height={1000} as a layout hint while the image is loading (prevents reflow).
|
|
||||||
Once the image is fully painted the browser must resolve height from the
|
|
||||||
intrinsic dimensions, not the HTML attribute — `height: auto` enforces that. */
|
|
||||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
|
||||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
|
||||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
|
||||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
|
||||||
|
|
||||||
/* Longstrip */
|
|
||||||
.stripGap { margin-bottom: 8px; }
|
|
||||||
|
|
||||||
/* ── Double page ── */
|
|
||||||
.doubleWrap {
|
|
||||||
display: flex; align-items: flex-start; justify-content: center;
|
|
||||||
max-width: calc(var(--max-page-width) * 2);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
|
|
||||||
.gapLeft { margin-right: 2px; }
|
|
||||||
.gapRight { margin-left: 2px; }
|
|
||||||
|
|
||||||
/* ── Bottom nav ── */
|
|
||||||
.bottombar {
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
|
|
||||||
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-void); flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 34px; height: 34px; border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-strong); color: var(--text-muted);
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
|
||||||
.navBtn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
/* ── States ── */
|
|
||||||
.center {
|
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
position: fixed; inset: 0; background: #000;
|
|
||||||
}
|
|
||||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
|
||||||
|
|
||||||
/* ── Download modal ── */
|
|
||||||
.dlBackdrop {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
z-index: calc(var(--z-reader) + 10);
|
|
||||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
|
||||||
padding: 48px var(--sp-4) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlModal {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-xl); padding: var(--sp-3);
|
|
||||||
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
||||||
animation: scaleIn 0.12s ease both; transform-origin: top right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlTitle {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
|
|
||||||
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlOption {
|
|
||||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
|
||||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
background: none; border: none; cursor: pointer; text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.dlOption:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
|
|
||||||
|
|
||||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.dlStepper {
|
|
||||||
display: flex; align-items: center; gap: 2px;
|
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlStepBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 22px; height: 28px;
|
|
||||||
font-size: var(--text-base); color: var(--text-muted);
|
|
||||||
background: none; border: none; cursor: pointer; line-height: 1;
|
|
||||||
transition: color var(--t-fast), background var(--t-fast);
|
|
||||||
}
|
|
||||||
.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
|
||||||
.dlStepBtn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
.dlStepVal {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary); min-width: 24px; text-align: center;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
/* Viewer focus — suppress outline since we're handling keys ourselves */
|
|
||||||
.viewer:focus { outline: none; }
|
|
||||||
@@ -1,989 +0,0 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
|
||||||
Square, Rows, Download, ArrowsLeftRight,
|
|
||||||
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import {
|
|
||||||
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
|
|
||||||
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
|
|
||||||
} from "../../lib/queries";
|
|
||||||
import { useStore, type FitMode } from "../../store";
|
|
||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS, type Keybinds } from "../../lib/keybinds";
|
|
||||||
import s from "./Reader.module.css";
|
|
||||||
|
|
||||||
// ── Page cache (module-level, survives re-renders) ────────────────────────────
|
|
||||||
const pageCache = new Map<number, string[]>();
|
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
|
||||||
const cacheOrder: number[] = [];
|
|
||||||
const MAX_CACHED = 10;
|
|
||||||
|
|
||||||
function cacheTouch(id: number) {
|
|
||||||
const i = cacheOrder.indexOf(id);
|
|
||||||
if (i !== -1) cacheOrder.splice(i, 1);
|
|
||||||
cacheOrder.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheEvict(keep: Set<number>) {
|
|
||||||
while (pageCache.size > MAX_CACHED) {
|
|
||||||
const victim = cacheOrder.find((id) => !keep.has(id));
|
|
||||||
if (!victim) break;
|
|
||||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
|
||||||
pageCache.delete(victim);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
|
||||||
const cached = pageCache.get(chapterId);
|
|
||||||
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
|
|
||||||
|
|
||||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
|
|
||||||
if (!inflight.has(chapterId)) {
|
|
||||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(
|
|
||||||
FETCH_CHAPTER_PAGES, { chapterId },
|
|
||||||
).then((d) => {
|
|
||||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
|
||||||
pageCache.set(chapterId, urls);
|
|
||||||
cacheTouch(chapterId);
|
|
||||||
return urls;
|
|
||||||
}).finally(() => inflight.delete(chapterId));
|
|
||||||
inflight.set(chapterId, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = inflight.get(chapterId)!;
|
|
||||||
|
|
||||||
if (!signal) return base;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
|
||||||
base.then(resolve, reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Image helpers ─────────────────────────────────────────────────────────────
|
|
||||||
const aspectCache = new Map<string, number>();
|
|
||||||
|
|
||||||
function preloadImage(url: string) { new Image().src = url; }
|
|
||||||
|
|
||||||
function decodeImage(url: string): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
|
||||||
img.onerror = () => resolve();
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureAspect(url: string): Promise<number> {
|
|
||||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
|
||||||
return new Promise((res) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
// Guard against 0 dimensions (image not fully decoded yet) and NaN
|
|
||||||
const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
|
||||||
aspectCache.set(url, ratio);
|
|
||||||
res(ratio);
|
|
||||||
};
|
|
||||||
img.onerror = () => res(0.67);
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Download modal ────────────────────────────────────────────────────────────
|
|
||||||
function DownloadModal({
|
|
||||||
chapter, remaining, onClose,
|
|
||||||
}: {
|
|
||||||
chapter: { id: number; name: string; isDownloaded?: boolean };
|
|
||||||
remaining: { id: number; isDownloaded?: boolean }[];
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const addToast = useStore((s) => s.addToast);
|
|
||||||
const [nextN, setNextN] = useState(5);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const queueable = remaining.filter((c) => !c.isDownloaded);
|
|
||||||
const alreadyDl = !!chapter.isDownloaded;
|
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>, toastBody: string) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
addToast({ kind: "download", title: "Download queued", body: toastBody });
|
|
||||||
} catch (e) {
|
|
||||||
addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
setBusy(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.dlBackdrop} onClick={onClose}>
|
|
||||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<p className={s.dlTitle}>Download</p>
|
|
||||||
<button className={s.dlOption} disabled={busy || alreadyDl}
|
|
||||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }), alreadyDl ? "" : chapter.name)}>
|
|
||||||
This chapter
|
|
||||||
<span className={s.dlSub}>{alreadyDl ? "Already downloaded" : chapter.name}</span>
|
|
||||||
</button>
|
|
||||||
<div className={s.dlRow}>
|
|
||||||
<button className={s.dlOption} disabled={busy || queueable.length === 0}
|
|
||||||
onClick={() => run(
|
|
||||||
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map((c) => c.id) }),
|
|
||||||
`${Math.min(nextN, queueable.length)} chapters queued`,
|
|
||||||
)}>
|
|
||||||
Next chapters
|
|
||||||
<span className={s.dlSub}>{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button className={s.dlStepBtn} onClick={() => setNextN((n) => Math.max(1, n - 1))} disabled={nextN <= 1}>−</button>
|
|
||||||
<span className={s.dlStepVal}>{nextN}</span>
|
|
||||||
<button className={s.dlStepBtn} onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))} disabled={nextN >= queueable.length}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className={s.dlOption} disabled={busy || queueable.length === 0}
|
|
||||||
onClick={() => run(
|
|
||||||
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map((c) => c.id) }),
|
|
||||||
`${queueable.length} chapter${queueable.length !== 1 ? "s" : ""} queued`,
|
|
||||||
)}>
|
|
||||||
All remaining
|
|
||||||
<span className={s.dlSub}>{queueable.length} not yet downloaded</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zoom popover ──────────────────────────────────────────────────────────────
|
|
||||||
function ZoomPopover({ value, onChange, onReset, onClose }: {
|
|
||||||
value: number; onChange: (v: number) => void; onReset: () => void; onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
|
|
||||||
document.addEventListener("mousedown", h);
|
|
||||||
return () => document.removeEventListener("mousedown", h);
|
|
||||||
}, [onClose]);
|
|
||||||
return (
|
|
||||||
<div className={s.zoomPopover} ref={ref}>
|
|
||||||
<input type="range" className={s.zoomSlider} min={200} max={2400} step={50} value={value}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))} />
|
|
||||||
<button className={s.zoomResetBtn} onClick={onReset}>{Math.round((value / 900) * 100)}%</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
interface StripChapter {
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
urls: string[];
|
|
||||||
startGlobalIdx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
|
||||||
export default function Reader() {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const settingsRef = useRef<typeof settings | null>(null);
|
|
||||||
const chapterListRef = useRef<typeof activeChapterList>([]);
|
|
||||||
const loadingIdRef = useRef<number | null>(null);
|
|
||||||
const markedReadRef = useRef<Set<number>>(new Set());
|
|
||||||
const appendedRef = useRef<Set<number>>(new Set());
|
|
||||||
const appendingRef = useRef(false);
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const visibleChapterRef = useRef<number | null>(null);
|
|
||||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
|
||||||
const pageUrlsRef = useRef<string[]>([]);
|
|
||||||
const activeChapterRef = useRef<typeof activeChapter>(null);
|
|
||||||
const markReadOnNextRef = useRef(true);
|
|
||||||
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
|
|
||||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
|
||||||
const [zoomOpen, setZoomOpen] = useState(false);
|
|
||||||
const [uiVisible, setUiVisible] = useState(true);
|
|
||||||
const [pageReady, setPageReady] = useState(false);
|
|
||||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
|
||||||
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
|
|
||||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
stripChaptersRef.current = stripChapters;
|
|
||||||
|
|
||||||
// Restore scroll position synchronously after a head-trim, before paint.
|
|
||||||
// This is the only reliable way to prevent the visible jump — rAF fires
|
|
||||||
// one frame too late and the user sees the incorrect position briefly.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const anchor = scrollAnchorRef.current;
|
|
||||||
if (!anchor || !containerRef.current) return;
|
|
||||||
scrollAnchorRef.current = null;
|
|
||||||
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
|
|
||||||
// gained is negative when nodes were removed (scrollHeight shrank).
|
|
||||||
// Subtract the same amount from scrollTop so visible content stays put.
|
|
||||||
if (gained < 0) {
|
|
||||||
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
|
|
||||||
}
|
|
||||||
}, [stripChapters]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
activeManga, activeChapter, activeChapterList,
|
|
||||||
pageUrls, pageNumber, settings,
|
|
||||||
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
|
|
||||||
updateSettings, addHistory,
|
|
||||||
} = useStore();
|
|
||||||
|
|
||||||
const rtl = settings.readingDirection === "rtl";
|
|
||||||
const fit = settings.fitMode ?? "width";
|
|
||||||
const style = settings.pageStyle ?? "single";
|
|
||||||
const maxW = settings.maxPageWidth ?? 900;
|
|
||||||
const autoNext = settings.autoNextChapter ?? false;
|
|
||||||
const markReadOnNext = settings.markReadOnNext ?? true;
|
|
||||||
|
|
||||||
settingsRef.current = settings;
|
|
||||||
chapterListRef.current = activeChapterList;
|
|
||||||
pageUrlsRef.current = pageUrls;
|
|
||||||
activeChapterRef.current = activeChapter;
|
|
||||||
markReadOnNextRef.current = markReadOnNext;
|
|
||||||
|
|
||||||
// Mark the current chapter read when the user manually skips to another chapter.
|
|
||||||
// Uses refs only — safe to call from any callback without stale-closure issues.
|
|
||||||
// markReadOnNext gates this; autoNextChapter does NOT block it because a manual
|
|
||||||
// chapter-skip is always intentional regardless of the auto-advance setting.
|
|
||||||
const maybeMarkCurrentRead = useCallback(() => {
|
|
||||||
const ch = activeChapterRef.current;
|
|
||||||
if (!ch) return;
|
|
||||||
if (!markReadOnNextRef.current) return;
|
|
||||||
if (markedReadRef.current.has(ch.id)) return;
|
|
||||||
markedReadRef.current.add(ch.id);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true }).catch((e) => {
|
|
||||||
markedReadRef.current.delete(ch.id);
|
|
||||||
console.error("MARK_CHAPTER_READ (manual next) failed:", e);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── UI autohide ──────────────────────────────────────────────────────────────
|
|
||||||
const showUi = useCallback(() => {
|
|
||||||
setUiVisible(true);
|
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
||||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showUi();
|
|
||||||
return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { containerRef.current?.focus({ preventScroll: true }); }, [activeChapter?.id]);
|
|
||||||
|
|
||||||
// ── Load chapter ─────────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeChapter) {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
appendedRef.current = new Set();
|
|
||||||
appendingRef.current = false;
|
|
||||||
markedReadRef.current = new Set();
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
visibleChapterRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
const targetId = activeChapter.id;
|
|
||||||
loadingIdRef.current = targetId;
|
|
||||||
appendedRef.current = new Set([targetId]);
|
|
||||||
appendingRef.current = false;
|
|
||||||
markedReadRef.current = new Set();
|
|
||||||
// Clear stale aspect ratios — server URLs can return different images
|
|
||||||
// after a re-fetch, and a stale cached ratio renders as a black/collapsed img.
|
|
||||||
aspectCache.clear();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setPageGroups([]);
|
|
||||||
setPageReady(false);
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
visibleChapterRef.current = null;
|
|
||||||
|
|
||||||
fetchPages(targetId, ctrl.signal)
|
|
||||||
.then(async (urls) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
// Don't block the render on decoding — set URLs immediately so the
|
|
||||||
// browser can start painting the first image without waiting for the
|
|
||||||
// full decode. The img element's own decoding="async" handles the rest.
|
|
||||||
setPageUrls(urls);
|
|
||||||
setPageReady(true);
|
|
||||||
if (style === "longstrip" && autoNext) {
|
|
||||||
const firstChunk: StripChapter = {
|
|
||||||
chapterId: targetId,
|
|
||||||
chapterName: activeChapter.name,
|
|
||||||
urls,
|
|
||||||
startGlobalIdx: 0,
|
|
||||||
};
|
|
||||||
setStripChapters([firstChunk]);
|
|
||||||
setVisibleChapterId(targetId);
|
|
||||||
visibleChapterRef.current = targetId;
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [activeChapter?.id]);
|
|
||||||
|
|
||||||
// ── Append next chapter to the strip ────────────────────────────────────────
|
|
||||||
const appendNextChapter = useCallback(() => {
|
|
||||||
if (appendingRef.current) return;
|
|
||||||
|
|
||||||
const strip = stripChaptersRef.current;
|
|
||||||
const lastChunk = strip[strip.length - 1];
|
|
||||||
if (!lastChunk) return;
|
|
||||||
|
|
||||||
const list = chapterListRef.current;
|
|
||||||
const lastIdx = list.findIndex((c) => c.id === lastChunk.chapterId);
|
|
||||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
|
||||||
|
|
||||||
const nextEntry = list[lastIdx + 1];
|
|
||||||
if (!nextEntry || appendedRef.current.has(nextEntry.id)) return;
|
|
||||||
|
|
||||||
appendedRef.current.add(nextEntry.id);
|
|
||||||
appendingRef.current = true;
|
|
||||||
|
|
||||||
fetchPages(nextEntry.id)
|
|
||||||
.then((urls) => {
|
|
||||||
// Kick off aspect measurement in background — don't block appending on it
|
|
||||||
urls.forEach((url) => measureAspect(url).catch(() => {}));
|
|
||||||
// Ensure the first several images are already in the browser cache
|
|
||||||
// by the time React renders them — eliminates the blank-image flash
|
|
||||||
// that occurs when a freshly appended chapter hasn't been prefetched.
|
|
||||||
urls.slice(0, 6).forEach(preloadImage);
|
|
||||||
return urls;
|
|
||||||
})
|
|
||||||
.then((urls) => {
|
|
||||||
setStripChapters((cur) => {
|
|
||||||
if (cur.some((c) => c.chapterId === nextEntry.id)) return cur;
|
|
||||||
|
|
||||||
const last = cur[cur.length - 1];
|
|
||||||
const newStart = last ? last.startGlobalIdx + last.urls.length : 0;
|
|
||||||
const updated = [...cur, {
|
|
||||||
chapterId: nextEntry.id,
|
|
||||||
chapterName: nextEntry.name,
|
|
||||||
urls,
|
|
||||||
startGlobalIdx: newStart,
|
|
||||||
}];
|
|
||||||
|
|
||||||
if (updated.length > 3) {
|
|
||||||
// Snapshot scroll position BEFORE React removes the nodes.
|
|
||||||
// useLayoutEffect will restore it synchronously after the DOM
|
|
||||||
// mutation, preventing any visible jump.
|
|
||||||
if (containerRef.current) {
|
|
||||||
scrollAnchorRef.current = {
|
|
||||||
scrollTop: containerRef.current.scrollTop,
|
|
||||||
scrollHeight: containerRef.current.scrollHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updated.slice(-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
appendingRef.current = false;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("appendNextChapter failed:", err);
|
|
||||||
appendedRef.current.delete(nextEntry.id);
|
|
||||||
appendingRef.current = false;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Longstrip: scroll-driven page + chapter tracking + mark-as-read ──────────
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el || style !== "longstrip") return;
|
|
||||||
|
|
||||||
const READ_LINE_PCT = 0.20;
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
const containerTop = el.getBoundingClientRect().top;
|
|
||||||
const readLineY = containerTop + el.clientHeight * READ_LINE_PCT;
|
|
||||||
const imgs = el.querySelectorAll<HTMLElement>("img[data-local-page]");
|
|
||||||
|
|
||||||
let activeLocalPage: number | null = null;
|
|
||||||
let activeChId: number | null = null;
|
|
||||||
|
|
||||||
for (const img of imgs) {
|
|
||||||
const rect = img.getBoundingClientRect();
|
|
||||||
if (rect.top <= readLineY) {
|
|
||||||
activeLocalPage = Number(img.dataset.localPage);
|
|
||||||
activeChId = Number(img.dataset.chapter);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeLocalPage === null && imgs.length > 0) {
|
|
||||||
activeLocalPage = Number(imgs[0].dataset.localPage);
|
|
||||||
activeChId = Number(imgs[0].dataset.chapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeLocalPage !== null) setPageNumber(activeLocalPage);
|
|
||||||
|
|
||||||
if (activeChId && activeChId !== visibleChapterRef.current) {
|
|
||||||
visibleChapterRef.current = activeChId;
|
|
||||||
setVisibleChapterId(activeChId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsRef.current?.autoMarkRead && activeLocalPage !== null && activeChId) {
|
|
||||||
const strip = stripChaptersRef.current;
|
|
||||||
const chunk = strip.find((c) => c.chapterId === activeChId);
|
|
||||||
const total = chunk ? chunk.urls.length : pageUrlsRef.current.length;
|
|
||||||
if (total > 0 && activeLocalPage >= total - 1) {
|
|
||||||
const ch = activeChId;
|
|
||||||
if (!markedReadRef.current.has(ch)) {
|
|
||||||
markedReadRef.current.add(ch);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: ch, isRead: true }).catch((e) => {
|
|
||||||
markedReadRef.current.delete(ch);
|
|
||||||
console.error("MARK_CHAPTER_READ failed for chapter", ch, e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
onScroll();
|
|
||||||
return () => el.removeEventListener("scroll", onScroll);
|
|
||||||
}, [style]);
|
|
||||||
|
|
||||||
// ── Longstrip: sentinel triggers append ──────────────────────────────────────
|
|
||||||
// activeChapter?.id in deps ensures the observer reinstalls fresh on every
|
|
||||||
// manga switch — without it, switching manga reuses the stale observer which
|
|
||||||
// has already fired and won't re-fire for the new chapter's sentinel position.
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!sentinel || !el || style !== "longstrip" || !autoNext) return;
|
|
||||||
if (stripChapters.length === 0) return;
|
|
||||||
|
|
||||||
// Trigger append when the user has scrolled through 80% of the current
|
|
||||||
// strip — early enough that the next chapter is ready before they reach
|
|
||||||
// the end. A fixed-pixel rootMargin can't express "80% of scrollHeight"
|
|
||||||
// so we use a scroll listener for the threshold check, and keep the
|
|
||||||
// IntersectionObserver only as a fallback for the absolute bottom.
|
|
||||||
const onScroll80 = () => {
|
|
||||||
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
|
||||||
if (pct >= 0.8) appendNextChapter();
|
|
||||||
};
|
|
||||||
el.addEventListener("scroll", onScroll80, { passive: true });
|
|
||||||
|
|
||||||
// IntersectionObserver as hard backstop at the very bottom
|
|
||||||
const obs = new IntersectionObserver(([entry]) => {
|
|
||||||
if (!entry.isIntersecting) return;
|
|
||||||
appendNextChapter();
|
|
||||||
}, { root: el, rootMargin: "0px", threshold: 0 });
|
|
||||||
|
|
||||||
obs.observe(sentinel);
|
|
||||||
|
|
||||||
// Double-rAF ensures real image heights are committed before we measure.
|
|
||||||
// Fires the 80% check once on mount so short/cached chapters that never
|
|
||||||
// produce a scroll event still trigger an append.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
|
||||||
if (pct >= 0.8) appendNextChapter();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { obs.disconnect(); el.removeEventListener("scroll", onScroll80); };
|
|
||||||
}, [style, autoNext, stripChapters.length, activeChapter?.id, appendNextChapter]);
|
|
||||||
// ^^^^^^^^^^^^^^^^^ reinstall on manga switch
|
|
||||||
|
|
||||||
// ── Mark last chapter read when reaching the very bottom ─────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el || style !== "longstrip") return;
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return;
|
|
||||||
const last = stripChaptersRef.current[stripChaptersRef.current.length - 1];
|
|
||||||
if (!last) return;
|
|
||||||
if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) {
|
|
||||||
markedReadRef.current.add(last.chapterId);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener("scroll", onScroll);
|
|
||||||
}, [style]);
|
|
||||||
|
|
||||||
// Rebuild strip when autoNext is toggled while longstrip is active
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== "longstrip" || !pageUrls.length || !activeChapter) return;
|
|
||||||
appendedRef.current = new Set([activeChapter.id]);
|
|
||||||
appendingRef.current = false;
|
|
||||||
if (autoNext) {
|
|
||||||
setStripChapters([{
|
|
||||||
chapterId: activeChapter.id,
|
|
||||||
chapterName: activeChapter.name,
|
|
||||||
urls: pageUrls,
|
|
||||||
startGlobalIdx: 0,
|
|
||||||
}]);
|
|
||||||
setVisibleChapterId(activeChapter.id);
|
|
||||||
visibleChapterRef.current = activeChapter.id;
|
|
||||||
} else {
|
|
||||||
setStripChapters([]);
|
|
||||||
setVisibleChapterId(null);
|
|
||||||
visibleChapterRef.current = null;
|
|
||||||
}
|
|
||||||
if (containerRef.current) containerRef.current.scrollTop = 0;
|
|
||||||
}, [autoNext, style]);
|
|
||||||
|
|
||||||
// Reset scroll on non-longstrip page change
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
|
||||||
}, [pageNumber, style]);
|
|
||||||
|
|
||||||
// Always scroll to top when a new chapter opens — even if pageNumber stays at 1
|
|
||||||
// (navigating chapter→chapter while already on page 1 won't trigger the effect above).
|
|
||||||
useEffect(() => {
|
|
||||||
if (containerRef.current) containerRef.current.scrollTop = 0;
|
|
||||||
}, [activeChapter?.id]);
|
|
||||||
|
|
||||||
// ── Preload adjacent pages ───────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const ahead = settings.preloadPages ?? 3;
|
|
||||||
for (let i = 1; i <= ahead; i++) {
|
|
||||||
const url = pageUrls[pageNumber - 1 + i];
|
|
||||||
if (url) decodeImage(url);
|
|
||||||
}
|
|
||||||
const behind = pageUrls[pageNumber - 2];
|
|
||||||
if (behind) preloadImage(behind);
|
|
||||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
|
||||||
|
|
||||||
// ── Derived display values ───────────────────────────────────────────────────
|
|
||||||
const lastPage = pageUrls.length;
|
|
||||||
|
|
||||||
const displayChapter = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
|
|
||||||
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
|
|
||||||
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
|
|
||||||
|
|
||||||
// ── Adjacent chapters + cache eviction ──────────────────────────────────────
|
|
||||||
const adjacent = useMemo(() => {
|
|
||||||
const ref = displayChapter ?? activeChapter;
|
|
||||||
if (!ref || !activeChapterList.length)
|
|
||||||
return { prev: null, next: null, remaining: [] };
|
|
||||||
const idx = activeChapterList.findIndex((c) => c.id === ref.id);
|
|
||||||
return {
|
|
||||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
|
||||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
|
||||||
remaining: activeChapterList.slice(idx + 1),
|
|
||||||
};
|
|
||||||
}, [displayChapter, activeChapter, activeChapterList]);
|
|
||||||
|
|
||||||
// ── Prefetch next 3 chapters into pageCache so strip appends are instant ────
|
|
||||||
// Fires whenever the active chapter changes. Fetches page URL lists for the
|
|
||||||
// next 3 chapters in the background so appendNextChapter always gets a cache
|
|
||||||
// hit instead of waiting on a network round-trip.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeChapter || !activeChapterList.length) return;
|
|
||||||
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
|
|
||||||
if (idx < 0) return;
|
|
||||||
|
|
||||||
const PREFETCH_AHEAD = 3;
|
|
||||||
const toPin: number[] = [activeChapter.id];
|
|
||||||
|
|
||||||
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
|
|
||||||
const entry = activeChapterList[idx + i];
|
|
||||||
if (!entry) break;
|
|
||||||
toPin.push(entry.id);
|
|
||||||
fetchPages(entry.id)
|
|
||||||
.then((urls) => {
|
|
||||||
// Preload the first several images of every prefetched chapter,
|
|
||||||
// not just the immediate next one — chapters 2–3 ahead would
|
|
||||||
// otherwise start loading cold when appended, causing blank flashes.
|
|
||||||
// Fewer images for farther-ahead chapters to avoid wasting bandwidth.
|
|
||||||
const preloadCount = i === 1 ? 8 : i === 2 ? 4 : 2;
|
|
||||||
urls.slice(0, preloadCount).forEach(preloadImage);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin one chapter behind too so going back is fast
|
|
||||||
if (idx > 0) {
|
|
||||||
const prev = activeChapterList[idx - 1];
|
|
||||||
toPin.push(prev.id);
|
|
||||||
fetchPages(prev.id).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheEvict(new Set(toPin));
|
|
||||||
}, [activeChapter?.id, activeChapterList]);
|
|
||||||
|
|
||||||
const visibleChunkLastPage = useMemo(() => {
|
|
||||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
|
||||||
const chId = visibleChapterId ?? activeChapter?.id;
|
|
||||||
const chunk = stripChapters.find((c) => c.chapterId === chId);
|
|
||||||
return chunk?.urls.length ?? lastPage;
|
|
||||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
|
|
||||||
|
|
||||||
const visibleChunkPage = pageNumber;
|
|
||||||
|
|
||||||
// ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeChapter || !lastPage) return;
|
|
||||||
if (activeManga) {
|
|
||||||
addHistory({
|
|
||||||
mangaId: activeManga.id, mangaTitle: activeManga.title,
|
|
||||||
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
|
|
||||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (style === "longstrip") return;
|
|
||||||
if (settings.autoMarkRead && pageNumber === lastPage) {
|
|
||||||
if (!markedReadRef.current.has(activeChapter.id)) {
|
|
||||||
markedReadRef.current.add(activeChapter.id);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style]);
|
|
||||||
|
|
||||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
|
||||||
let cancelled = false;
|
|
||||||
const snap = pageUrls;
|
|
||||||
Promise.all(snap.map(measureAspect)).then((aspects) => {
|
|
||||||
if (cancelled || snap !== pageUrls) return;
|
|
||||||
const offset = settings.offsetDoubleSpreads;
|
|
||||||
const groups: number[][] = [[1]];
|
|
||||||
if (offset) groups.push([2]);
|
|
||||||
let i = offset ? 3 : 2;
|
|
||||||
while (i <= snap.length) {
|
|
||||||
const a = aspects[i - 1];
|
|
||||||
const nextA = aspects[i] ?? 0;
|
|
||||||
if (a > 1.2 || i === snap.length || nextA > 1.2) {
|
|
||||||
groups.push([i++]);
|
|
||||||
} else {
|
|
||||||
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPageGroups(groups);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
|
||||||
|
|
||||||
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
||||||
const advanceGroup = useCallback((forward: boolean) => {
|
|
||||||
if (!pageGroups.length) return;
|
|
||||||
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
|
||||||
if (forward) {
|
|
||||||
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
|
|
||||||
else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
|
|
||||||
else closeReader();
|
|
||||||
} else {
|
|
||||||
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
|
||||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
|
||||||
}
|
|
||||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
|
||||||
|
|
||||||
const goForward = useCallback(() => {
|
|
||||||
if (loading) return;
|
|
||||||
// Longstrip: bottom arrows always switch chapters, not pages
|
|
||||||
if (style === "longstrip") {
|
|
||||||
if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
|
||||||
if (!pageUrls.length) return;
|
|
||||||
if (pageNumber < lastPage) {
|
|
||||||
decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1));
|
|
||||||
} else if (adjacent.next) {
|
|
||||||
maybeMarkCurrentRead();
|
|
||||||
setPageNumber(1); openReader(adjacent.next, activeChapterList);
|
|
||||||
} else {
|
|
||||||
closeReader();
|
|
||||||
}
|
|
||||||
}, [loading, style, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup, maybeMarkCurrentRead]);
|
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
|
||||||
if (loading) return;
|
|
||||||
// Longstrip: bottom arrows always switch chapters, not pages
|
|
||||||
if (style === "longstrip") {
|
|
||||||
if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
|
||||||
if (!pageUrls.length) return;
|
|
||||||
if (pageNumber > 1) {
|
|
||||||
decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1));
|
|
||||||
} else if (adjacent.prev) {
|
|
||||||
openReader(adjacent.prev, activeChapterList);
|
|
||||||
}
|
|
||||||
}, [loading, style, pageNumber, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup]);
|
|
||||||
|
|
||||||
const goNext = rtl ? goBack : goForward;
|
|
||||||
const goPrev = rtl ? goForward : goBack;
|
|
||||||
|
|
||||||
function cycleStyle() {
|
|
||||||
const opts = ["single", "longstrip"] as const;
|
|
||||||
const cur = style === "double" ? "single" : style;
|
|
||||||
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFit() {
|
|
||||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
|
||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (!e.ctrlKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
|
||||||
};
|
|
||||||
window.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
return () => window.removeEventListener("wheel", onWheel);
|
|
||||||
}, [maxW]);
|
|
||||||
|
|
||||||
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
|
||||||
const goForwardRef = useRef(goForward);
|
|
||||||
const goBackRef = useRef(goBack);
|
|
||||||
const cycleStyleRef = useRef(cycleStyle);
|
|
||||||
useEffect(() => { goForwardRef.current = goForward; }, [goForward]);
|
|
||||||
useEffect(() => { goBackRef.current = goBack; }, [goBack]);
|
|
||||||
useEffect(() => { cycleStyleRef.current = cycleStyle; });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
|
||||||
const kb: Keybinds = settingsRef.current?.keybinds ?? DEFAULT_KEYBINDS;
|
|
||||||
const maxW = settingsRef.current?.maxPageWidth ?? 900;
|
|
||||||
const rtl = settingsRef.current?.readingDirection === "rtl";
|
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (zoomOpen) { setZoomOpen(false); return; }
|
|
||||||
if (dlOpen) { setDlOpen(false); return; }
|
|
||||||
closeReader(); return;
|
|
||||||
}
|
|
||||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, maxW - 100) }); return; }
|
|
||||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
|
||||||
|
|
||||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForwardRef.current(); }
|
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBackRef.current(); }
|
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
|
||||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
|
||||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = chapterListRef.current;
|
|
||||||
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
|
||||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
|
||||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const list = chapterListRef.current;
|
|
||||||
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
|
||||||
const prev = idx > 0 ? list[idx - 1] : null;
|
|
||||||
if (prev) openReader(prev, list);
|
|
||||||
}
|
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyleRef.current(); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
|
||||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [zoomOpen, dlOpen, lastPage, maybeMarkCurrentRead]);
|
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
|
||||||
function handleTap(e: React.MouseEvent) {
|
|
||||||
if (style === "longstrip") return;
|
|
||||||
const x = e.clientX / window.innerWidth;
|
|
||||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
|
||||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
|
|
||||||
const imgCls = [
|
|
||||||
s.img,
|
|
||||||
fit === "width" && s.fitWidth,
|
|
||||||
fit === "height" && s.fitHeight,
|
|
||||||
fit === "screen" && s.fitScreen,
|
|
||||||
fit === "original" && s.fitOriginal,
|
|
||||||
settings.optimizeContrast && s.optimizeContrast,
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
const fitIcon =
|
|
||||||
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
|
||||||
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
|
|
||||||
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
|
|
||||||
<ArrowsOut size={14} weight="light" />;
|
|
||||||
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
|
||||||
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
|
|
||||||
|
|
||||||
const stripToRender: StripChapter[] = style === "longstrip"
|
|
||||||
? (autoNext && stripChapters.length > 0
|
|
||||||
? stripChapters
|
|
||||||
: [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0 }])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root} onMouseMove={(e) => {
|
|
||||||
if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi();
|
|
||||||
}}>
|
|
||||||
{/* ── Topbar ── */}
|
|
||||||
<div className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
|
||||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
|
||||||
<button className={s.iconBtn} onClick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, activeChapterList); } }} disabled={!adjacent.prev} title="Previous chapter">
|
|
||||||
<CaretLeft size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<span className={s.chLabel}>
|
|
||||||
<span className={s.chTitle}>{activeManga?.title}</span>
|
|
||||||
<span className={s.chSep}>/</span>
|
|
||||||
<span>{displayChapter?.name}</span>
|
|
||||||
</span>
|
|
||||||
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
|
||||||
<button className={s.iconBtn} onClick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } }} disabled={!adjacent.next} title="Next chapter">
|
|
||||||
<CaretRight size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
<div className={s.topSep} />
|
|
||||||
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
|
|
||||||
{fitIcon}<span className={s.modeBtnLabel}>{fitLabel}</span>
|
|
||||||
</button>
|
|
||||||
<div className={s.zoomWrap}>
|
|
||||||
<button className={s.zoomBtn} onClick={() => setZoomOpen((o) => !o)} title="Zoom">
|
|
||||||
{Math.round((maxW / 900) * 100)}%
|
|
||||||
</button>
|
|
||||||
{zoomOpen && (
|
|
||||||
<ZoomPopover value={maxW}
|
|
||||||
onChange={(v) => updateSettings({ maxPageWidth: v })}
|
|
||||||
onReset={() => updateSettings({ maxPageWidth: 900 })}
|
|
||||||
onClose={() => setZoomOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })} title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
|
|
||||||
<ArrowsLeftRight size={14} weight="light" /><span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
|
||||||
</button>
|
|
||||||
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
|
|
||||||
{styleIcon}<span className={s.modeBtnLabel}>{style}</span>
|
|
||||||
</button>
|
|
||||||
{style !== "single" && (
|
|
||||||
<button className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ pageGap: !settings.pageGap })} title="Toggle page gap">
|
|
||||||
<span className={s.modeBtnLabel}>Gap</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{style === "longstrip" && (
|
|
||||||
<button className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ autoNextChapter: !autoNext })} title="Auto-advance to next chapter">
|
|
||||||
<span className={s.modeBtnLabel}>Auto</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!autoNext && (
|
|
||||||
<button
|
|
||||||
className={[s.modeBtn, markReadOnNext ? s.modeBtnActive : ""].join(" ")}
|
|
||||||
onClick={() => updateSettings({ markReadOnNext: !markReadOnNext })}
|
|
||||||
title={markReadOnNext
|
|
||||||
? "Mark chapter read when advancing to next (click to disable)"
|
|
||||||
: "Don't mark chapter read on next (click to enable)"}>
|
|
||||||
<span className={s.modeBtnLabel}>Mk.Read</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
|
||||||
<Download size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Viewer ── */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
|
|
||||||
style={cssVars}
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={handleTap}
|
|
||||||
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === " " && style === "longstrip") {
|
|
||||||
e.preventDefault();
|
|
||||||
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<p className={s.errorMsg}>{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{style === "longstrip" ? (
|
|
||||||
<>
|
|
||||||
{stripToRender.map((chunk) =>
|
|
||||||
chunk.urls.map((url, i) => {
|
|
||||||
const localPage = i + 1;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={`${chunk.chapterId}-${i}`}
|
|
||||||
src={url}
|
|
||||||
alt={`${chunk.chapterName} – Page ${localPage}`}
|
|
||||||
data-local-page={localPage}
|
|
||||||
data-chapter={chunk.chapterId}
|
|
||||||
data-total={chunk.urls.length}
|
|
||||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
|
||||||
loading={i < 3 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
height={1000}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
<div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} />
|
|
||||||
</>
|
|
||||||
) : (pageReady && (
|
|
||||||
<img
|
|
||||||
src={pageUrls[pageNumber - 1]}
|
|
||||||
alt={`Page ${pageNumber}`}
|
|
||||||
className={imgCls}
|
|
||||||
decoding="async"
|
|
||||||
style={{ transition: "opacity 0.1s ease" }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
|
||||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
|
||||||
<button className={s.navBtn} onClick={goPrev}
|
|
||||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (pageNumber === 1 && !adjacent.prev))}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button className={s.navBtn} onClick={goNext}
|
|
||||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (pageNumber === lastPage && !adjacent.next))}>
|
|
||||||
<ArrowRight size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dlOpen && activeChapter && (
|
|
||||||
<DownloadModal chapter={activeChapter} remaining={adjacent.remaining} onClose={() => setDlOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
/* ── Root ──────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tabs ──────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.tabActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
}
|
|
||||||
.tabActive:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ── Keyword bar ───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.keywordBar {
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchBar {
|
|
||||||
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: 0 var(--sp-3) 0 var(--sp-2);
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.searchInput {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
padding: 7px 0;
|
|
||||||
}
|
|
||||||
.searchInput::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.clearBtn {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.clearBtn:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.advancedBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
|
||||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.searchBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Advanced filter panel ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.advancedPanel {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedTitle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedActions { display: flex; gap: var(--sp-1); }
|
|
||||||
|
|
||||||
.advancedLink {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.advancedLink:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.langGrid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langChip {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.langChipActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.advancedDivider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--border-dim);
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedCheck {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox { accent-color: var(--accent-fg); cursor: pointer; }
|
|
||||||
|
|
||||||
.advancedFooter {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.advancedLinkStandalone {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.advancedLinkStandalone:hover { opacity: 1; }
|
|
||||||
|
|
||||||
/* ── Empty states ──────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyIcon { color: var(--text-faint); }
|
|
||||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
|
|
||||||
/* ── Keyword results ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.results {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceSection {
|
|
||||||
padding: var(--sp-1) var(--sp-4) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.sourceSection:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.sourceHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceIcon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceName {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceLang {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 1px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultCount {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceError {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
padding: var(--sp-1) 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal scroll row */
|
|
||||||
.sourceRow {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--sp-1);
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.sourceRow::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
/* ── Manga card ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 110px;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .cardTitle { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inLibBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
right: var(--sp-1);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--accent-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.skCard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagGrid .card { width: 100%; }
|
|
||||||
.tagGrid .skCard { width: 100%; }
|
|
||||||
|
|
||||||
.skeleton { border-radius: var(--radius-sm); }
|
|
||||||
|
|
||||||
.skCover {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skTitle { height: 10px; width: 80%; }
|
|
||||||
|
|
||||||
/* ── Split root (Tag + Source tabs) ────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitRoot {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Split sidebar ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitSidebar {
|
|
||||||
width: 180px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitSearchWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.splitSearchInput {
|
|
||||||
flex: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.splitSearchClear {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.splitSearchClear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.splitList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-1);
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-dim) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.splitItemActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
}
|
|
||||||
.splitItemActive:hover { background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.splitItemLabel {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
|
||||||
|
|
||||||
.splitItemSource { gap: var(--sp-2); }
|
|
||||||
|
|
||||||
.splitEmpty {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: var(--sp-3);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitLoading {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Split content ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.splitContent {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitContentHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitSourceTitle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitContentTitle {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitResultCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitSourceIcon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tag active bar ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tagActiveBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagPillRow {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagPill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 7px;
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagPillRemove {
|
|
||||||
color: var(--accent-fg);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
.tagPillRemove:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.tagBarRight {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagModeToggle {
|
|
||||||
display: flex;
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagModeBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagModeBtn:last-child { border-right: none; }
|
|
||||||
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.tagClearAll {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-faint);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.tagClearAll:hover {
|
|
||||||
color: var(--color-error);
|
|
||||||
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
|
||||||
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagCheckMark {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Grid results ──────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.tagGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Show more / load more ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.showMoreCell {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showMoreBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.showMoreBtn:hover:not(:disabled) {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.loadMoreRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Source tab: lang filter + browse bar ──────────────────────────────────── */
|
|
||||||
|
|
||||||
.langFilterRow {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sourceBrowseBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── NSFW badge ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.nsfwBadge {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--color-error);
|
|
||||||
background: var(--color-error-bg, rgba(180, 60, 60, 0.08));
|
|
||||||
border: 1px solid rgba(180, 60, 60, 0.25);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 1px 5px;
|
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
<div>SeriesDetail.svelte</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
<div>Reader.svelte</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div>Search.svelte</div>
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
/* ─── Backdrop ── */
|
|
||||||
.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 shell ── */
|
|
||||||
.modal {
|
|
||||||
width: min(720px, calc(100vw - 48px));
|
|
||||||
height: min(520px, 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), 0 8px 24px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Sidebar ── */
|
|
||||||
.sidebar {
|
|
||||||
width: 152px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border-right: 1px solid var(--border-dim);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--sp-5) var(--sp-3);
|
|
||||||
gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalTitle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0 var(--sp-2) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.navItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: 7px var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.navItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
|
|
||||||
.navActive { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.navActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ─── Content ── */
|
|
||||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
|
|
||||||
.contentHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contentTitle {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.closeBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.contentBody { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); }
|
|
||||||
|
|
||||||
/* ─── Panel / Section ── */
|
|
||||||
.panel { display: flex; flex-direction: column; gap: var(--sp-6); }
|
|
||||||
.section { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Toggle ── */
|
|
||||||
.toggleRow {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
cursor: pointer; transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.toggleRow:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.toggleInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
||||||
.toggleLabel { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-tight); }
|
|
||||||
.toggleDesc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-snug); }
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative; width: 34px; height: 18px; border-radius: var(--radius-full);
|
|
||||||
background: var(--bg-subtle); border: 1px solid var(--border-strong); flex-shrink: 0;
|
|
||||||
cursor: pointer; transition: background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.toggleOn { background: var(--accent-dim); border-color: var(--accent); }
|
|
||||||
.toggleThumb {
|
|
||||||
position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
|
|
||||||
border-radius: 50%; background: var(--text-faint);
|
|
||||||
transition: transform var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.toggleOn .toggleThumb { transform: translateX(16px); background: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ─── Stepper ── */
|
|
||||||
.stepRow {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: var(--sp-4); padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.stepRow:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.stepControls { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.stepBtn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); font-size: var(--text-base);
|
|
||||||
color: var(--text-muted); transition: background var(--t-base), color var(--t-base);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.stepBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.stepBtn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
.stepVal {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Select (custom) ── */
|
|
||||||
.selectWrap { position: relative; flex-shrink: 0; min-width: 130px; }
|
|
||||||
|
|
||||||
.selectBtn {
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
|
||||||
width: 100%; padding: 5px 10px;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); color: var(--text-secondary);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.selectBtn:hover { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.selectCaret {
|
|
||||||
color: var(--text-faint); flex-shrink: 0;
|
|
||||||
transition: transform var(--t-base);
|
|
||||||
}
|
|
||||||
.selectCaretOpen { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
.selectMenu {
|
|
||||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
|
||||||
border-radius: var(--radius-md); padding: var(--sp-1);
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
||||||
z-index: 200; animation: scaleIn 0.1s ease both; transform-origin: top center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectOption {
|
|
||||||
padding: 6px 10px; border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-secondary); background: none; border: none;
|
|
||||||
cursor: pointer; text-align: left;
|
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
|
||||||
}
|
|
||||||
.selectOption:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
|
||||||
.selectOptionActive { color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.selectOptionActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
/* ─── Scale ── */
|
|
||||||
.scaleRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: 10px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.scaleSlider { flex: 1; }
|
|
||||||
.scaleVal {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary);
|
|
||||||
min-width: 36px; text-align: right; letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.scaleHint {
|
|
||||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
|
||||||
padding: 0 var(--sp-3) var(--sp-2);
|
|
||||||
}
|
|
||||||
.scalePreset {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
background: none; color: var(--text-faint); cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.scalePreset:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
.scalePresetActive {
|
|
||||||
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Text input ── */
|
|
||||||
.textInput {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
outline: none; flex-shrink: 0; width: 180px;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.textInput:focus { border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
/* ─── Keybinds ── */
|
|
||||||
.kbHeader { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
|
|
||||||
.kbHint { font-size: var(--text-xs); color: var(--text-faint); padding: 0 var(--sp-3) var(--sp-3); }
|
|
||||||
.resetAllBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim); background: none; cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.resetAllBtn:hover { color: var(--color-error); border-color: var(--color-error); }
|
|
||||||
|
|
||||||
.kbList { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.kbRow {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: var(--sp-4); padding: 8px var(--sp-3); border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.kbRow:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.kbLabel { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
|
|
||||||
.kbRight { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.kbBind {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 12px; border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
|
||||||
color: var(--text-secondary); cursor: pointer; min-width: 100px; text-align: center;
|
|
||||||
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
.kbBind:hover { border-color: var(--accent); color: var(--accent-fg); }
|
|
||||||
.kbBindListening {
|
|
||||||
border-color: var(--accent); background: var(--accent-muted); color: var(--accent-fg);
|
|
||||||
animation: pulse 1s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbReset {
|
|
||||||
font-size: var(--text-base); color: var(--text-faint); width: 22px; height: 22px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid transparent; background: none;
|
|
||||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
|
|
||||||
.kbReset:disabled { opacity: 0.2; cursor: default; }
|
|
||||||
|
|
||||||
/* ─── Storage ── */
|
|
||||||
.storageLoading {
|
|
||||||
font-size: var(--text-sm); color: var(--text-faint);
|
|
||||||
padding: var(--sp-3) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); }
|
|
||||||
|
|
||||||
.storageBar {
|
|
||||||
width: 100%; height: 7px;
|
|
||||||
background: var(--bg-overlay); border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storageBarFill {
|
|
||||||
height: 100%; border-radius: var(--radius-full);
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
.storageBarWarn { background: #d97706; }
|
|
||||||
.storageBarCritical { background: var(--color-error); }
|
|
||||||
|
|
||||||
.storageBarLabels {
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
.storageBarUsed { color: var(--text-secondary); }
|
|
||||||
.storageBarFree { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.storageBarNote {
|
|
||||||
font-size: var(--text-xs); color: var(--text-faint);
|
|
||||||
margin-top: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.storageLegend {
|
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.storageLegendRow {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
padding: 6px 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.storageDot {
|
|
||||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.storageDotManga { background: var(--accent); }
|
|
||||||
.storageDotApp { background: var(--border-strong); }
|
|
||||||
.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
|
||||||
|
|
||||||
.storageLegendLabel { flex: 1; color: var(--text-muted); }
|
|
||||||
.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
|
||||||
|
|
||||||
.storageLimitHint {
|
|
||||||
font-size: var(--text-xs); color: #d97706;
|
|
||||||
padding: 0 var(--sp-3) var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setLimitBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px; border-radius: var(--radius-md);
|
|
||||||
background: none; border: 1px solid var(--border-strong);
|
|
||||||
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); }
|
|
||||||
|
|
||||||
.storagePathNote {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-1) var(--sp-3) var(--sp-2);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── About ── */
|
|
||||||
.aboutBlock {
|
|
||||||
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.aboutLine { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-base); }
|
|
||||||
.dangerBtn {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px; border-radius: var(--radius-md);
|
|
||||||
background: none; border: 1px solid var(--color-error);
|
|
||||||
color: var(--color-error); cursor: pointer; flex-shrink: 0;
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); }
|
|
||||||
.dangerBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
/* ── Folder management (Settings FoldersTab) ────────────────────────── */
|
|
||||||
.folderCreateRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-1) var(--sp-3) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderCreateBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.folderCreateBtn:hover:not(:disabled) {
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.folderCreateBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.folderList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: 9px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: background var(--t-fast);
|
|
||||||
}
|
|
||||||
.folderRow:hover { background: var(--bg-raised); }
|
|
||||||
|
|
||||||
.folderRowName {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderRowCount {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-right: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderTabToggle {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.folderTabToggle:hover {
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
.folderTabToggleOn {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
.folderTabToggleOn:hover {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Theme picker ── */
|
|
||||||
.themeGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeCard {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: var(--sp-2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
|
||||||
.themeCardActive {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--accent-muted);
|
|
||||||
}
|
|
||||||
.themeCardActive:hover { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.themePreview {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(0,0,0,0.15);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themePreviewBg {
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themePreviewSidebar {
|
|
||||||
width: 22%;
|
|
||||||
height: 100%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themePreviewContent {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10% 12%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themePreviewAccent {
|
|
||||||
height: 14%;
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 55%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themePreviewText {
|
|
||||||
height: 9%;
|
|
||||||
border-radius: 2px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeCardInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeCardLabel {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeCardDesc {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.themeCardCheck {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--sp-1);
|
|
||||||
right: var(--sp-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
<div>Settings stub</div>
|
||||||
@@ -1,954 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
|
||||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Folder } from "../../store";
|
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
|
||||||
import type { Settings, FitMode, Theme } from "../../store";
|
|
||||||
import s from "./Settings.module.css";
|
|
||||||
|
|
||||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
||||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: <PaintBrush size={14} weight="light" /> },
|
|
||||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
|
||||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
|
||||||
{ id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
|
|
||||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
|
||||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
|
||||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
|
||||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
|
||||||
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange, label, description }: {
|
|
||||||
checked: boolean; onChange: (v: boolean) => void; label: string; description?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className={s.toggleRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>{label}</span>
|
|
||||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
|
||||||
</div>
|
|
||||||
<button role="switch" aria-checked={checked}
|
|
||||||
className={[s.toggle, checked ? s.toggleOn : ""].join(" ")}
|
|
||||||
onClick={() => onChange(!checked)}>
|
|
||||||
<span className={s.toggleThumb} />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stepper({ value, onChange, min, max, step = 1, label, description }: {
|
|
||||||
value: number; onChange: (v: number) => void;
|
|
||||||
min: number; max: number; step?: number; label: string; description?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>{label}</span>
|
|
||||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={s.stepControls}>
|
|
||||||
<button className={s.stepBtn} onClick={() => onChange(Math.max(min, value - step))} disabled={value <= min}>−</button>
|
|
||||||
<span className={s.stepVal}>{value}</span>
|
|
||||||
<button className={s.stepBtn} onClick={() => onChange(Math.min(max, value + step))} disabled={value >= max}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectRow({ value, options, onChange, label, description }: {
|
|
||||||
value: string;
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const selected = options.find((o) => o.value === value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
|
||||||
document.addEventListener("mousedown", handler);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handler);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>{label}</span>
|
|
||||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={s.selectWrap} ref={ref}>
|
|
||||||
<button className={s.selectBtn} onClick={() => setOpen((o) => !o)}>
|
|
||||||
<span>{selected?.label ?? value}</span>
|
|
||||||
<svg className={[s.selectCaret, open ? s.selectCaretOpen : ""].join(" ")} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
|
||||||
<path d="M0 0l5 6 5-6" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className={s.selectMenu}>
|
|
||||||
{options.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
className={[s.selectOption, o.value === value ? s.selectOptionActive : ""].join(" ")}
|
|
||||||
onClick={() => { onChange(o.value); setOpen(false); }}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextRow({ value, onChange, label, description, placeholder }: {
|
|
||||||
value: string; onChange: (v: string) => void;
|
|
||||||
label: string; description?: string; placeholder?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>{label}</span>
|
|
||||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
|
||||||
</div>
|
|
||||||
<input className={s.textInput} value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder} spellCheck={false} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GeneralTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Interface Scale</p>
|
|
||||||
<div className={s.scaleRow}>
|
|
||||||
<input type="range" min={70} max={150} step={5}
|
|
||||||
value={settings.uiScale}
|
|
||||||
onChange={(e) => update({ uiScale: Number(e.target.value) })}
|
|
||||||
className={s.scaleSlider} />
|
|
||||||
<span className={s.scaleVal}>{settings.uiScale}%</span>
|
|
||||||
<button className={s.stepBtn}
|
|
||||||
onClick={() => update({ uiScale: 100 })}
|
|
||||||
disabled={settings.uiScale === 100} title="Reset">↺</button>
|
|
||||||
</div>
|
|
||||||
<p className={s.scaleHint}>
|
|
||||||
{[70, 80, 90, 100, 110, 125, 150].map((v) => (
|
|
||||||
<button key={v}
|
|
||||||
className={[s.scalePreset, settings.uiScale === v ? s.scalePresetActive : ""].join(" ")}
|
|
||||||
onClick={() => update({ uiScale: v })}>{v}%</button>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Server</p>
|
|
||||||
<TextRow label="Server URL" description="Base URL of your Suwayomi instance"
|
|
||||||
value={settings.serverUrl ?? "http://localhost:4567"}
|
|
||||||
onChange={(v) => update({ serverUrl: v })}
|
|
||||||
placeholder="http://localhost:4567" />
|
|
||||||
<TextRow label="Server binary" description="Path or command to launch tachidesk-server"
|
|
||||||
value={settings.serverBinary}
|
|
||||||
onChange={(v) => update({ serverBinary: v })}
|
|
||||||
placeholder="tachidesk-server" />
|
|
||||||
<Toggle label="Auto-start server"
|
|
||||||
description="Launch tachidesk-server when Moku opens"
|
|
||||||
checked={settings.autoStartServer}
|
|
||||||
onChange={(v) => update({ autoStartServer: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Inactivity</p>
|
|
||||||
<SelectRow
|
|
||||||
label="Idle screen timeout"
|
|
||||||
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
|
|
||||||
value={String(settings.idleTimeoutMin ?? 5)}
|
|
||||||
options={[
|
|
||||||
{ value: "0", label: "Never" },
|
|
||||||
{ value: "1", label: "1 minute" },
|
|
||||||
{ value: "2", label: "2 minutes" },
|
|
||||||
{ value: "5", label: "5 minutes" },
|
|
||||||
{ value: "10", label: "10 minutes" },
|
|
||||||
{ value: "15", label: "15 minutes" },
|
|
||||||
{ value: "30", label: "30 minutes" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ idleTimeoutMin: Number(v) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReaderTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Page Layout</p>
|
|
||||||
<SelectRow label="Default layout"
|
|
||||||
description="How chapters open by default"
|
|
||||||
value={settings.pageStyle === "double" ? "single" : settings.pageStyle}
|
|
||||||
options={[
|
|
||||||
{ value: "single", label: "Single page" },
|
|
||||||
{ value: "longstrip", label: "Long strip" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
|
|
||||||
<SelectRow label="Reading direction"
|
|
||||||
description="Left-to-right for most manga, right-to-left for Japanese"
|
|
||||||
value={settings.readingDirection}
|
|
||||||
options={[
|
|
||||||
{ value: "ltr", label: "Left to right" },
|
|
||||||
{ value: "rtl", label: "Right to left" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
|
|
||||||
<Toggle label="Page gap"
|
|
||||||
description="Add spacing between pages in longstrip mode"
|
|
||||||
checked={settings.pageGap}
|
|
||||||
onChange={(v) => update({ pageGap: v })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Fit & Zoom</p>
|
|
||||||
<SelectRow label="Default fit mode"
|
|
||||||
description="How pages are sized to fit the screen"
|
|
||||||
value={settings.fitMode ?? "width"}
|
|
||||||
options={[
|
|
||||||
{ value: "width", label: "Fit width" },
|
|
||||||
{ value: "height", label: "Fit height" },
|
|
||||||
{ value: "screen", label: "Fit screen" },
|
|
||||||
{ value: "original", label: "Original (1:1)" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ fitMode: v as FitMode })} />
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>Max page width</span>
|
|
||||||
<span className={s.toggleDesc}>Pixel cap for fit-width mode. Ctrl+scroll in reader to adjust live.</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.stepControls}>
|
|
||||||
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.max(200, (settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
|
||||||
<span className={s.stepVal}>{settings.maxPageWidth ?? 900}px</span>
|
|
||||||
<button className={s.stepBtn} onClick={() => update({ maxPageWidth: Math.min(2400, (settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toggle label="Optimize contrast"
|
|
||||||
description="Use webkit-optimize-contrast rendering (sharper on low-DPI)"
|
|
||||||
checked={settings.optimizeContrast}
|
|
||||||
onChange={(v) => update({ optimizeContrast: v })} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Behaviour</p>
|
|
||||||
<Toggle label="Auto-mark chapters read"
|
|
||||||
description="Mark a chapter as read when you reach the last page"
|
|
||||||
checked={settings.autoMarkRead}
|
|
||||||
onChange={(v) => update({ autoMarkRead: v })} />
|
|
||||||
<Toggle label="Auto-advance chapters"
|
|
||||||
description="Automatically open the next chapter at the end of a long strip"
|
|
||||||
checked={settings.autoNextChapter ?? false}
|
|
||||||
onChange={(v) => update({ autoNextChapter: v })} />
|
|
||||||
{!(settings.autoNextChapter ?? false) && (
|
|
||||||
<Toggle label="Mark read when skipping to next chapter"
|
|
||||||
description="When auto-advance is off, mark the current chapter as read if you tap the next chapter button before finishing it"
|
|
||||||
checked={settings.markReadOnNext ?? true}
|
|
||||||
onChange={(v) => update({ markReadOnNext: v })} />
|
|
||||||
)}
|
|
||||||
<Stepper label="Pages to preload"
|
|
||||||
description="Images loaded ahead of the current page"
|
|
||||||
value={settings.preloadPages} min={0} max={10}
|
|
||||||
onChange={(v) => update({ preloadPages: v })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LibraryTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
|
||||||
const historyLen = useStore((s) => s.history.length);
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Display</p>
|
|
||||||
<Toggle label="Crop cover images"
|
|
||||||
description="Fill grid cells — may crop cover edges"
|
|
||||||
checked={settings.libraryCropCovers}
|
|
||||||
onChange={(v) => update({ libraryCropCovers: v })} />
|
|
||||||
<Toggle label="Show NSFW sources"
|
|
||||||
description="Display adult content sources in the sources list"
|
|
||||||
checked={settings.showNsfw}
|
|
||||||
onChange={(v) => update({ showNsfw: v })} />
|
|
||||||
<Stepper label="Initial cards to display"
|
|
||||||
description="Cards shown before 'Show more' appears"
|
|
||||||
value={settings.libraryPageSize} min={12} max={200} step={12}
|
|
||||||
onChange={(v) => update({ libraryPageSize: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Chapters</p>
|
|
||||||
<SelectRow label="Default sort direction"
|
|
||||||
value={settings.chapterSortDir}
|
|
||||||
options={[
|
|
||||||
{ value: "desc", label: "Newest first" },
|
|
||||||
{ value: "asc", label: "Oldest first" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ chapterSortDir: v as Settings["chapterSortDir"] })} />
|
|
||||||
<Stepper label="Chapters per page"
|
|
||||||
description="Chapter list pagination size"
|
|
||||||
value={settings.chapterPageSize} min={10} max={100} step={5}
|
|
||||||
onChange={(v) => update({ chapterPageSize: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Extensions</p>
|
|
||||||
<SelectRow label="Preferred language"
|
|
||||||
description="Language variant shown first when an extension has multiple"
|
|
||||||
value={settings.preferredExtensionLang ?? "en"}
|
|
||||||
options={[
|
|
||||||
{ value: "en", label: "English" },
|
|
||||||
{ value: "es", label: "Spanish" },
|
|
||||||
{ value: "fr", label: "French" },
|
|
||||||
{ value: "de", label: "German" },
|
|
||||||
{ value: "pt-br", label: "Portuguese (BR)" },
|
|
||||||
{ value: "it", label: "Italian" },
|
|
||||||
{ value: "ru", label: "Russian" },
|
|
||||||
{ value: "ar", label: "Arabic" },
|
|
||||||
{ value: "tr", label: "Turkish" },
|
|
||||||
{ value: "zh", label: "Chinese (Simplified)" },
|
|
||||||
{ value: "zh-hant", label: "Chinese (Traditional)" },
|
|
||||||
{ value: "ko", label: "Korean" },
|
|
||||||
{ value: "ja", label: "Japanese" },
|
|
||||||
{ value: "id", label: "Indonesian" },
|
|
||||||
{ value: "vi", label: "Vietnamese" },
|
|
||||||
{ value: "th", label: "Thai" },
|
|
||||||
{ value: "pl", label: "Polish" },
|
|
||||||
{ value: "nl", label: "Dutch" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update({ preferredExtensionLang: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>History</p>
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>Reading history</span>
|
|
||||||
<span className={s.toggleDesc}>{historyLen} entries stored</span>
|
|
||||||
</div>
|
|
||||||
<button className={s.dangerBtn} onClick={clearHistory} disabled={historyLen === 0}>
|
|
||||||
Clear history
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PerformanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Rendering</p>
|
|
||||||
<Toggle label="GPU acceleration"
|
|
||||||
description="Promote reader and library to compositor layers (recommended)"
|
|
||||||
checked={settings.gpuAcceleration}
|
|
||||||
onChange={(v) => update({ gpuAcceleration: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Idle / Splash Screen</p>
|
|
||||||
<Toggle label="Animated card background"
|
|
||||||
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
|
|
||||||
checked={settings.splashCards ?? true}
|
|
||||||
onChange={(v) => update({ splashCards: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Interface</p>
|
|
||||||
<Toggle label="Compact sidebar"
|
|
||||||
description="Reduce sidebar icon spacing"
|
|
||||||
checked={settings.compactSidebar}
|
|
||||||
onChange={(v) => update({ compactSidebar: v })} />
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Reader</p>
|
|
||||||
<Stepper
|
|
||||||
label="Input debounce"
|
|
||||||
description="Delay (ms) before page-turn input is processed. Increase if the reader feels laggy or skips pages. Set to 0 to disable."
|
|
||||||
value={settings.readerDebounceMs ?? 120}
|
|
||||||
min={0}
|
|
||||||
max={500}
|
|
||||||
step={20}
|
|
||||||
onChange={(v) => update({ readerDebounceMs: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeybindsTab({ settings, update, reset }: {
|
|
||||||
settings: Settings; update: (p: Partial<Settings>) => void; reset: () => void;
|
|
||||||
}) {
|
|
||||||
const [listening, setListening] = useState<keyof Keybinds | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!listening) return;
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
const bind = eventToKeybind(e);
|
|
||||||
if (!bind) return;
|
|
||||||
update({ keybinds: { ...settings.keybinds, [listening!]: bind } });
|
|
||||||
setListening(null);
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", onKey, true);
|
|
||||||
return () => window.removeEventListener("keydown", onKey, true);
|
|
||||||
}, [listening, settings.keybinds]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<div className={s.kbHeader}>
|
|
||||||
<p className={s.sectionTitle}>Keyboard shortcuts</p>
|
|
||||||
<button className={s.resetAllBtn} onClick={reset}>Reset all</button>
|
|
||||||
</div>
|
|
||||||
<p className={s.kbHint}>Click a key to rebind, then press the new combination.</p>
|
|
||||||
<div className={s.kbList}>
|
|
||||||
{(Object.keys(KEYBIND_LABELS) as (keyof Keybinds)[]).map((key) => {
|
|
||||||
const isListening = listening === key;
|
|
||||||
const isDefault = settings.keybinds[key] === DEFAULT_KEYBINDS[key];
|
|
||||||
return (
|
|
||||||
<div key={key} className={s.kbRow}>
|
|
||||||
<span className={s.kbLabel}>{KEYBIND_LABELS[key]}</span>
|
|
||||||
<div className={s.kbRight}>
|
|
||||||
<button
|
|
||||||
className={[s.kbBind, isListening ? s.kbBindListening : ""].join(" ")}
|
|
||||||
onClick={() => setListening(isListening ? null : key)}>
|
|
||||||
{isListening ? "Press key…" : settings.keybinds[key]}
|
|
||||||
</button>
|
|
||||||
<button className={s.kbReset}
|
|
||||||
onClick={() => update({ keybinds: { ...settings.keybinds, [key]: DEFAULT_KEYBINDS[key] } })}
|
|
||||||
disabled={isDefault} title="Reset">↺</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Storage helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function fmtBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageInfo {
|
|
||||||
manga_bytes: number;
|
|
||||||
total_bytes: number;
|
|
||||||
free_bytes: number;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StorageBar({ used, free, limit, total }: { used: number; free: number; limit: number | null; total: number }) {
|
|
||||||
// "Available space" = what's actually usable: already-used manga bytes + free bytes on disk.
|
|
||||||
// We intentionally do NOT use total_bytes (full drive size) as the cap — other apps / OS
|
|
||||||
// overhead eat into that, and it makes our bar look almost empty even when downloads are large.
|
|
||||||
const available = used + free; // usable space relevant to downloads
|
|
||||||
const cap = limit !== null ? Math.min(limit, available) : available;
|
|
||||||
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
|
||||||
const critical = pctUsed > 90;
|
|
||||||
const warning = pctUsed > 75;
|
|
||||||
const freeInCap = Math.max(0, cap - used);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.storageBarWrap}>
|
|
||||||
<div className={s.storageBar}>
|
|
||||||
<div
|
|
||||||
className={[s.storageBarFill, critical ? s.storageBarCritical : warning ? s.storageBarWarn : ""].join(" ")}
|
|
||||||
style={{ width: `${pctUsed}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={s.storageBarLabels}>
|
|
||||||
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
|
|
||||||
<span className={s.storageBarFree}>{fmtBytes(freeInCap)} free</span>
|
|
||||||
</div>
|
|
||||||
{limit !== null && (
|
|
||||||
<p className={s.storageBarNote}>
|
|
||||||
Limit {fmtBytes(limit)} · {fmtBytes(free)} free on disk of {fmtBytes(total)} total
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
const [info, setInfo] = useState<StorageInfo | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [clearing, setClearing] = useState(false);
|
|
||||||
const [cleared, setCleared] = useState(false);
|
|
||||||
|
|
||||||
const limitGb = settings.storageLimitGb ?? null;
|
|
||||||
const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null;
|
|
||||||
|
|
||||||
async function fetchInfo() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
|
|
||||||
const result = await invoke<StorageInfo>("get_storage_info", {
|
|
||||||
downloadsPath: pathData.settings.downloadsPath,
|
|
||||||
});
|
|
||||||
setInfo(result);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { fetchInfo(); }, []);
|
|
||||||
|
|
||||||
function handleClearCache() {
|
|
||||||
setClearing(true);
|
|
||||||
caches.keys()
|
|
||||||
.then((names) => Promise.all(names.map((n) => caches.delete(n))))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
setClearing(false);
|
|
||||||
setCleared(true);
|
|
||||||
setTimeout(() => setCleared(false), 2500);
|
|
||||||
fetchInfo();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const mangaBytes = info?.manga_bytes ?? 0;
|
|
||||||
const totalBytes = info?.total_bytes ?? 0;
|
|
||||||
const freeBytes = info?.free_bytes ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Disk Usage</p>
|
|
||||||
{loading && <p className={s.storageLoading}>Reading filesystem…</p>}
|
|
||||||
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
|
|
||||||
{!loading && !error && info && (
|
|
||||||
<>
|
|
||||||
<StorageBar used={mangaBytes} free={freeBytes} limit={limitBytes} total={totalBytes} />
|
|
||||||
<div className={s.storageLegend}>
|
|
||||||
<div className={s.storageLegendRow}>
|
|
||||||
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
|
|
||||||
<span className={s.storageLegendLabel}>Downloaded manga</span>
|
|
||||||
<span className={s.storageLegendVal}>{fmtBytes(mangaBytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.storageLegendRow}>
|
|
||||||
<span className={[s.storageDot, s.storageDotFree].join(" ")} />
|
|
||||||
<span className={s.storageLegendLabel}>Drive free</span>
|
|
||||||
<span className={s.storageLegendVal}>{fmtBytes(freeBytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className={s.storageLegendRow}>
|
|
||||||
<span className={[s.storageDot, s.storageDotApp].join(" ")} />
|
|
||||||
<span className={s.storageLegendLabel}>Drive total</span>
|
|
||||||
<span className={s.storageLegendVal}>{fmtBytes(totalBytes)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className={s.storagePathNote}>{info.path}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Storage Limit</p>
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>Limit download storage</span>
|
|
||||||
<span className={s.toggleDesc}>
|
|
||||||
{limitGb === null
|
|
||||||
? "No limit — uses full drive capacity"
|
|
||||||
: `Warn when downloads exceed ${limitGb} GB`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{limitGb === null ? (
|
|
||||||
<button className={s.setLimitBtn} onClick={() => update({ storageLimitGb: 10 })}>
|
|
||||||
Set limit
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className={s.stepControls}>
|
|
||||||
<button className={s.stepBtn}
|
|
||||||
onClick={() => update({ storageLimitGb: Math.max(1, limitGb - 1) })}
|
|
||||||
disabled={limitGb <= 1}>−</button>
|
|
||||||
<span className={s.stepVal}>{limitGb} GB</span>
|
|
||||||
<button className={s.stepBtn}
|
|
||||||
onClick={() => update({ storageLimitGb: limitGb + 1 })}>+</button>
|
|
||||||
<button className={s.kbReset} onClick={() => update({ storageLimitGb: null })} title="Remove limit">↺</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > (freeBytes + mangaBytes) && (
|
|
||||||
<p className={s.storageLimitHint}>Limit exceeds available space ({fmtBytes(freeBytes)} free on disk)</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Cache</p>
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>Image cache</span>
|
|
||||||
<span className={s.toggleDesc}>Cached page images stored by the webview</span>
|
|
||||||
</div>
|
|
||||||
<button className={s.dangerBtn} onClick={handleClearCache} disabled={clearing}>
|
|
||||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Folders tab ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FoldersTab() {
|
|
||||||
const folders = useStore((s) => s.settings.folders);
|
|
||||||
const addFolder = useStore((s) => s.addFolder);
|
|
||||||
const removeFolder = useStore((s) => s.removeFolder);
|
|
||||||
const renameFolder = useStore((s) => s.renameFolder);
|
|
||||||
const toggleFolderTab = useStore((s) => s.toggleFolderTab);
|
|
||||||
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [editingName, setEditingName] = useState("");
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
const name = newName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
addFolder(name);
|
|
||||||
setNewName("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(folder: Folder) {
|
|
||||||
setEditingId(folder.id);
|
|
||||||
setEditingName(folder.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitEdit() {
|
|
||||||
if (editingId && editingName.trim()) {
|
|
||||||
renameFolder(editingId, editingName.trim());
|
|
||||||
}
|
|
||||||
setEditingId(null);
|
|
||||||
setEditingName("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Manage Folders</p>
|
|
||||||
<p className={s.toggleDesc} style={{ padding: "0 var(--sp-3) var(--sp-3)", display: "block" }}>
|
|
||||||
Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Create new folder */}
|
|
||||||
<div className={s.folderCreateRow}>
|
|
||||||
<input
|
|
||||||
className={s.textInput}
|
|
||||||
placeholder="New folder name…"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
|
||||||
style={{ flex: 1, width: "auto" }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={s.folderCreateBtn}
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!newName.trim()}
|
|
||||||
>
|
|
||||||
<Plus size={13} weight="bold" />
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folder list */}
|
|
||||||
{folders.length === 0 ? (
|
|
||||||
<p className={s.storageLoading}>No folders yet. Create one above.</p>
|
|
||||||
) : (
|
|
||||||
<div className={s.folderList}>
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<div key={folder.id} className={s.folderRow}>
|
|
||||||
{editingId === folder.id ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className={s.textInput}
|
|
||||||
value={editingName}
|
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") commitEdit();
|
|
||||||
if (e.key === "Escape") { setEditingId(null); }
|
|
||||||
}}
|
|
||||||
onBlur={commitEdit}
|
|
||||||
style={{ flex: 1, width: "auto" }}
|
|
||||||
/>
|
|
||||||
<button className={s.kbReset} onClick={commitEdit} title="Save">✓</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FolderSimple size={14} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
|
||||||
<span className={s.folderRowName}>{folder.name}</span>
|
|
||||||
<span className={s.folderRowCount}>{folder.mangaIds.length} manga</span>
|
|
||||||
{/* Show as tab toggle */}
|
|
||||||
<button
|
|
||||||
className={[s.folderTabToggle, folder.showTab ? s.folderTabToggleOn : ""].join(" ")}
|
|
||||||
onClick={() => toggleFolderTab(folder.id)}
|
|
||||||
title={folder.showTab ? "Shown as library tab — click to hide" : "Click to show as library tab"}
|
|
||||||
>
|
|
||||||
{folder.showTab ? "Tab on" : "Tab off"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={s.kbReset}
|
|
||||||
onClick={() => startEdit(folder)}
|
|
||||||
title="Rename"
|
|
||||||
>
|
|
||||||
<Pencil size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={[s.kbReset, s.folderDeleteBtn].join(" ")}
|
|
||||||
onClick={() => removeFolder(folder.id)}
|
|
||||||
title="Delete folder"
|
|
||||||
>
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Appearance tab ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
|
||||||
{
|
|
||||||
id: "dark",
|
|
||||||
label: "Dark",
|
|
||||||
description: "Default near-black",
|
|
||||||
swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "high-contrast",
|
|
||||||
label: "High Contrast",
|
|
||||||
description: "Darker base, sharper text",
|
|
||||||
swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "light",
|
|
||||||
label: "Light",
|
|
||||||
description: "Warm off-white",
|
|
||||||
swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "light-contrast",
|
|
||||||
label: "Light Contrast",
|
|
||||||
description: "Light with maximum text contrast",
|
|
||||||
swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "midnight",
|
|
||||||
label: "Midnight",
|
|
||||||
description: "Deep blue-black tint",
|
|
||||||
swatches: ["#0c1020", "#101428", "#a8b4e8", "#eeeef8"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "warm",
|
|
||||||
label: "Warm",
|
|
||||||
description: "Amber and sepia tones",
|
|
||||||
swatches: ["#16130c", "#1c1810", "#e0b860", "#f5f0e0"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
|
||||||
const current = settings.theme ?? "dark";
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Theme</p>
|
|
||||||
<div className={s.themeGrid}>
|
|
||||||
{THEMES.map((theme) => {
|
|
||||||
const active = current === theme.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={theme.id}
|
|
||||||
className={[s.themeCard, active ? s.themeCardActive : ""].join(" ")}
|
|
||||||
onClick={() => update({ theme: theme.id })}
|
|
||||||
title={theme.description}
|
|
||||||
>
|
|
||||||
<div className={s.themePreview}>
|
|
||||||
{/* Mini UI preview using the theme swatches */}
|
|
||||||
<div className={s.themePreviewBg} style={{ background: theme.swatches[0] }}>
|
|
||||||
<div className={s.themePreviewSidebar} style={{ background: theme.swatches[1] }} />
|
|
||||||
<div className={s.themePreviewContent}>
|
|
||||||
<div className={s.themePreviewAccent} style={{ background: theme.swatches[2] }} />
|
|
||||||
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "55" }} />
|
|
||||||
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "33", width: "60%" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={s.themeCardInfo}>
|
|
||||||
<span className={s.themeCardLabel}>{theme.label}</span>
|
|
||||||
<span className={s.themeCardDesc}>{theme.description}</span>
|
|
||||||
</div>
|
|
||||||
{active && <span className={s.themeCardCheck}>✓</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DevToolsTab() {
|
|
||||||
const [splashTriggered, setSplashTriggered] = useState(false);
|
|
||||||
|
|
||||||
function triggerSplash() {
|
|
||||||
setSplashTriggered(true);
|
|
||||||
setTimeout(() => setSplashTriggered(false), 200);
|
|
||||||
(window as any).__mokuShowSplash?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Splash Screen</p>
|
|
||||||
<div className={s.stepRow}>
|
|
||||||
<div className={s.toggleInfo}>
|
|
||||||
<span className={s.toggleLabel}>Preview idle screen</span>
|
|
||||||
<span className={s.toggleDesc}>Show the idle splash — dismiss with any click or key</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={s.dangerBtn}
|
|
||||||
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
|
|
||||||
color: splashTriggered ? "var(--bg-base)" : undefined,
|
|
||||||
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
|
|
||||||
transition: "all 0.15s ease" }}
|
|
||||||
onClick={triggerSplash}
|
|
||||||
>
|
|
||||||
Show idle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Build Info</p>
|
|
||||||
<div className={s.aboutBlock}>
|
|
||||||
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
|
|
||||||
Mode: {import.meta.env.MODE}
|
|
||||||
</p>
|
|
||||||
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
|
|
||||||
Dev: {String(import.meta.env.DEV)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AboutTab() {
|
|
||||||
return (
|
|
||||||
<div className={s.panel}>
|
|
||||||
<div className={s.section}>
|
|
||||||
<p className={s.sectionTitle}>Moku</p>
|
|
||||||
<div className={s.aboutBlock}>
|
|
||||||
<p className={s.aboutLine}>A manga reader frontend for Suwayomi / Tachidesk.</p>
|
|
||||||
<p className={s.aboutLine} style={{ color: "var(--text-faint)", marginTop: "var(--sp-2)" }}>
|
|
||||||
Built with Tauri + React. Connects to tachidesk-server.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function SettingsModal() {
|
|
||||||
const [tab, setTab] = useState<Tab>("general");
|
|
||||||
const closeSettings = useStore((s) => s.closeSettings);
|
|
||||||
const settings = useStore((s) => s.settings);
|
|
||||||
const updateSettings = useStore((s) => s.updateSettings);
|
|
||||||
const resetKeybinds = useStore((s) => s.resetKeybinds);
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentBodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
contentBodyRef.current?.scrollTo({ top: 0 });
|
|
||||||
}, [tab]);
|
|
||||||
|
|
||||||
const handleBackdrop = useCallback(
|
|
||||||
(e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); },
|
|
||||||
[closeSettings]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeSettings(); };
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [closeSettings]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.backdrop} ref={backdropRef} onClick={handleBackdrop}>
|
|
||||||
<div className={s.modal} role="dialog" aria-label="Settings">
|
|
||||||
<div className={s.sidebar}>
|
|
||||||
<p className={s.modalTitle}>Settings</p>
|
|
||||||
<nav className={s.nav}>
|
|
||||||
{TABS.map((t) => (
|
|
||||||
<button key={t.id}
|
|
||||||
className={[s.navItem, tab === t.id ? s.navActive : ""].join(" ")}
|
|
||||||
onClick={() => setTab(t.id)}>
|
|
||||||
{t.icon}
|
|
||||||
<span>{t.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div className={s.content}>
|
|
||||||
<div className={s.contentHeader}>
|
|
||||||
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
|
|
||||||
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<div className={s.contentBody} ref={contentBodyRef}>
|
|
||||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "appearance" && <AppearanceTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
|
|
||||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
|
||||||
{tab === "folders" && <FoldersTab />}
|
|
||||||
{tab === "about" && <AboutTab />}
|
|
||||||
{tab === "devtools" && <DevToolsTab />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back:hover { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.sourceName {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: var(--sp-3) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs { display: flex; gap: 2px; }
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-base), color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.searchWrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
position: absolute;
|
|
||||||
left: 9px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 10px 5px 26px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
width: 200px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
/* ─── Responsive grid ─────────────────────────────────────────────────────── */
|
|
||||||
/*
|
|
||||||
Adapts to screen width:
|
|
||||||
- narrow (< ~640px): 2 columns
|
|
||||||
- default (~640-900px): auto-fill ~120px → 4–6 cols
|
|
||||||
- wide (> ~900px): more columns, stays readable
|
|
||||||
*/
|
|
||||||
.grid, .loadingGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-5) var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
align-content: start;
|
|
||||||
/* GPU for smooth scroll */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .title { color: var(--text-primary); }
|
|
||||||
|
|
||||||
.coverWrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: filter var(--t-base);
|
|
||||||
will-change: filter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inLibraryBadge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: var(--sp-1);
|
|
||||||
left: var(--sp-1);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
/* Use secondary not muted - readable against dark bg */
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skeleton */
|
|
||||||
.cardSkeleton { padding: 0; }
|
|
||||||
|
|
||||||
.coverSkeleton {
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSkeleton {
|
|
||||||
height: 11px;
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
width: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--sp-4);
|
|
||||||
padding: var(--sp-4);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 12px;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:hover:not(:disabled) {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageBtn:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.pageNum {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
min-width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Manga } from "../../lib/types";
|
|
||||||
import s from "./SourceBrowse.module.css";
|
|
||||||
|
|
||||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
|
||||||
|
|
||||||
export default function SourceBrowse() {
|
|
||||||
const activeSource = useStore((state) => state.activeSource);
|
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
|
||||||
const folders = useStore((state) => state.settings.folders);
|
|
||||||
const addFolder = useStore((state) => state.addFolder);
|
|
||||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
|
||||||
|
|
||||||
const [mangas, setMangas] = useState<Manga[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [browseType, setBrowseType] = useState<BrowseType>("POPULAR");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
async function fetch(type: BrowseType, p: number, q: string) {
|
|
||||||
if (!activeSource) return;
|
|
||||||
setLoading(true);
|
|
||||||
setMangas([]);
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: activeSource.id, type, page: p, query: q || null }
|
|
||||||
)
|
|
||||||
.then((d) => {
|
|
||||||
setMangas(d.fetchSourceManga.mangas);
|
|
||||||
setHasNextPage(d.fetchSourceManga.hasNextPage);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(browseType, page, search);
|
|
||||||
}, [activeSource?.id, browseType, page, search]);
|
|
||||||
|
|
||||||
function submitSearch() {
|
|
||||||
const q = searchInput.trim();
|
|
||||||
setSearch(q);
|
|
||||||
setBrowseType("SEARCH");
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMode(mode: BrowseType) {
|
|
||||||
if (mode === browseType) return;
|
|
||||||
setBrowseType(mode);
|
|
||||||
setSearch("");
|
|
||||||
setSearchInput("");
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openManga(m: Manga) {
|
|
||||||
setActiveManga(m);
|
|
||||||
setNavPage("library");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
|
||||||
disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => setMangas((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
|
||||||
.catch(console.error),
|
|
||||||
},
|
|
||||||
...(folders.length > 0 ? [
|
|
||||||
{ separator: true } as ContextMenuEntry,
|
|
||||||
...folders.map((f): ContextMenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
|
|
||||||
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add",
|
|
||||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
|
||||||
onClick: () => {
|
|
||||||
const name = prompt("Folder name:");
|
|
||||||
if (name?.trim()) {
|
|
||||||
const id = addFolder(name.trim());
|
|
||||||
assignMangaToFolder(id, m.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeSource) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<button className={s.back} onClick={() => setActiveSource(null)}>
|
|
||||||
<ArrowLeft size={13} weight="light" />
|
|
||||||
<span>Sources</span>
|
|
||||||
</button>
|
|
||||||
<span className={s.sourceName}>{activeSource.displayName}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.toolbar}>
|
|
||||||
<div className={s.tabs}>
|
|
||||||
{(["POPULAR", "LATEST"] as BrowseType[]).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
onClick={() => setMode(mode)}
|
|
||||||
className={[s.tab, browseType === mode && search === "" ? s.tabActive : ""].join(" ").trim()}
|
|
||||||
>
|
|
||||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{search && (
|
|
||||||
<button className={[s.tab, s.tabActive].join(" ")}>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
ref={searchRef}
|
|
||||||
className={s.search}
|
|
||||||
placeholder="Search source…"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.loadingGrid}>
|
|
||||||
{Array.from({ length: 18 }).map((_, i) => (
|
|
||||||
<div key={i} className={s.cardSkeleton}>
|
|
||||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
|
||||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : mangas.length === 0 ? (
|
|
||||||
<div className={s.empty}>No results.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.grid}>
|
|
||||||
{mangas.map((m) => (
|
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
|
||||||
<div className={s.coverWrap}>
|
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
|
||||||
</div>
|
|
||||||
<p className={s.title}>{m.title}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && (page > 1 || hasNextPage) && (
|
|
||||||
<div className={s.pagination}>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
<Prev size={13} weight="light" />
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
<span className={s.pageNum}>{page}</span>
|
|
||||||
<button
|
|
||||||
className={s.pageBtn}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={!hasNextPage}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<Next size={13} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ctx && (
|
|
||||||
<ContextMenu
|
|
||||||
x={ctx.x}
|
|
||||||
y={ctx.y}
|
|
||||||
items={buildCtxItems(ctx.manga)}
|
|
||||||
onClose={() => setCtx(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: var(--sp-6);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
animation: fadeIn 0.14s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchWrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchIcon {
|
|
||||||
position: absolute;
|
|
||||||
left: 9px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 5px 10px 5px 26px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
width: 180px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.langRow {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langBtn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
|
||||||
|
|
||||||
.langBtnActive {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.langBtnActive:hover {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list { display: flex; flex-direction: column; gap: 1px; }
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
padding: 9px var(--sp-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: none;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
|
|
||||||
.rowIndented {
|
|
||||||
padding-left: var(--sp-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.indentSpacer {
|
|
||||||
width: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:hover .arrow { opacity: 1; }
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 160px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "@phosphor-icons/react";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import type { Source } from "../../lib/types";
|
|
||||||
import s from "./SourceList.module.css";
|
|
||||||
|
|
||||||
type Group = { name: string; icon: string; sources: Source[] };
|
|
||||||
|
|
||||||
export default function SourceList() {
|
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [lang, setLang] = useState("all");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => setSources(d.sources.nodes))
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const langs = ["all", ...Array.from(new Set(sources.map((src) => src.lang))).sort()];
|
|
||||||
|
|
||||||
const filtered = sources.filter((src) => {
|
|
||||||
if (src.id === "0") return false;
|
|
||||||
const matchLang = lang === "all" || src.lang === lang;
|
|
||||||
const matchSearch =
|
|
||||||
src.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
src.displayName.toLowerCase().includes(search.toLowerCase());
|
|
||||||
return matchLang && matchSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groups: Group[] = [];
|
|
||||||
const seen = new Map<string, Group>();
|
|
||||||
for (const src of filtered) {
|
|
||||||
const key = src.name;
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
const g: Group = { name: src.name, icon: src.iconUrl, sources: [] };
|
|
||||||
seen.set(key, g);
|
|
||||||
groups.push(g);
|
|
||||||
}
|
|
||||||
seen.get(key)!.sources.push(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGroup(name: string) {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.has(name) ? next.delete(name) : next.add(name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={s.root}>
|
|
||||||
<div className={s.header}>
|
|
||||||
<h1 className={s.heading}>Sources</h1>
|
|
||||||
<div className={s.searchWrap}>
|
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
|
||||||
<input
|
|
||||||
className={s.search}
|
|
||||||
placeholder="Search"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={s.langRow}>
|
|
||||||
{langs.map((l) => (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
onClick={() => setLang(l)}
|
|
||||||
className={[s.langBtn, lang === l ? s.langBtnActive : ""].join(" ").trim()}
|
|
||||||
>
|
|
||||||
{l === "all" ? "All" : l.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className={s.empty}>
|
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
|
||||||
</div>
|
|
||||||
) : groups.length === 0 ? (
|
|
||||||
<div className={s.empty}>No sources found.</div>
|
|
||||||
) : (
|
|
||||||
<div className={s.list}>
|
|
||||||
{groups.map((g) => {
|
|
||||||
const single = g.sources.length === 1;
|
|
||||||
const open = expanded.has(g.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={g.name}>
|
|
||||||
<button
|
|
||||||
className={s.row}
|
|
||||||
onClick={() => single ? setActiveSource(g.sources[0]) : toggleGroup(g.name)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(g.icon)}
|
|
||||||
alt={g.name}
|
|
||||||
className={s.icon}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
||||||
/>
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.name}>{g.name}</span>
|
|
||||||
<span className={s.meta}>
|
|
||||||
{single
|
|
||||||
? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}`
|
|
||||||
: `${g.sources.length} languages`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className={s.arrow}>
|
|
||||||
{single ? "→" : open ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!single && open && g.sources.map((src) => (
|
|
||||||
<button
|
|
||||||
key={src.id}
|
|
||||||
className={[s.row, s.rowIndented].join(" ")}
|
|
||||||
onClick={() => setActiveSource(src)}
|
|
||||||
>
|
|
||||||
<div className={s.indentSpacer} />
|
|
||||||
<div className={s.info}>
|
|
||||||
<span className={s.name}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
|
||||||
</div>
|
|
||||||
<span className={s.arrow}>→</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { Source } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name, preferring the given language.
|
|
||||||
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately.
|
|
||||||
*/
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
|
||||||
const byName = new Map<string, Source[]>();
|
|
||||||
for (const src of sources) {
|
|
||||||
if (src.id === "0") continue;
|
|
||||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
|
||||||
byName.get(src.name)!.push(src);
|
|
||||||
}
|
|
||||||
const picked: Source[] = [];
|
|
||||||
for (const group of byName.values()) {
|
|
||||||
const preferred = group.find((s) => s.lang === preferredLang);
|
|
||||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
|
||||||
}
|
|
||||||
return picked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
|
||||||
* This eliminates the same series appearing from multiple sources in grids.
|
|
||||||
*/
|
|
||||||
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: T[] = [];
|
|
||||||
for (const m of items) {
|
|
||||||
const key = m.title.toLowerCase().trim();
|
|
||||||
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
|
||||||
*/
|
|
||||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const out: T[] = [];
|
|
||||||
for (const m of items) {
|
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import App from "./App.svelte";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
const app = new App({ target: document.getElementById("app")! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Library from "./components/pages/Library.svelte";
|
||||||
|
import Search from "./components/search/Search.svelte";
|
||||||
|
import History from "./components/history/History.svelte";
|
||||||
|
import Explore from "./components/pages/Explore.svelte";
|
||||||
|
import Downloads from "./components/downloads/Downloads.svelte";
|
||||||
|
import Extensions from "./components/pages/Extensions.svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
"/": Library,
|
||||||
|
"/search": Search,
|
||||||
|
"/history": History,
|
||||||
|
"/explore": Explore,
|
||||||
|
"/downloads": Downloads,
|
||||||
|
"/extensions": Extensions,
|
||||||
|
};
|
||||||
+151
-288
@@ -1,317 +1,180 @@
|
|||||||
import { create } from "zustand";
|
import { writable, get } from "svelte/store";
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
import type { Manga, Chapter, Source } from "../lib/types";
|
import type { Manga, Chapter, Source } from "../lib/types";
|
||||||
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||||
|
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||||
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
export type Theme =
|
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||||
| "dark" // default — near-black
|
|
||||||
| "high-contrast" // darker + sharper text
|
|
||||||
| "light" // warm off-white
|
|
||||||
| "light-contrast" // light + max contrast
|
|
||||||
| "midnight" // blue-black tint
|
|
||||||
| "warm"; // amber/sepia tint
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
mangaId: number;
|
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||||
mangaTitle: string;
|
chapterId: number; chapterName: string; pageNumber: number; readAt: number;
|
||||||
thumbnailUrl: string;
|
|
||||||
chapterId: number;
|
|
||||||
chapterName: string;
|
|
||||||
pageNumber: number;
|
|
||||||
readAt: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string; kind: "success" | "error" | "info" | "download";
|
||||||
kind: "success" | "error" | "info" | "download";
|
title: string; body?: string; duration?: number;
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveDownload {
|
export interface ActiveDownload { chapterId: number; mangaId: number; progress: number }
|
||||||
chapterId: number;
|
|
||||||
mangaId: number;
|
|
||||||
progress: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder { id: string; name: string; mangaIds: number[]; showTab: boolean }
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
mangaIds: number[];
|
|
||||||
showTab: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
pageStyle: PageStyle;
|
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode;
|
||||||
readingDirection: ReadingDirection;
|
maxPageWidth: number; pageGap: boolean; optimizeContrast: boolean;
|
||||||
fitMode: FitMode;
|
offsetDoubleSpreads: boolean; preloadPages: number; autoMarkRead: boolean;
|
||||||
maxPageWidth: number;
|
autoNextChapter: boolean; libraryCropCovers: boolean; libraryPageSize: number;
|
||||||
pageGap: boolean;
|
showNsfw: boolean; chapterSortDir: ChapterSortDir; chapterPageSize: number;
|
||||||
optimizeContrast: boolean;
|
uiScale: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
||||||
offsetDoubleSpreads: boolean;
|
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
||||||
preloadPages: number;
|
preferredExtensionLang: string; keybinds: Keybinds; idleTimeoutMin?: number;
|
||||||
autoMarkRead: boolean;
|
splashCards?: boolean; storageLimitGb: number | null; folders: Folder[];
|
||||||
autoNextChapter: boolean;
|
markReadOnNext: boolean; readerDebounceMs: number; theme: Theme;
|
||||||
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[];
|
|
||||||
/**
|
|
||||||
* Mark a chapter as read when the user manually taps the "next chapter"
|
|
||||||
* button/key while autoNextChapter is off. Has no effect when autoNextChapter
|
|
||||||
* is on (the scroll-based mark-as-read logic handles that path).
|
|
||||||
*/
|
|
||||||
markReadOnNext: boolean;
|
|
||||||
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
|
||||||
readerDebounceMs: number;
|
|
||||||
/** UI colour theme. Applied as data-theme on <html>. */
|
|
||||||
theme: Theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
pageStyle: "longstrip",
|
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
||||||
readingDirection: "ltr",
|
maxPageWidth: 900, pageGap: true, optimizeContrast: false,
|
||||||
fitMode: "width",
|
offsetDoubleSpreads: false, preloadPages: 3, autoMarkRead: true,
|
||||||
maxPageWidth: 900,
|
autoNextChapter: true, libraryCropCovers: true, libraryPageSize: 48,
|
||||||
pageGap: true,
|
showNsfw: false, chapterSortDir: "desc", chapterPageSize: 25,
|
||||||
optimizeContrast: false,
|
uiScale: 100, compactSidebar: false, gpuAcceleration: true,
|
||||||
offsetDoubleSpreads: false,
|
serverUrl: "http://localhost:4567", serverBinary: "tachidesk-server",
|
||||||
preloadPages: 3,
|
autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
|
||||||
autoMarkRead: true,
|
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, folders: [],
|
||||||
autoNextChapter: true,
|
markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
|
||||||
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: [],
|
|
||||||
markReadOnNext: true,
|
|
||||||
readerDebounceMs: 120,
|
|
||||||
theme: "dark",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Store {
|
function loadPersisted() {
|
||||||
navPage: NavPage;
|
try {
|
||||||
setNavPage: (page: NavPage) => void;
|
const raw = localStorage.getItem("moku-store");
|
||||||
genreFilter: string;
|
if (!raw) return null;
|
||||||
setGenreFilter: (genre: string) => void;
|
return JSON.parse(raw);
|
||||||
searchPrefill: string;
|
} catch { return null; }
|
||||||
setSearchPrefill: (q: string) => void;
|
|
||||||
activeManga: Manga | null;
|
|
||||||
setActiveManga: (manga: Manga | null) => void;
|
|
||||||
previewManga: Manga | null;
|
|
||||||
setPreviewManga: (manga: Manga | null) => void;
|
|
||||||
activeChapter: Chapter | null;
|
|
||||||
activeChapterList: Chapter[];
|
|
||||||
openReader: (chapter: Chapter, chapterList: Chapter[]) => void;
|
|
||||||
closeReader: () => void;
|
|
||||||
activeSource: Source | null;
|
|
||||||
setActiveSource: (source: Source | null) => void;
|
|
||||||
pageUrls: string[];
|
|
||||||
setPageUrls: (urls: string[]) => void;
|
|
||||||
pageNumber: number;
|
|
||||||
setPageNumber: (n: number) => void;
|
|
||||||
libraryFilter: LibraryFilter;
|
|
||||||
setLibraryFilter: (filter: LibraryFilter) => void;
|
|
||||||
libraryTagFilter: string[];
|
|
||||||
setLibraryTagFilter: (tags: string[]) => void;
|
|
||||||
settingsOpen: boolean;
|
|
||||||
openSettings: () => void;
|
|
||||||
closeSettings: () => void;
|
|
||||||
activeDownloads: ActiveDownload[];
|
|
||||||
setActiveDownloads: (items: ActiveDownload[]) => void;
|
|
||||||
history: HistoryEntry[];
|
|
||||||
addHistory: (entry: HistoryEntry) => void;
|
|
||||||
clearHistory: () => void;
|
|
||||||
toasts: Toast[];
|
|
||||||
addToast: (toast: Omit<Toast, "id">) => void;
|
|
||||||
dismissToast: (id: string) => void;
|
|
||||||
settings: Settings;
|
|
||||||
updateSettings: (patch: Partial<Settings>) => void;
|
|
||||||
resetKeybinds: () => void;
|
|
||||||
// Folder helpers
|
|
||||||
addFolder: (name: string) => string;
|
|
||||||
removeFolder: (id: string) => void;
|
|
||||||
renameFolder: (id: string, name: string) => void;
|
|
||||||
toggleFolderTab: (id: string) => void;
|
|
||||||
assignMangaToFolder: (folderId: string, mangaId: number) => void;
|
|
||||||
removeMangaFromFolder: (folderId: string, mangaId: number) => void;
|
|
||||||
getMangaFolders: (mangaId: number) => Folder[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function genId(): string {
|
function persist(key: string, value: unknown) {
|
||||||
return Math.random().toString(36).slice(2, 10);
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<Store>()(
|
const saved = loadPersisted();
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
navPage: "library",
|
|
||||||
setNavPage: (navPage) => set({ navPage }),
|
|
||||||
genreFilter: "",
|
|
||||||
setGenreFilter: (genreFilter) => set({ genreFilter }),
|
|
||||||
searchPrefill: "",
|
|
||||||
setSearchPrefill: (searchPrefill) => set({ searchPrefill }),
|
|
||||||
activeManga: null,
|
|
||||||
setActiveManga: (activeManga) => set({ activeManga }),
|
|
||||||
previewManga: null,
|
|
||||||
setPreviewManga: (previewManga) => set({ previewManga }),
|
|
||||||
activeChapter: null,
|
|
||||||
activeChapterList: [],
|
|
||||||
openReader: (chapter, chapterList) =>
|
|
||||||
set({ activeChapter: chapter, activeChapterList: chapterList, pageUrls: [], pageNumber: 1 }),
|
|
||||||
closeReader: () =>
|
|
||||||
set({ activeChapter: null, activeChapterList: [], pageUrls: [], pageNumber: 1 }),
|
|
||||||
activeSource: null,
|
|
||||||
setActiveSource: (activeSource) => set({ activeSource }),
|
|
||||||
pageUrls: [],
|
|
||||||
setPageUrls: (pageUrls) => set({ pageUrls }),
|
|
||||||
pageNumber: 1,
|
|
||||||
setPageNumber: (pageNumber) => set({ pageNumber }),
|
|
||||||
libraryFilter: "library",
|
|
||||||
setLibraryFilter: (libraryFilter) => set({ libraryFilter }),
|
|
||||||
libraryTagFilter: [],
|
|
||||||
setLibraryTagFilter: (libraryTagFilter) => set({ libraryTagFilter }),
|
|
||||||
settingsOpen: false,
|
|
||||||
openSettings: () => set({ settingsOpen: true }),
|
|
||||||
closeSettings: () => set({ settingsOpen: false }),
|
|
||||||
activeDownloads: [],
|
|
||||||
setActiveDownloads: (activeDownloads) => set({ activeDownloads }),
|
|
||||||
history: [],
|
|
||||||
addHistory: (entry) =>
|
|
||||||
set((s) => {
|
|
||||||
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
|
||||||
if (existing === 0) {
|
|
||||||
const updated = [...s.history];
|
|
||||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
|
||||||
return { history: updated };
|
|
||||||
}
|
|
||||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
|
||||||
return { history: [entry, ...deduped].slice(0, 300) };
|
|
||||||
}),
|
|
||||||
clearHistory: () => set({ history: [] }),
|
|
||||||
toasts: [],
|
|
||||||
addToast: (toast) =>
|
|
||||||
set((s) => ({
|
|
||||||
toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5),
|
|
||||||
})),
|
|
||||||
dismissToast: (id) =>
|
|
||||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
|
||||||
settings: DEFAULT_SETTINGS,
|
|
||||||
updateSettings: (patch) =>
|
|
||||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
|
||||||
resetKeybinds: () =>
|
|
||||||
set((s) => ({ settings: { ...s.settings, keybinds: DEFAULT_KEYBINDS } })),
|
|
||||||
|
|
||||||
// ── Folder actions ──────────────────────────────────────────────────────
|
function mergeSettings(saved: any): Settings {
|
||||||
addFolder: (name) => {
|
return {
|
||||||
const id = genId();
|
...DEFAULT_SETTINGS,
|
||||||
set((s) => ({
|
...saved?.settings,
|
||||||
settings: {
|
folders: saved?.settings?.folders ?? [],
|
||||||
...s.settings,
|
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
|
||||||
folders: [...s.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }],
|
};
|
||||||
},
|
}
|
||||||
}));
|
|
||||||
return id;
|
export const navPage = writable<NavPage>(saved?.navPage ?? "library");
|
||||||
},
|
export const libraryFilter = writable<LibraryFilter>(saved?.libraryFilter ?? "library");
|
||||||
removeFolder: (id) =>
|
export const history = writable<HistoryEntry[]>(saved?.history ?? []);
|
||||||
set((s) => ({
|
export const settings = writable<Settings>(mergeSettings(saved));
|
||||||
settings: {
|
|
||||||
...s.settings,
|
export const genreFilter = writable<string>("");
|
||||||
folders: s.settings.folders.filter((f) => f.id !== id),
|
export const searchPrefill = writable<string>("");
|
||||||
},
|
export const activeManga = writable<Manga | null>(null);
|
||||||
})),
|
export const previewManga = writable<Manga | null>(null);
|
||||||
renameFolder: (id, name) =>
|
export const activeSource = writable<Source | null>(null);
|
||||||
set((s) => ({
|
export const pageUrls = writable<string[]>([]);
|
||||||
settings: {
|
export const pageNumber = writable<number>(1);
|
||||||
...s.settings,
|
export const libraryTagFilter = writable<string[]>([]);
|
||||||
folders: s.settings.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f),
|
export const settingsOpen = writable<boolean>(false);
|
||||||
},
|
export const activeDownloads = writable<ActiveDownload[]>([]);
|
||||||
})),
|
export const toasts = writable<Toast[]>([]);
|
||||||
toggleFolderTab: (id) =>
|
|
||||||
set((s) => ({
|
export const activeChapter = writable<Chapter | null>(null);
|
||||||
settings: {
|
export const activeChapterList = writable<Chapter[]>([]);
|
||||||
...s.settings,
|
|
||||||
folders: s.settings.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f),
|
export function openReader(chapter: Chapter, chapterList: Chapter[]) {
|
||||||
},
|
activeChapter.set(chapter);
|
||||||
})),
|
activeChapterList.set(chapterList);
|
||||||
assignMangaToFolder: (folderId, mangaId) =>
|
pageUrls.set([]);
|
||||||
set((s) => ({
|
pageNumber.set(1);
|
||||||
settings: {
|
}
|
||||||
...s.settings,
|
|
||||||
folders: s.settings.folders.map((f) =>
|
export function closeReader() {
|
||||||
f.id === folderId && !f.mangaIds.includes(mangaId)
|
activeChapter.set(null);
|
||||||
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
|
activeChapterList.set([]);
|
||||||
: f
|
pageUrls.set([]);
|
||||||
),
|
pageNumber.set(1);
|
||||||
},
|
}
|
||||||
})),
|
|
||||||
removeMangaFromFolder: (folderId, mangaId) =>
|
export function addHistory(entry: HistoryEntry) {
|
||||||
set((s) => ({
|
history.update((h) => {
|
||||||
settings: {
|
if (h[0]?.chapterId === entry.chapterId) {
|
||||||
...s.settings,
|
const updated = [...h];
|
||||||
folders: s.settings.folders.map((f) =>
|
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
f.id === folderId
|
return updated;
|
||||||
? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) }
|
|
||||||
: f
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
getMangaFolders: (mangaId) =>
|
|
||||||
get().settings.folders.filter((f) => f.mangaIds.includes(mangaId)),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "moku-store",
|
|
||||||
partialize: (s) => ({
|
|
||||||
settings: s.settings,
|
|
||||||
navPage: s.navPage,
|
|
||||||
libraryFilter: s.libraryFilter,
|
|
||||||
history: s.history,
|
|
||||||
}),
|
|
||||||
merge: (persisted: any, current) => ({
|
|
||||||
...current,
|
|
||||||
...(persisted as object),
|
|
||||||
settings: {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
...(persisted as any)?.settings,
|
|
||||||
folders: (persisted as any)?.settings?.folders ?? [],
|
|
||||||
keybinds: {
|
|
||||||
...DEFAULT_KEYBINDS,
|
|
||||||
...(persisted as any)?.settings?.keybinds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
)
|
return [entry, ...h.filter((x) => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToast(toast: Omit<Toast, "id">) {
|
||||||
|
toasts.update((t) => [...t, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
toasts.update((t) => t.filter((x) => x.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(patch: Partial<Settings>) {
|
||||||
|
settings.update((s) => ({ ...s, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetKeybinds() {
|
||||||
|
settings.update((s) => ({ ...s, keybinds: DEFAULT_KEYBINDS }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const genId = () => Math.random().toString(36).slice(2, 10);
|
||||||
|
|
||||||
|
export function addFolder(name: string): string {
|
||||||
|
const id = genId();
|
||||||
|
settings.update((s) => ({ ...s, folders: [...s.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] }));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFolder(id: string) {
|
||||||
|
settings.update((s) => ({ ...s, folders: s.folders.filter((f) => f.id !== id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameFolder(id: string, name: string) {
|
||||||
|
settings.update((s) => ({ ...s, folders: s.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFolderTab(id: string) {
|
||||||
|
settings.update((s) => ({ ...s, folders: s.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignMangaToFolder(folderId: string, mangaId: number) {
|
||||||
|
settings.update((s) => ({
|
||||||
|
...s, folders: s.folders.map((f) =>
|
||||||
|
f.id === folderId && !f.mangaIds.includes(mangaId) ? { ...f, mangaIds: [...f.mangaIds, mangaId] } : f
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMangaFromFolder(folderId: string, mangaId: number) {
|
||||||
|
settings.update((s) => ({
|
||||||
|
...s, folders: s.folders.map((f) =>
|
||||||
|
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) } : f
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMangaFolders(mangaId: number): Folder[] {
|
||||||
|
return get(settings).folders.filter((f) => f.mangaIds.includes(mangaId));
|
||||||
|
}
|
||||||
|
|
||||||
|
navPage.subscribe((v) => persist("moku-store", { ...loadPersisted(), navPage: v }));
|
||||||
|
libraryFilter.subscribe((v) => persist("moku-store", { ...loadPersisted(), libraryFilter: v }));
|
||||||
|
history.subscribe((v) => persist("moku-store", { ...loadPersisted(), history: v }));
|
||||||
|
settings.subscribe((v) => persist("moku-store", { ...loadPersisted(), settings: v }));
|
||||||
|
|||||||
+10
-61
@@ -1,21 +1,10 @@
|
|||||||
/* ─────────────────────────────────────────────
|
|
||||||
Moku — Global Styles
|
|
||||||
───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap");
|
||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
@import "./animations.css";
|
@import "./animations.css";
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #app { height: 100%; overflow: hidden; }
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg-void);
|
background: var(--bg-void);
|
||||||
@@ -26,52 +15,22 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
font-feature-settings: "kern" 1, "liga" 1;
|
font-feature-settings: "kern" 1, "liga" 1;
|
||||||
/* GPU: promote root to compositor layer for smooth compositing */
|
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars */
|
|
||||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--radius-full); transition: background var(--t-base); }
|
||||||
background: var(--border-strong);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: background var(--t-base);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
|
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
|
||||||
|
|
||||||
/* Focus */
|
:focus-visible { outline: 1px solid var(--border-focus); outline-offset: 2px; }
|
||||||
:focus-visible {
|
::selection { background: var(--accent-dim); color: var(--accent-fg); }
|
||||||
outline: 1px solid var(--border-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection */
|
|
||||||
::selection {
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent-fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base resets */
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
button { font-family: inherit; cursor: pointer; background: none; border: none; color: inherit; }
|
||||||
|
input, textarea { font-family: inherit; background: none; border: none; color: inherit; }
|
||||||
img { display: block; }
|
img { display: block; }
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
/* Range — reader scrubber */
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -82,24 +41,14 @@ input[type="range"] {
|
|||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
width: 12px;
|
width: 12px; height: 12px;
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-fg);
|
background: var(--accent-fg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--t-base), transform var(--t-base);
|
transition: background var(--t-base), transform var(--t-base);
|
||||||
}
|
}
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent-bright); transform: scale(1.15); }
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb:hover {
|
.mono { font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
||||||
background: var(--accent-bright);
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Monospace label utility */
|
|
||||||
.mono {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
+12
-14
@@ -1,21 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"isolatedModules": true,
|
||||||
"noUnusedParameters": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"$lib/*": ["./src/lib/*"],
|
||||||
|
"$store/*": ["./src/store/*"],
|
||||||
|
"$components/*": ["./src/components/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
+10
-4
@@ -1,14 +1,20 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [svelte()],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/src-tauri/**", '**/build-dir/**', '**/repo/**', '**/.flatpak-builder/**'],
|
ignored: ["**/.flatpak-builder/**", "**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: ["es2021", "chrome100", "safari13"],
|
||||||
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user