[V1] Fixed Downloader Pause/Clear & Began Revision #1 Bug Fixes

This commit is contained in:
Youwes09
2026-02-23 00:03:37 -06:00
parent 55d1431673
commit edf2af8618
12 changed files with 731 additions and 118 deletions
+1 -1
View File
@@ -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;
+108 -21
View File
@@ -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 {
@@ -12,36 +12,98 @@ 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 [togglingPlay, setTogglingPlay] = useState(false);
const [clearing, setClearing] = useState(false);
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
const setActiveDownloads = useStore((s) => s.setActiveDownloads); const setActiveDownloads = useStore((s) => s.setActiveDownloads);
async function poll() { // Apply status to local state + global store
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) const applyStatus = useCallback((ds: DownloadStatus) => {
.then((d) => { setStatus(ds);
setStatus(d.downloadStatus);
setActiveDownloads( setActiveDownloads(
d.downloadStatus.queue.map((item) => ({ ds.queue.map((item) => ({
chapterId: item.chapter.id, chapterId: item.chapter.id,
mangaId: item.chapter.mangaId, mangaId: item.chapter.mangaId,
progress: item.progress, progress: item.progress,
})) }))
); );
}) }, [setActiveDownloads]);
async function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyStatus(d.downloadStatus))
.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() {
if (togglingPlay) return;
setTogglingPlay(true);
// 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);
}
}
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) { async function dequeue(chapterId: number) {
await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error); 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(); 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 queue = status?.queue ?? [];
@@ -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 className={s.iconBtn} onClick={clear} disabled={queue.length === 0} title="Clear queue"> </button>
{/* 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" /> <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>
@@ -90,11 +173,12 @@ export default function DownloadQueue() {
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>
+26
View File
@@ -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; }
+74 -7
View File
@@ -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);
@@ -59,6 +60,9 @@ export default function Library() {
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; }
+171 -46
View File
@@ -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>
)} )}
+14
View File
@@ -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]);
+109 -6
View File
@@ -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>
); );
} }
+55 -3
View File
@@ -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";
@@ -13,6 +14,10 @@ export default function SourceBrowse() {
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
View File
@@ -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
View File
@@ -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) };
}), }),