mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Fixed Downloader Pause/Clear & Began Revision #1 Bug Fixes
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ export default function App() {
|
|||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||||
}, [settings.uiScale]);
|
}, [settings.uiScale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -34,9 +34,19 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
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:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.iconBtn:disabled { opacity: 0.3; cursor: default; }
|
.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 {
|
.statusBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -55,6 +65,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--text-faint);
|
background: var(--text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusDotActive {
|
.statusDotActive {
|
||||||
@@ -68,6 +79,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusCount {
|
.statusCount {
|
||||||
@@ -87,11 +99,14 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: border-color var(--t-fast);
|
transition: border-color var(--t-fast), opacity var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActive { border-color: var(--accent-dim); }
|
.rowActive { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
/* Fade out rows being removed */
|
||||||
|
.rowRemoving { opacity: 0.4; pointer-events: none; }
|
||||||
|
|
||||||
/* Thumbnail */
|
/* Thumbnail */
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -185,8 +200,8 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
transition: color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
|
.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
.removeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
import { Play, Pause, Trash, CircleNotch, X } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -10,41 +10,103 @@ import type { DownloadStatus } from "../../lib/types";
|
|||||||
import s from "./DownloadQueue.module.css";
|
import s from "./DownloadQueue.module.css";
|
||||||
|
|
||||||
export default function DownloadQueue() {
|
export default function DownloadQueue() {
|
||||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
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
|
||||||
|
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() {
|
async function poll() {
|
||||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
.then((d) => {
|
.then((d) => applyStatus(d.downloadStatus))
|
||||||
setStatus(d.downloadStatus);
|
|
||||||
setActiveDownloads(
|
|
||||||
d.downloadStatus.queue.map((item) => ({
|
|
||||||
chapterId: item.chapter.id,
|
|
||||||
mangaId: item.chapter.mangaId,
|
|
||||||
progress: item.progress,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll();
|
poll();
|
||||||
const id = setInterval(poll, 1500);
|
const id = setInterval(poll, 2000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); }
|
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||||
async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); }
|
|
||||||
async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); }
|
async function togglePlay() {
|
||||||
async function dequeue(chapterId: number) {
|
if (togglingPlay) return;
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error);
|
setTogglingPlay(true);
|
||||||
poll();
|
// Optimistic flip so button responds instantly
|
||||||
|
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(); // resync on error
|
||||||
|
} finally {
|
||||||
|
setTogglingPlay(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = status?.queue ?? [];
|
async function clear() {
|
||||||
|
if (clearing) return;
|
||||||
|
setClearing(true);
|
||||||
|
// Optimistic clear
|
||||||
|
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(); // resync on error
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dequeue(chapterId: number) {
|
||||||
|
if (dequeueing.has(chapterId)) return;
|
||||||
|
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||||
|
// Optimistic remove
|
||||||
|
setStatus((prev) =>
|
||||||
|
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||||
|
// Sync authoritative state after dequeue
|
||||||
|
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";
|
const isRunning = status?.state === "STARTED";
|
||||||
|
|
||||||
function pagesDownloaded(progress: number, pageCount: number): number {
|
function pagesDownloaded(progress: number, pageCount: number): number {
|
||||||
@@ -56,24 +118,45 @@ export default function DownloadQueue() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Downloads</h1>
|
<h1 className={s.heading}>Downloads</h1>
|
||||||
<div className={s.headerActions}>
|
<div className={s.headerActions}>
|
||||||
{isRunning ? (
|
{/* Play / Pause toggle */}
|
||||||
<button className={s.iconBtn} onClick={stop} title="Pause">
|
<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" />
|
<Pause size={14} weight="fill" />
|
||||||
</button>
|
) : (
|
||||||
) : (
|
|
||||||
<button className={s.iconBtn} onClick={start} disabled={queue.length === 0} title="Resume">
|
|
||||||
<Play size={14} weight="fill" />
|
<Play size={14} weight="fill" />
|
||||||
</button>
|
)}
|
||||||
)}
|
</button>
|
||||||
<button className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue">
|
|
||||||
<Trash size={14} weight="regular" />
|
{/* Clear queue */}
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
<div className={[s.statusDot, isRunning ? s.statusDotActive : ""].join(" ").trim()} />
|
||||||
<span className={s.statusText}>{isRunning ? "Downloading" : "Paused"}</span>
|
<span className={s.statusText}>
|
||||||
|
{togglingPlay
|
||||||
|
? (isRunning ? "Pausing…" : "Starting…")
|
||||||
|
: isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
<span className={s.statusCount}>{queue.length} queued</span>
|
<span className={s.statusCount}>{queue.length} queued</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,15 +169,16 @@ export default function DownloadQueue() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.list}>
|
<div className={s.list}>
|
||||||
{queue.map((item, i) => {
|
{queue.map((item, i) => {
|
||||||
const isActive = i === 0 && isRunning;
|
const isActive = i === 0 && isRunning;
|
||||||
const pages = item.chapter.pageCount ?? 0;
|
const pages = item.chapter.pageCount ?? 0;
|
||||||
const done = pagesDownloaded(item.progress, pages);
|
const done = pagesDownloaded(item.progress, pages);
|
||||||
const manga = item.chapter.manga;
|
const manga = item.chapter.manga;
|
||||||
|
const isRemoving = dequeueing.has(item.chapter.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.chapter.id}
|
key={item.chapter.id}
|
||||||
className={[s.row, isActive ? s.rowActive : ""].join(" ").trim()}
|
className={[s.row, isActive ? s.rowActive : "", isRemoving ? s.rowRemoving : ""].join(" ").trim()}
|
||||||
>
|
>
|
||||||
{manga?.thumbnailUrl && (
|
{manga?.thumbnailUrl && (
|
||||||
<div className={s.thumb}>
|
<div className={s.thumb}>
|
||||||
@@ -136,9 +220,12 @@ export default function DownloadQueue() {
|
|||||||
<button
|
<button
|
||||||
className={s.removeBtn}
|
className={s.removeBtn}
|
||||||
onClick={() => dequeue(item.chapter.id)}
|
onClick={() => dequeue(item.chapter.id)}
|
||||||
|
disabled={isRemoving}
|
||||||
title="Remove from queue"
|
title="Remove from queue"
|
||||||
>
|
>
|
||||||
<X size={12} weight="light" />
|
{isRemoving
|
||||||
|
? <CircleNotch size={11} weight="light" className="anim-spin" />
|
||||||
|
: <X size={12} weight="light" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,15 +17,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: var(--sp-3);
|
margin-bottom: var(--sp-3);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
/* Explicit reset — prevents browser from injecting a default button background */
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.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 {
|
.logoIcon {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
@@ -58,10 +64,21 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
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);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.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); }
|
.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); }
|
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
@@ -76,6 +93,15 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-faint);
|
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);
|
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:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
@@ -50,6 +50,7 @@ export default function Library() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
@@ -58,7 +59,10 @@ export default function Library() {
|
|||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||||
const folders = useStore((state) => state.settings.folders);
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
||||||
@@ -99,8 +103,6 @@ export default function Library() {
|
|||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||||
|
|
||||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||||
// We need to know columns to chunk filtered into rows.
|
|
||||||
// Use a ResizeObserver on the scroll container to get real width.
|
|
||||||
const [containerWidth, setContainerWidth] = useState(800);
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -142,9 +144,17 @@ export default function Library() {
|
|||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||||
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||||
|
const ids = downloadedChapters.map((c) => c.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
// Delete the downloaded files
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||||
|
|
||||||
|
// Also remove these chapters from the download queue (fix #12)
|
||||||
|
// Fire-and-forget — queue removal is best-effort
|
||||||
|
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||||
|
|
||||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,17 @@ export default function Library() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
label: "Open",
|
label: "Open",
|
||||||
@@ -181,6 +202,35 @@ export default function Library() {
|
|||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||||
onClick: () => deleteAllDownloads(m),
|
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());
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +258,16 @@ export default function Library() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root} ref={scrollRef}>
|
<div
|
||||||
|
className={s.root}
|
||||||
|
ref={scrollRef}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// Only fire on the bare background, not on cards
|
||||||
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<div className={s.headerLeft}>
|
<div className={s.headerLeft}>
|
||||||
<h1 className={s.heading}>Library</h1>
|
<h1 className={s.heading}>Library</h1>
|
||||||
@@ -285,7 +344,7 @@ export default function Library() {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
{libraryFilter === "library"
|
{libraryFilter === "library"
|
||||||
? "No manga saved to library. Browse sources to add some."
|
? "No manga saved to library, browse sources to add some."
|
||||||
: libraryFilter === "downloaded"
|
: libraryFilter === "downloaded"
|
||||||
? "No downloaded manga."
|
? "No downloaded manga."
|
||||||
: !isBuiltinFilter
|
: !isBuiltinFilter
|
||||||
@@ -342,6 +401,14 @@ export default function Library() {
|
|||||||
onClose={() => setCtx(null)}
|
onClose={() => setCtx(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{emptyCtx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={emptyCtx.x}
|
||||||
|
y={emptyCtx.y}
|
||||||
|
items={buildEmptyCtxItems()}
|
||||||
|
onClose={() => setEmptyCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -874,3 +874,100 @@
|
|||||||
.dlItemDanger:hover:not(:disabled) {
|
.dlItemDanger:hover:not(:disabled) {
|
||||||
background: var(--color-error-bg) !important;
|
background: var(--color-error-bg) !important;
|
||||||
}
|
}
|
||||||
|
/* ── Download dropdown extended: Next-N quick buttons + range picker ─────── */
|
||||||
|
.dlSectionLabel {
|
||||||
|
padding: 6px var(--sp-3) 2px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlNextBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.dlNextBtn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.dlNextBtn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.dlNextSub {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: var(--sp-1) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.dlRangeInput:focus { border-color: var(--border-focus); }
|
||||||
|
.dlRangeInput::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.dlRangeSep {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlRangeGo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-dim);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dlRangeGo:hover:not(:disabled) { background: var(--accent-dim); }
|
||||||
|
.dlRangeGo:disabled { opacity: 0.3; cursor: default; }
|
||||||
@@ -33,6 +33,155 @@ interface CtxState {
|
|||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
|
|
||||||
|
// ── Download dropdown with range picker ──────────────────────────────────────
|
||||||
|
interface DownloadDropdownProps {
|
||||||
|
sortedChapters: Chapter[];
|
||||||
|
continueChapter: { chapter: Chapter; type: string } | null;
|
||||||
|
downloadedCount: number;
|
||||||
|
deletingAll: boolean;
|
||||||
|
onEnqueue: (ids: number[]) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadDropdown({
|
||||||
|
sortedChapters, continueChapter, downloadedCount, deletingAll,
|
||||||
|
onEnqueue, onDelete, onClose,
|
||||||
|
}: DownloadDropdownProps) {
|
||||||
|
const [rangeFrom, setRangeFrom] = useState("");
|
||||||
|
const [rangeTo, setRangeTo] = useState("");
|
||||||
|
const [showRange, setShowRange] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler, true);
|
||||||
|
return () => document.removeEventListener("mousedown", handler, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const continueIdx = continueChapter
|
||||||
|
? sortedChapters.indexOf(continueChapter.chapter)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
function enqueueNext(n: number) {
|
||||||
|
if (continueIdx < 0) return;
|
||||||
|
const ids = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueRange() {
|
||||||
|
const from = parseFloat(rangeFrom);
|
||||||
|
const to = parseFloat(rangeTo);
|
||||||
|
if (isNaN(from) || isNaN(to)) return;
|
||||||
|
const lo = Math.min(from, to), hi = Math.max(from, to);
|
||||||
|
const ids = sortedChapters
|
||||||
|
.filter((c) => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
if (ids.length) onEnqueue(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadNotDl = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded);
|
||||||
|
const allNotDl = sortedChapters.filter((c) => !c.isDownloaded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.dlDropdown} ref={ref}>
|
||||||
|
|
||||||
|
{/* ── Next N from current ── */}
|
||||||
|
{continueChapter && continueIdx >= 0 && (
|
||||||
|
<>
|
||||||
|
<p className={s.dlSectionLabel}>
|
||||||
|
From Ch.{continueChapter.chapter.chapterNumber}
|
||||||
|
</p>
|
||||||
|
<div className={s.dlNextRow}>
|
||||||
|
{[5, 10, 25].map((n) => {
|
||||||
|
const avail = sortedChapters
|
||||||
|
.slice(continueIdx, continueIdx + n)
|
||||||
|
.filter((c) => !c.isDownloaded).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
className={s.dlNextBtn}
|
||||||
|
disabled={avail === 0}
|
||||||
|
onClick={() => enqueueNext(n)}
|
||||||
|
>
|
||||||
|
<span>Next {n}</span>
|
||||||
|
<span className={s.dlNextSub}>{avail} new</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Custom range ── */}
|
||||||
|
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
|
||||||
|
<span>Custom range…</span>
|
||||||
|
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
||||||
|
</button>
|
||||||
|
{showRange && (
|
||||||
|
<div className={s.dlRangeRow}>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="From"
|
||||||
|
value={rangeFrom}
|
||||||
|
onChange={(e) => setRangeFrom(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<span className={s.dlRangeSep}>–</span>
|
||||||
|
<input
|
||||||
|
className={s.dlRangeInput}
|
||||||
|
placeholder="To"
|
||||||
|
value={rangeTo}
|
||||||
|
onChange={(e) => setRangeTo(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && enqueueRange()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={s.dlRangeGo}
|
||||||
|
disabled={!rangeFrom.trim() || !rangeTo.trim()}
|
||||||
|
onClick={enqueueRange}
|
||||||
|
>
|
||||||
|
Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
|
||||||
|
{/* ── Standard options ── */}
|
||||||
|
<button className={s.dlItem}
|
||||||
|
onClick={() => onEnqueue(unreadNotDl.map((c) => c.id))}>
|
||||||
|
<span>Unread chapters</span>
|
||||||
|
<span className={s.dlItemSub}>{unreadNotDl.length} remaining</span>
|
||||||
|
</button>
|
||||||
|
<button className={s.dlItem}
|
||||||
|
onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
|
||||||
|
<span>Download all</span>
|
||||||
|
<span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{downloadedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={s.dlDivider} />
|
||||||
|
<button
|
||||||
|
className={[s.dlItem, s.dlItemDanger].join(" ")}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deletingAll}
|
||||||
|
>
|
||||||
|
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
||||||
|
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Folder picker (icon button for list header) ───────────────────────────────
|
// ── Folder picker (icon button for list header) ───────────────────────────────
|
||||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -300,15 +449,26 @@ export default function SeriesDetail() {
|
|||||||
danger: ch.isDownloaded,
|
danger: ch.isDownloaded,
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: "Download next 5 from here",
|
||||||
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
|
onClick: () => {
|
||||||
|
const ids = sortedChapters
|
||||||
|
.slice(indexInSorted, indexInSorted + 5)
|
||||||
|
.filter((c) => !c.isDownloaded)
|
||||||
|
.map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Download all from here",
|
label: "Download all from here",
|
||||||
icon: <DownloadSimple size={13} weight="light" />,
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const fromHere = sortedChapters
|
const ids = sortedChapters
|
||||||
.slice(indexInSorted)
|
.slice(indexInSorted)
|
||||||
.filter((c) => !c.isDownloaded)
|
.filter((c) => !c.isDownloaded)
|
||||||
.map((c) => c.id);
|
.map((c) => c.id);
|
||||||
enqueueMultiple(fromHere);
|
enqueueMultiple(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -544,50 +704,15 @@ export default function SeriesDetail() {
|
|||||||
<Download size={13} weight="light" />
|
<Download size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
{dlOpen && (
|
{dlOpen && (
|
||||||
<div className={s.dlDropdown}>
|
<DownloadDropdown
|
||||||
{continueChapter && (
|
sortedChapters={sortedChapters}
|
||||||
<button className={s.dlItem}
|
continueChapter={continueChapter}
|
||||||
onClick={() => {
|
downloadedCount={downloadedCount}
|
||||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
deletingAll={deletingAll}
|
||||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
onEnqueue={(ids) => { enqueueMultiple(ids); setDlOpen(false); }}
|
||||||
enqueueMultiple(ids);
|
onDelete={() => { deleteAllDownloads(); setDlOpen(false); }}
|
||||||
setDlOpen(false);
|
onClose={() => setDlOpen(false)}
|
||||||
}}>
|
/>
|
||||||
<span>From current</span>
|
|
||||||
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Unread chapters</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Download all</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
{downloadedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
|
|
||||||
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
|
|
||||||
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
|
|
||||||
disabled={deletingAll}
|
|
||||||
>
|
|
||||||
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
|
|
||||||
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -347,6 +347,18 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
|||||||
checked={settings.compactSidebar}
|
checked={settings.compactSidebar}
|
||||||
onChange={(v) => update({ compactSidebar: v })} />
|
onChange={(v) => update({ compactSidebar: v })} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -717,6 +729,8 @@ export default function SettingsModal() {
|
|||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const contentBodyRef = useRef<HTMLDivElement>(null);
|
const contentBodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentBodyRef.current?.scrollTo({ top: 0 });
|
contentBodyRef.current?.scrollTo({ top: 0 });
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState, useMemo, memo } from "react";
|
import { useEffect, useState, useMemo, memo } from "react";
|
||||||
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire } from "@phosphor-icons/react";
|
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
@@ -43,16 +45,18 @@ function SkeletonRow({ count = 8 }: { count?: number }) {
|
|||||||
const MiniCard = memo(function MiniCard({
|
const MiniCard = memo(function MiniCard({
|
||||||
manga,
|
manga,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
subtitle,
|
subtitle,
|
||||||
progress,
|
progress,
|
||||||
}: {
|
}: {
|
||||||
manga: Manga;
|
manga: Manga;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button className={s.card} onClick={onClick}>
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
@@ -89,6 +93,44 @@ function GenreDrill({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onOpen: (m: Manga) => void;
|
onOpen: (m: Manga) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
const folders = useStore((st) => st.settings.folders);
|
||||||
|
const addFolder = useStore((st) => st.addFolder);
|
||||||
|
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||||
|
|
||||||
|
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 }).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 filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const combined = new Map<number, Manga>();
|
const combined = new Map<number, Manga>();
|
||||||
[...manga, ...sourceManga]
|
[...manga, ...sourceManga]
|
||||||
@@ -108,7 +150,7 @@ function GenreDrill({
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.drillGrid}>
|
<div className={s.drillGrid}>
|
||||||
{filtered.map((m) => (
|
{filtered.map((m) => (
|
||||||
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)}>
|
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(m.thumbnailUrl)}
|
src={thumbUrl(m.thumbnailUrl)}
|
||||||
@@ -126,6 +168,14 @@ function GenreDrill({
|
|||||||
<div className={s.empty}>No manga found for {genre}.</div>
|
<div className={s.empty}>No manga found for {genre}.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,6 +343,49 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
const settings = useStore((s) => s.settings);
|
const settings = useStore((s) => s.settings);
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
const setNavPage = useStore((s) => s.setNavPage);
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(() => setActiveManga({ ...m, inLibrary: true }))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Load library
|
// Load library
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -473,6 +566,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
key={manga.id}
|
key={manga.id}
|
||||||
manga={manga}
|
manga={manga}
|
||||||
onClick={() => openManga(manga)}
|
onClick={() => openManga(manga)}
|
||||||
|
onContextMenu={(e) => openCtx(e, manga)}
|
||||||
subtitle={chapterName}
|
subtitle={chapterName}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
@@ -493,7 +587,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
>
|
>
|
||||||
<div className={s.row}>
|
<div className={s.row}>
|
||||||
{recommended.map((m) => (
|
{recommended.map((m) => (
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
<GhostCard key={`ghost-rec-${i}`} />
|
<GhostCard key={`ghost-rec-${i}`} />
|
||||||
@@ -520,7 +614,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.row}>
|
<div className={s.row}>
|
||||||
{popularManga.map((m) => (
|
{popularManga.map((m) => (
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
<GhostCard key={`ghost-pop-${i}`} />
|
<GhostCard key={`ghost-pop-${i}`} />
|
||||||
@@ -544,7 +638,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
>
|
>
|
||||||
<div className={s.row}>
|
<div className={s.row}>
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||||
))}
|
))}
|
||||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
<GhostCard key={`ghost-${genre}-${i}`} />
|
<GhostCard key={`ghost-${genre}-${i}`} />
|
||||||
@@ -565,6 +659,15 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next } from "@phosphor-icons/react";
|
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga } from "../../lib/types";
|
import type { Manga } from "../../lib/types";
|
||||||
import s from "./SourceBrowse.module.css";
|
import s from "./SourceBrowse.module.css";
|
||||||
@@ -11,8 +12,12 @@ type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
|||||||
export default function SourceBrowse() {
|
export default function SourceBrowse() {
|
||||||
const activeSource = useStore((state) => state.activeSource);
|
const activeSource = useStore((state) => state.activeSource);
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const setNavPage = useStore((state) => state.setNavPage);
|
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 [mangas, setMangas] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -63,6 +68,45 @@ export default function SourceBrowse() {
|
|||||||
setNavPage("library");
|
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;
|
if (!activeSource) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +164,7 @@ export default function SourceBrowse() {
|
|||||||
) : (
|
) : (
|
||||||
<div className={s.grid}>
|
<div className={s.grid}>
|
||||||
{mangas.map((m) => (
|
{mangas.map((m) => (
|
||||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
<button key={m.id} className={s.card} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||||
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
{m.inLibrary && <span className={s.inLibraryBadge}>In Library</span>}
|
||||||
@@ -152,6 +196,14 @@ export default function SourceBrowse() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+31
-5
@@ -250,23 +250,49 @@ export const DEQUEUE_DOWNLOAD = `
|
|||||||
|
|
||||||
export const START_DOWNLOADER = `
|
export const START_DOWNLOADER = `
|
||||||
mutation StartDownloader {
|
mutation StartDownloader {
|
||||||
startDownloader {
|
startDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const STOP_DOWNLOADER = `
|
export const STOP_DOWNLOADER = `
|
||||||
mutation StopDownloader {
|
mutation StopDownloader {
|
||||||
stopDownloader {
|
stopDownloader(input: {}) {
|
||||||
downloadStatus { state }
|
downloadStatus {
|
||||||
|
state
|
||||||
|
queue {
|
||||||
|
progress
|
||||||
|
state
|
||||||
|
chapter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
pageCount
|
||||||
|
mangaId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CLEAR_DOWNLOADER = `
|
export const CLEAR_DOWNLOADER = `
|
||||||
mutation ClearDownloader {
|
mutation ClearDownloader {
|
||||||
clearDownloader {
|
clearDownloader(input: {}) {
|
||||||
downloadStatus {
|
downloadStatus {
|
||||||
state
|
state
|
||||||
queue {
|
queue {
|
||||||
|
|||||||
+3
-2
@@ -59,6 +59,8 @@ export interface Settings {
|
|||||||
keybinds: Keybinds;
|
keybinds: Keybinds;
|
||||||
storageLimitGb: number | null;
|
storageLimitGb: number | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||||
|
readerDebounceMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -87,6 +89,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
keybinds: DEFAULT_KEYBINDS,
|
keybinds: DEFAULT_KEYBINDS,
|
||||||
storageLimitGb: null,
|
storageLimitGb: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
|
readerDebounceMs: 120,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
@@ -166,12 +169,10 @@ export const useStore = create<Store>()(
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId);
|
||||||
if (existing === 0) {
|
if (existing === 0) {
|
||||||
// Same chapter is already at the top — just update pageNumber and readAt in place
|
|
||||||
const updated = [...s.history];
|
const updated = [...s.history];
|
||||||
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
return { history: updated };
|
return { history: updated };
|
||||||
}
|
}
|
||||||
// New chapter or chapter not at top — remove old entry, prepend fresh
|
|
||||||
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||||
return { history: [entry, ...deduped].slice(0, 300) };
|
return { history: [entry, ...deduped].slice(0, 300) };
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user