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,8 +1,8 @@
|
||||
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 { 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 type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
@@ -50,6 +50,7 @@ export default function Library() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
@@ -58,7 +59,10 @@ export default function Library() {
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
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(() => {
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
||||
@@ -99,8 +103,6 @@ export default function Library() {
|
||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||
|
||||
// ── 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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -142,9 +144,17 @@ export default function Library() {
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
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;
|
||||
|
||||
// Delete the downloaded files
|
||||
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));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -157,6 +167,17 @@ export default function Library() {
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||
const inFolder = f.mangaIds.includes(m.id);
|
||||
return {
|
||||
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||
onClick: () => inFolder
|
||||
? removeMangaFromFolder(f.id, m.id)
|
||||
: assignMangaToFolder(f.id, m.id),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Open",
|
||||
@@ -181,6 +202,35 @@ export default function Library() {
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||
onClick: () => deleteAllDownloads(m),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...mangaFolderEntries,
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) {
|
||||
const id = addFolder(name.trim());
|
||||
assignMangaToFolder(id, m.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) addFolder(name.trim());
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -208,7 +258,16 @@ export default function Library() {
|
||||
);
|
||||
|
||||
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.headerLeft}>
|
||||
<h1 className={s.heading}>Library</h1>
|
||||
@@ -285,7 +344,7 @@ export default function Library() {
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.center}>
|
||||
{libraryFilter === "library"
|
||||
? "No manga saved to library. Browse sources to add some."
|
||||
? "No manga saved to library, browse sources to add some."
|
||||
: libraryFilter === "downloaded"
|
||||
? "No downloaded manga."
|
||||
: !isBuiltinFilter
|
||||
@@ -342,6 +401,14 @@ export default function Library() {
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
)}
|
||||
{emptyCtx && (
|
||||
<ContextMenu
|
||||
x={emptyCtx.x}
|
||||
y={emptyCtx.y}
|
||||
items={buildEmptyCtxItems()}
|
||||
onClose={() => setEmptyCtx(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -873,4 +873,101 @@
|
||||
}
|
||||
.dlItemDanger:hover:not(:disabled) {
|
||||
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;
|
||||
|
||||
// ── 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) ───────────────────────────────
|
||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -300,15 +449,26 @@ export default function SeriesDetail() {
|
||||
danger: ch.isDownloaded,
|
||||
},
|
||||
{ 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",
|
||||
icon: <DownloadSimple size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const fromHere = sortedChapters
|
||||
const ids = sortedChapters
|
||||
.slice(indexInSorted)
|
||||
.filter((c) => !c.isDownloaded)
|
||||
.map((c) => c.id);
|
||||
enqueueMultiple(fromHere);
|
||||
enqueueMultiple(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -544,50 +704,15 @@ export default function SeriesDetail() {
|
||||
<Download size={13} weight="light" />
|
||||
</button>
|
||||
{dlOpen && (
|
||||
<div className={s.dlDropdown}>
|
||||
{continueChapter && (
|
||||
<button className={s.dlItem}
|
||||
onClick={() => {
|
||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
||||
enqueueMultiple(ids);
|
||||
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>
|
||||
<DownloadDropdown
|
||||
sortedChapters={sortedChapters}
|
||||
continueChapter={continueChapter}
|
||||
downloadedCount={downloadedCount}
|
||||
deletingAll={deletingAll}
|
||||
onEnqueue={(ids) => { enqueueMultiple(ids); setDlOpen(false); }}
|
||||
onDelete={() => { deleteAllDownloads(); setDlOpen(false); }}
|
||||
onClose={() => setDlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user