diff --git a/src/App.tsx b/src/App.tsx index 26e6bc6..bb21d26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ export default function App() { const setActiveDownloads = useStore((s) => s.setActiveDownloads); useEffect(() => { - document.documentElement.style.zoom = `${settings.uiScale}%`; + document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; }, [settings.uiScale]); useEffect(() => { diff --git a/src/components/downloads/DownloadQueue.module.css b/src/components/downloads/DownloadQueue.module.css index 75942a2..be88260 100644 --- a/src/components/downloads/DownloadQueue.module.css +++ b/src/components/downloads/DownloadQueue.module.css @@ -34,9 +34,19 @@ color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } - -.iconBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } +.iconBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } .iconBtn:disabled { opacity: 0.3; cursor: default; } +/* Loading state — accent tint so it's visually distinct */ +.iconBtnLoading { + border-color: var(--accent-dim); + color: var(--accent-fg); + background: var(--accent-muted); +} +.iconBtnLoading:hover:not(:disabled) { + border-color: var(--accent-dim); + color: var(--accent-fg); + background: var(--accent-muted); +} .statusBar { display: flex; @@ -55,6 +65,7 @@ border-radius: 50%; background: var(--text-faint); flex-shrink: 0; + transition: background var(--t-base); } .statusDotActive { @@ -68,6 +79,7 @@ color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); + transition: color var(--t-base); } .statusCount { @@ -87,11 +99,14 @@ background: var(--bg-raised); border: 1px solid var(--border-dim); 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); } +/* Fade out rows being removed */ +.rowRemoving { opacity: 0.4; pointer-events: none; } + /* Thumbnail */ .thumb { width: 36px; @@ -185,8 +200,8 @@ color: var(--text-faint); transition: color var(--t-base), background var(--t-base); } - -.removeBtn:hover { color: var(--color-error); background: var(--color-error-bg); } +.removeBtn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } +.removeBtn:disabled { opacity: 0.5; cursor: default; } .empty { display: flex; diff --git a/src/components/downloads/DownloadQueue.tsx b/src/components/downloads/DownloadQueue.tsx index 982fa28..8afb062 100644 --- a/src/components/downloads/DownloadQueue.tsx +++ b/src/components/downloads/DownloadQueue.tsx @@ -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 { gql, thumbUrl } from "../../lib/client"; import { @@ -10,41 +10,103 @@ import type { DownloadStatus } from "../../lib/types"; import s from "./DownloadQueue.module.css"; export default function DownloadQueue() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const setActiveDownloads = useStore((s) => s.setActiveDownloads); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [togglingPlay, setTogglingPlay] = useState(false); + const [clearing, setClearing] = useState(false); + const [dequeueing, setDequeueing] = useState>(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() { gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) - .then((d) => { - setStatus(d.downloadStatus); - setActiveDownloads( - d.downloadStatus.queue.map((item) => ({ - chapterId: item.chapter.id, - mangaId: item.chapter.mangaId, - progress: item.progress, - })) - ); - }) + .then((d) => applyStatus(d.downloadStatus)) .catch(console.error) .finally(() => setLoading(false)); } useEffect(() => { poll(); - const id = setInterval(poll, 1500); + const id = setInterval(poll, 2000); return () => clearInterval(id); }, []); - async function start() { await gql(START_DOWNLOADER).catch(console.error); poll(); } - async function stop() { await gql(STOP_DOWNLOADER).catch(console.error); poll(); } - async function clear() { await gql(CLEAR_DOWNLOADER).catch(console.error); poll(); } - async function dequeue(chapterId: number) { - await gql(DEQUEUE_DOWNLOAD, { chapterId }).catch(console.error); - poll(); + // ── Actions ───────────────────────────────────────────────────────────────── + + 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); + } } - 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"; function pagesDownloaded(progress: number, pageCount: number): number { @@ -56,24 +118,45 @@ export default function DownloadQueue() {

Downloads

- {isRunning ? ( - - ) : ( - - )} - + + {/* Clear queue */} +
- {isRunning ? "Downloading" : "Paused"} + + {togglingPlay + ? (isRunning ? "Pausing…" : "Starting…") + : isRunning ? "Downloading" : "Paused"} + {queue.length} queued
@@ -86,15 +169,16 @@ export default function DownloadQueue() { ) : (
{queue.map((item, i) => { - const isActive = i === 0 && isRunning; - const pages = item.chapter.pageCount ?? 0; - const done = pagesDownloaded(item.progress, pages); - const manga = item.chapter.manga; + const isActive = i === 0 && isRunning; + const pages = item.chapter.pageCount ?? 0; + const done = pagesDownloaded(item.progress, pages); + const manga = item.chapter.manga; + const isRemoving = dequeueing.has(item.chapter.id); return (
{manga?.thumbnailUrl && (
@@ -136,9 +220,12 @@ export default function DownloadQueue() { )}
diff --git a/src/components/layout/Sidebar.module.css b/src/components/layout/Sidebar.module.css index 29e7214..4b5cca6 100644 --- a/src/components/layout/Sidebar.module.css +++ b/src/components/layout/Sidebar.module.css @@ -17,15 +17,21 @@ justify-content: center; margin-bottom: var(--sp-3); overflow: visible; + /* Explicit reset — prevents browser from injecting a default button background */ background: none; border: none; + outline: none; cursor: pointer; border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); padding: 0; + -webkit-appearance: none; + appearance: none; } .logo:hover { opacity: 0.8; transform: scale(0.96); } .logo:active { transform: scale(0.92); } +/* Kill the focus ring that can render as a coloured glow on some GTK themes */ +.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .logoIcon { width: 80px; @@ -58,10 +64,21 @@ display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); + /* Explicit resets — the green overlay was browser default button styles bleeding through */ + background: none; + border: none; + outline: none; + cursor: pointer; + padding: 0; + -webkit-appearance: none; + appearance: none; transition: color var(--t-base), background var(--t-base); } .tab:hover { color: var(--text-muted); background: var(--bg-raised); } +.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-md); } + .tabActive { color: var(--accent-fg); background: var(--accent-muted); } +/* Prevent hover state from overriding active colour */ .tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .bottom { @@ -76,6 +93,15 @@ display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); color: var(--text-faint); + /* Same explicit resets */ + background: none; + border: none; + outline: none; + cursor: pointer; + padding: 0; + -webkit-appearance: none; + appearance: none; transition: color var(--t-base), background var(--t-base), transform var(--t-slow); } -.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } \ No newline at end of file +.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); } +.settingsBtn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } \ No newline at end of file diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index fde7e2a..efbcd31 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -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(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(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: , + 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: , + 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: , + onClick: () => { + const name = prompt("Folder name:"); + if (name?.trim()) addFolder(name.trim()); + }, + }, ]; } @@ -208,7 +258,16 @@ export default function Library() { ); return ( -
+
{ + // 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 }); + }} + >

Library

@@ -285,7 +344,7 @@ export default function Library() { ) : filtered.length === 0 ? (
{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 && ( + setEmptyCtx(null)} + /> + )}
); } \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css index a5f6780..7d61892 100644 --- a/src/components/pages/SeriesDetail.module.css +++ b/src/components/pages/SeriesDetail.module.css @@ -873,4 +873,101 @@ } .dlItemDanger:hover:not(:disabled) { background: var(--color-error-bg) !important; -} \ No newline at end of file +} +/* ── 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; } \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index 3be41dd..7188aee 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -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(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 ( +
+ + {/* ── Next N from current ── */} + {continueChapter && continueIdx >= 0 && ( + <> +

+ From Ch.{continueChapter.chapter.chapterNumber} +

+
+ {[5, 10, 25].map((n) => { + const avail = sortedChapters + .slice(continueIdx, continueIdx + n) + .filter((c) => !c.isDownloaded).length; + return ( + + ); + })} +
+
+ + )} + + {/* ── Custom range ── */} + + {showRange && ( +
+ setRangeFrom(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && enqueueRange()} + /> + + setRangeTo(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && enqueueRange()} + /> + +
+ )} + +
+ + {/* ── Standard options ── */} + + + + {downloadedCount > 0 && ( + <> +
+ + + )} +
+ ); +} + // ── 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: , + onClick: () => { + const ids = sortedChapters + .slice(indexInSorted, indexInSorted + 5) + .filter((c) => !c.isDownloaded) + .map((c) => c.id); + enqueueMultiple(ids); + }, + }, { label: "Download all from here", icon: , 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() { {dlOpen && ( -
- {continueChapter && ( - - )} - - - {downloadedCount > 0 && ( - <> -
- - - )} -
+ { enqueueMultiple(ids); setDlOpen(false); }} + onDelete={() => { deleteAllDownloads(); setDlOpen(false); }} + onClose={() => setDlOpen(false)} + /> )}
)} diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index b465e8b..75746c9 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -347,6 +347,18 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p: checked={settings.compactSidebar} onChange={(v) => update({ compactSidebar: v })} />
+
+

Reader

+ update({ readerDebounceMs: v })} + /> +
); } @@ -717,6 +729,8 @@ export default function SettingsModal() { const backdropRef = useRef(null); const contentBodyRef = useRef(null); + + useEffect(() => { contentBodyRef.current?.scrollTo({ top: 0 }); }, [tab]); diff --git a/src/components/sources/Explore.tsx b/src/components/sources/Explore.tsx index 030e7ee..65a3164 100644 --- a/src/components/sources/Explore.tsx +++ b/src/components/sources/Explore.tsx @@ -1,6 +1,8 @@ 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 { 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 { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; @@ -43,16 +45,18 @@ function SkeletonRow({ count = 8 }: { count?: number }) { const MiniCard = memo(function MiniCard({ manga, onClick, + onContextMenu, subtitle, progress, }: { manga: Manga; onClick: () => void; + onContextMenu?: (e: React.MouseEvent) => void; subtitle?: string; progress?: number; }) { return ( -
+ {ctx && ( + setCtx(null)} + /> + )}
); } @@ -293,6 +343,49 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { const settings = useStore((s) => s.settings); const setActiveManga = useStore((s) => s.setActiveManga); 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: , + 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: , + onClick: () => assignMangaToFolder(f.id, m.id), + })), + ] : []), + { separator: true }, + { + label: "New folder & add", + icon: , + onClick: () => { + const name = prompt("Folder name:"); + if (name?.trim()) { + const id = addFolder(name.trim()); + assignMangaToFolder(id, m.id); + } + }, + }, + ]; + } // Load library useEffect(() => { @@ -473,6 +566,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { key={manga.id} manga={manga} onClick={() => openManga(manga)} + onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> @@ -493,7 +587,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { >
{recommended.map((m) => ( - openManga(m)} /> + openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( @@ -520,7 +614,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { ) : (
{popularManga.map((m) => ( - openManga(m)} /> + openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( @@ -544,7 +638,7 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { >
{items.map((m) => ( - openManga(m)} /> + openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( @@ -565,6 +659,15 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
)} + + {ctx && ( + setCtx(null)} + /> + )}
); } \ No newline at end of file diff --git a/src/components/sources/SourceBrowse.tsx b/src/components/sources/SourceBrowse.tsx index 5c90981..8c81dba 100644 --- a/src/components/sources/SourceBrowse.tsx +++ b/src/components/sources/SourceBrowse.tsx @@ -1,7 +1,8 @@ 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 { 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 type { Manga } from "../../lib/types"; import s from "./SourceBrowse.module.css"; @@ -11,8 +12,12 @@ type BrowseType = "POPULAR" | "LATEST" | "SEARCH"; export default function SourceBrowse() { const activeSource = useStore((state) => state.activeSource); const setActiveSource = useStore((state) => state.setActiveSource); - const setActiveManga = useStore((state) => state.setActiveManga); - const setNavPage = useStore((state) => state.setNavPage); + const setActiveManga = useStore((state) => state.setActiveManga); + const setNavPage = useStore((state) => state.setNavPage); + const folders = useStore((state) => state.settings.folders); + const addFolder = useStore((state) => state.addFolder); + const assignMangaToFolder = useStore((state) => state.assignMangaToFolder); + const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); const [mangas, setMangas] = useState([]); const [loading, setLoading] = useState(true); @@ -63,6 +68,45 @@ export default function SourceBrowse() { 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: , + 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: , + onClick: () => assignMangaToFolder(f.id, m.id), + })), + ] : []), + { separator: true }, + { + label: "New folder & add", + icon: , + onClick: () => { + const name = prompt("Folder name:"); + if (name?.trim()) { + const id = addFolder(name.trim()); + assignMangaToFolder(id, m.id); + } + }, + }, + ]; + } + if (!activeSource) return null; return ( @@ -120,7 +164,7 @@ export default function SourceBrowse() { ) : (
{mangas.map((m) => ( -
)} + {ctx && ( + setCtx(null)} + /> + )}
); } \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5ff1d3b..8930bec 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -250,23 +250,49 @@ export const DEQUEUE_DOWNLOAD = ` export const START_DOWNLOADER = ` mutation StartDownloader { - startDownloader { - downloadStatus { state } + startDownloader(input: {}) { + downloadStatus { + state + queue { + progress + state + chapter { + id + name + pageCount + mangaId + manga { id title thumbnailUrl } + } + } + } } } `; export const STOP_DOWNLOADER = ` mutation StopDownloader { - stopDownloader { - downloadStatus { state } + stopDownloader(input: {}) { + downloadStatus { + state + queue { + progress + state + chapter { + id + name + pageCount + mangaId + manga { id title thumbnailUrl } + } + } + } } } `; export const CLEAR_DOWNLOADER = ` mutation ClearDownloader { - clearDownloader { + clearDownloader(input: {}) { downloadStatus { state queue { diff --git a/src/store/index.ts b/src/store/index.ts index 3a5babd..d4eb9ec 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -59,6 +59,8 @@ export interface Settings { keybinds: Keybinds; storageLimitGb: number | null; folders: Folder[]; + /** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */ + readerDebounceMs: number; } export const DEFAULT_SETTINGS: Settings = { @@ -87,6 +89,7 @@ export const DEFAULT_SETTINGS: Settings = { keybinds: DEFAULT_KEYBINDS, storageLimitGb: null, folders: [], + readerDebounceMs: 120, }; interface Store { @@ -166,12 +169,10 @@ export const useStore = create()( set((s) => { const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId); if (existing === 0) { - // Same chapter is already at the top — just update pageNumber and readAt in place const updated = [...s.history]; updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; 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); return { history: [entry, ...deduped].slice(0, 300) }; }),