From 7b61f8583370a25d9ee57e27d4e8f5c01ef8088a Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Mon, 23 Feb 2026 11:36:52 -0600 Subject: [PATCH] [V1] Created Toaster & Augmented Explore Tab --- src/App.tsx | 59 +++- src/components/downloads/DownloadQueue.tsx | 37 +-- src/components/layout/Toaster.module.css | 85 +++++ src/components/layout/Toaster.tsx | 69 +++++ src/components/pages/History.module.css | 38 +++ src/components/pages/History.tsx | 59 +++- src/components/pages/Reader.tsx | 78 +++-- src/components/pages/SeriesDetail.module.css | 84 ++++- src/components/pages/SeriesDetail.tsx | 307 ++++++++++++++----- src/components/sources/Explore.tsx | 7 +- src/store/index.ts | 18 ++ 11 files changed, 704 insertions(+), 137 deletions(-) create mode 100644 src/components/layout/Toaster.module.css create mode 100644 src/components/layout/Toaster.tsx diff --git a/src/App.tsx b/src/App.tsx index bb21d26..7a27331 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,16 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { gql } from "./lib/client"; +import { GET_DOWNLOAD_STATUS } from "./lib/queries"; import "./styles/global.css"; import { useStore } from "./store"; import Layout from "./components/layout/Layout"; import Reader from "./components/pages/Reader"; import Settings from "./components/settings/Settings"; import TitleBar from "./components/layout/TitleBar"; +import Toaster from "./components/layout/Toaster"; +import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import s from "./App.module.css"; export default function App() { @@ -14,6 +18,41 @@ export default function App() { const settingsOpen = useStore((s) => s.settingsOpen); const settings = useStore((s) => s.settings); const setActiveDownloads = useStore((s) => s.setActiveDownloads); + const addToast = useStore((s) => s.addToast); + + // Ref-based snapshot of the last known queue so we can diff across polls/events + const prevQueueRef = useRef([]); + + /** Compare old queue → new queue and toast for anything that finished. */ + function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) { + for (const item of prev) { + if (item.state !== "DOWNLOADING") continue; + const stillPresent = next.some((q) => q.chapter.id === item.chapter.id); + if (!stillPresent) { + const manga = item.chapter.manga; + addToast({ + kind: "success", + title: "Chapter downloaded", + body: manga + ? `${manga.title} — ${item.chapter.name}` + : item.chapter.name, + duration: 4000, + }); + } + } + } + + function applyQueue(next: DownloadQueueItem[]) { + detectCompletions(prevQueueRef.current, next); + prevQueueRef.current = next; + setActiveDownloads( + next.map((item) => ({ + chapterId: item.chapter.id, + mangaId: item.chapter.mangaId, + progress: item.progress, + })) + ); + } useEffect(() => { document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; @@ -33,7 +72,22 @@ export default function App() { return () => { invoke("kill_server").catch(() => {}); }; }, [settings.autoStartServer, settings.serverBinary]); - // Global Tauri download-progress listener — no polling, always current + // Global download status poller — always running, regardless of which page is open. + // This is the single source of truth for completion toasts. + useEffect(() => { + function poll() { + gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) + .then((d) => applyQueue(d.downloadStatus.queue)) + .catch(console.error); + } + poll(); // immediate first fetch + const id = setInterval(poll, 2000); + return () => clearInterval(id); + }, []); + + // Tauri real-time event — supplements the poller for instant UI badge updates. + // The payload is a lighter shape (no chapter name/manga), so we only use it + // for active download progress, not for completion detection. useEffect(() => { type DlPayload = { chapterId: number; mangaId: number; progress: number }[]; const unsub = listen("download-progress", (e) => { @@ -49,6 +103,7 @@ export default function App() { {activeChapter ? : } {settingsOpen && } + ); } \ No newline at end of file diff --git a/src/components/downloads/DownloadQueue.tsx b/src/components/downloads/DownloadQueue.tsx index 8afb062..0e63191 100644 --- a/src/components/downloads/DownloadQueue.tsx +++ b/src/components/downloads/DownloadQueue.tsx @@ -10,14 +10,15 @@ 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 [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); + const [clearing, setClearing] = useState(false); + const [dequeueing, setDequeueing] = useState>(new Set()); + const setActiveDownloads = useStore((s) => s.setActiveDownloads); - // Apply status to local state + global store + // Apply status to local state + global store. + // Completion toasting is handled globally in App.tsx — no duplication here. const applyStatus = useCallback((ds: DownloadStatus) => { setStatus(ds); setActiveDownloads( @@ -47,7 +48,6 @@ export default function DownloadQueue() { 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 { @@ -60,7 +60,7 @@ export default function DownloadQueue() { } } catch (e) { console.error(e); - poll(); // resync on error + poll(); } finally { setTogglingPlay(false); } @@ -69,7 +69,6 @@ export default function DownloadQueue() { async function clear() { if (clearing) return; setClearing(true); - // Optimistic clear setStatus((prev) => prev ? { ...prev, queue: [] } : prev); setActiveDownloads([]); try { @@ -77,7 +76,7 @@ export default function DownloadQueue() { applyStatus(d.clearDownloader.downloadStatus); } catch (e) { console.error(e); - poll(); // resync on error + poll(); } finally { setClearing(false); } @@ -86,13 +85,11 @@ export default function DownloadQueue() { 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); @@ -118,7 +115,6 @@ export default function DownloadQueue() {

Downloads

- {/* Play / Pause toggle */} +
+ ); +} + +// ── toaster container ──────────────────────────────────────────────────────── + +export default function Toaster() { + const toasts = useStore((s) => s.toasts); + + if (!toasts.length) return null; + + return createPortal( +
+ {toasts.map((t) => )} +
, + document.body + ); +} \ No newline at end of file diff --git a/src/components/pages/History.module.css b/src/components/pages/History.module.css index d387c37..02f24a8 100644 --- a/src/components/pages/History.module.css +++ b/src/components/pages/History.module.css @@ -38,6 +38,44 @@ } .clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); } +.statsBar { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-6); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; + background: var(--bg-raised); +} + +.statItem { + display: flex; + align-items: baseline; + gap: 5px; +} + +.statVal { + font-family: var(--font-ui); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--accent-fg); + letter-spacing: var(--tracking-tight); +} + +.statLabel { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.statDivider { + width: 1px; + height: 12px; + background: var(--border-dim); + flex-shrink: 0; +} + .list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); } .group { margin-bottom: var(--sp-5); } diff --git a/src/components/pages/History.tsx b/src/components/pages/History.tsx index 2342348..a970016 100644 --- a/src/components/pages/History.tsx +++ b/src/components/pages/History.tsx @@ -28,10 +28,18 @@ function dayLabel(ts: number): string { return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); } -// ── Session grouping ────────────────────────────────────────────────────────── -// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed -// into one session card showing the chapter range read. +// Estimate reading time: ~8 seconds per page, counted from chapter entries +// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown) +function formatReadTime(minutes: number): string { + if (minutes < 1) return "< 1 min"; + if (minutes < 60) return `${minutes} min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} +// ── Session grouping ────────────────────────────────────────────────────────── const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min export interface ReadingSession { @@ -97,7 +105,8 @@ export default function History() { const history = useStore((s) => s.history); const clearHistory = useStore((s) => s.clearHistory); const setActiveManga = useStore((s) => s.setActiveManga); - const setNavPage = useStore((s) => s.setNavPage); + const openReader = useStore((s) => s.openReader); + const activeChapterList = useStore((s) => s.activeChapterList); const [search, setSearch] = useState(""); const filtered = useMemo(() => { @@ -111,9 +120,28 @@ export default function History() { const sessions = useMemo(() => buildSessions(filtered), [filtered]); const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]); + // ── Stats ───────────────────────────────────────────────────────────────── + const stats = useMemo(() => { + if (!history.length) return null; + // Unique chapters read + const uniqueChapters = new Set(history.map((e) => e.chapterId)).size; + // Unique manga read + const uniqueManga = new Set(history.map((e) => e.mangaId)).size; + // Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter + const estimatedMinutes = Math.round(uniqueChapters * 4.5); + return { uniqueChapters, uniqueManga, estimatedMinutes }; + }, [history]); + function resumeReading(session: ReadingSession) { - setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any); - setNavPage("library"); + // If the chapter list is available in store (user already visited this manga), + // open the reader directly for a snappier experience + const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId); + if (chapterInList && activeChapterList.length > 0) { + openReader(chapterInList, activeChapterList); + } else { + // Fall back to opening SeriesDetail — it will show the continue button + setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any); + } } return ( @@ -137,6 +165,25 @@ export default function History() {
+ {stats && ( +
+ + {stats.uniqueChapters} + chapters read + + + + {stats.uniqueManga} + series + + + + {formatReadTime(stats.estimatedMinutes)} + est. read time + +
+ )} + {history.length === 0 ? (
diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx index 4193067..a12a4bd 100644 --- a/src/components/pages/Reader.tsx +++ b/src/components/pages/Reader.tsx @@ -77,53 +77,86 @@ function DownloadModal({ remaining, onClose, }: { - chapter: { id: number; name: string }; - remaining: { id: number }[]; + chapter: { id: number; name: string; isDownloaded?: boolean }; + remaining: { id: number; isDownloaded?: boolean }[]; onClose: () => void; }) { + const addToast = useStore((s) => s.addToast); const [nextN, setNextN] = useState(5); const [busy, setBusy] = useState(false); - const run = async (fn: () => Promise) => { + // Only offer chapters that aren't already downloaded + const queueable = remaining.filter((c) => !c.isDownloaded); + + const run = async (fn: () => Promise, toastBody: string) => { setBusy(true); - await fn().catch(console.error); + try { + await fn(); + addToast({ kind: "download", title: "Download queued", body: toastBody }); + } catch (e) { + addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) }); + } setBusy(false); onClose(); }; + const thisAlreadyDl = !!chapter.isDownloaded; + return (
e.stopPropagation()}>

Download

-
-
e.stopPropagation()}> - + disabled={nextN <= 1} + >− {nextN} - +
-
@@ -909,6 +942,7 @@ export default function Reader() { style={cssVars} tabIndex={-1} onClick={handleTap} + onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }} onKeyDown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css index 7d61892..6cd9627 100644 --- a/src/components/pages/SeriesDetail.module.css +++ b/src/components/pages/SeriesDetail.module.css @@ -98,6 +98,16 @@ letter-spacing: var(--tracking-wide); } +.genreClickable { + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.genreClickable:hover { + color: var(--accent-fg); + border-color: var(--accent-dim); + background: var(--accent-muted); +} + .sourceLabel { font-family: var(--font-ui); font-size: var(--text-2xs); @@ -111,11 +121,52 @@ color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; - -webkit-line-clamp: 8; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.descriptionExpanded { + -webkit-line-clamp: unset; + display: block; + overflow: visible; +} + +.descriptionWrap { + display: flex; + flex-direction: column; + gap: 2px; +} + +.descToggle { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--accent-fg); + letter-spacing: var(--tracking-wide); + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + opacity: 0.7; + transition: opacity var(--t-base); +} +.descToggle:hover { opacity: 1; } + +.genreToggle { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + padding: 1px 6px; + letter-spacing: var(--tracking-wide); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base); +} +.genreToggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); } + /* ── Progress ── */ .progressSection { display: flex; @@ -230,10 +281,39 @@ color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; - margin-top: auto; padding-top: var(--sp-2); } +/* ── Sidebar mark-all quick actions ── */ +.markAllRow { + display: flex; + gap: var(--sp-2); +} + +.markAllBtn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 5px var(--sp-2); + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-faint); + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.markAllBtn:hover:not(:disabled) { + color: var(--text-secondary); + border-color: var(--border-strong); + background: var(--bg-raised); +} +.markAllBtn:disabled { opacity: 0.3; cursor: default; } + /* ── Chapter list ── */ .listWrap { flex: 1; diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index 7188aee..65b0f1d 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, - ArrowSquareOut, BookOpen, CircleNotch, Play, + ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple, } from "@phosphor-icons/react"; @@ -17,6 +17,8 @@ import MigrateModal from "./MigrateModal"; import type { Manga, Chapter } from "../../lib/types"; import s from "./SeriesDetail.module.css"; +// ── Helpers ─────────────────────────────────────────────────────────────────── + function formatDate(ts: string | null | undefined): string { if (!ts) return ""; const n = Number(ts); @@ -33,7 +35,8 @@ interface CtxState { const CHAPTERS_PER_PAGE = 25; -// ── Download dropdown with range picker ────────────────────────────────────── +// ── Download dropdown ───────────────────────────────────────────────────────── + interface DownloadDropdownProps { sortedChapters: Chapter[]; continueChapter: { chapter: Chapter; type: string } | null; @@ -91,12 +94,9 @@ function DownloadDropdown({ return (
- {/* ── Next N from current ── */} {continueChapter && continueIdx >= 0 && ( <> -

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

+

From Ch.{continueChapter.chapter.chapterNumber}

{[5, 10, 25].map((n) => { const avail = sortedChapters @@ -119,7 +119,6 @@ function DownloadDropdown({ )} - {/* ── Custom range ── */} - @@ -182,18 +178,20 @@ function DownloadDropdown({ ); } -// ── Folder picker (icon button for list header) ─────────────────────────────── +// ── Folder picker ───────────────────────────────────────────────────────────── + function FolderPicker({ mangaId }: { mangaId: number }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); const ref = useRef(null); + const folders = useStore((st) => st.settings.folders); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder); const addFolder = useStore((st) => st.addFolder); - const [newName, setNewName] = useState(""); - const [creating, setCreating] = useState(false); - const assigned = folders.filter((f) => f.mangaIds.includes(mangaId)); + const assigned = folders.filter((f) => f.mangaIds.includes(mangaId)); const hasAssigned = assigned.length > 0; useEffect(() => { @@ -283,31 +281,39 @@ function FolderPicker({ mangaId }: { mangaId: number }) { } // ── Main component ──────────────────────────────────────────────────────────── -export default function SeriesDetail() { - const activeManga = useStore((state) => state.activeManga); - const setActiveManga = useStore((state) => state.setActiveManga); - const openReader = useStore((state) => state.openReader); - const settings = useStore((state) => state.settings); - const updateSettings = useStore((state) => state.updateSettings); - const [manga, setManga] = useState(activeManga); - const [chapters, setChapters] = useState([]); - const [loadingManga, setLoadingManga] = useState(true); +export default function SeriesDetail() { + const activeManga = useStore((state) => state.activeManga); + const setActiveManga = useStore((state) => state.setActiveManga); + const openReader = useStore((state) => state.openReader); + const settings = useStore((state) => state.settings); + const updateSettings = useStore((state) => state.updateSettings); + const addToast = useStore((state) => state.addToast); + const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter); + const setLibraryFilter = useStore((state) => state.setLibraryFilter); + + const [manga, setManga] = useState(activeManga); + const [chapters, setChapters] = useState([]); + const [loadingManga, setLoadingManga] = useState(false); const [loadingChapters, setLoadingChapters] = useState(true); - const [enqueueing, setEnqueueing] = useState>(new Set()); - const [dlOpen, setDlOpen] = useState(false); - const [detailsOpen, setDetailsOpen] = useState(false); - const [migrateOpen, setMigrateOpen] = useState(false); + const [enqueueing, setEnqueueing] = useState>(new Set()); + const [dlOpen, setDlOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const [migrateOpen, setMigrateOpen] = useState(false); const [togglingLibrary, setTogglingLibrary] = useState(false); - const [chapterPage, setChapterPage] = useState(1); - const [ctx, setCtx] = useState(null); - const [jumpOpen, setJumpOpen] = useState(false); - const [jumpInput, setJumpInput] = useState(""); - const [viewMode, setViewMode] = useState<"list" | "grid">("list"); - const [deletingAll, setDeletingAll] = useState(false); + const [chapterPage, setChapterPage] = useState(1); + const [ctx, setCtx] = useState(null); + const [jumpOpen, setJumpOpen] = useState(false); + const [jumpInput, setJumpInput] = useState(""); + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + const [deletingAll, setDeletingAll] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [descExpanded, setDescExpanded] = useState(false); + const [genresExpanded, setGenresExpanded] = useState(false); const sortDir = settings.chapterSortDir; + // Load extended manga details useEffect(() => { if (!activeManga) return; setLoadingManga(true); @@ -326,6 +332,7 @@ export default function SeriesDetail() { }); }, []); + // Load chapters: show cache immediately, then silently refresh from source useEffect(() => { if (!activeManga) return; setLoadingChapters(true); @@ -333,34 +340,40 @@ export default function SeriesDetail() { setChapterPage(1); loadChapters(activeManga.id) + .then((cached) => + gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) + .then(() => loadChapters(activeManga.id)) + .then((fresh) => { + // Suppress no-op: if count unchanged the state is already correct + void (fresh.length === cached.length); + }) + .catch(console.error) + ) .catch(console.error) .finally(() => setLoadingChapters(false)); - - gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) - .then(() => loadChapters(activeManga.id)) - .catch(console.error); }, [activeManga?.id]); + // ── Derived state ────────────────────────────────────────────────────────── + const sortedChapters = useMemo(() => sortDir === "desc" ? [...chapters].reverse() : [...chapters], [chapters, sortDir] ); - const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE); - const pageChapters = sortedChapters.slice( + const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE); + const pageChapters = sortedChapters.slice( (chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE ); - - const readCount = chapters.filter((c) => c.isRead).length; - const totalCount = chapters.length; - const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0; + const readCount = chapters.filter((c) => c.isRead).length; + const totalCount = chapters.length; + const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0; const downloadedCount = chapters.filter((c) => c.isDownloaded).length; const continueChapter = useMemo(() => { if (!chapters.length) return null; const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); - const anyRead = asc.some((c) => c.isRead); + const anyRead = asc.some((c) => c.isRead); const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); if (inProgress) return { chapter: inProgress, type: "continue" as const }; const firstUnread = asc.find((c) => !c.isRead); @@ -368,6 +381,8 @@ export default function SeriesDetail() { return { chapter: asc[0], type: "reread" as const }; }, [chapters]); + // ── Actions ──────────────────────────────────────────────────────────────── + async function toggleLibrary() { if (!manga) return; setTogglingLibrary(true); @@ -381,23 +396,42 @@ export default function SeriesDetail() { e.stopPropagation(); setEnqueueing((prev) => new Set(prev).add(chapter.id)); await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error); + addToast({ kind: "download", title: "Download queued", body: chapter.name }); setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; }); if (activeManga) loadChapters(activeManga.id); } + async function enqueueMultiple(chapterIds: number[]) { + if (!chapterIds.length) return; + await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); + addToast({ + kind: "download", + title: "Download queued", + body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`, + }); + if (activeManga) loadChapters(activeManga.id); + } + async function markRead(chapterId: number, isRead: boolean) { await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c)); } - async function markAllAboveRead(indexInSorted: number) { - const targets = sortedChapters.slice(0, indexInSorted + 1); - const ids = targets.filter((c) => !c.isRead).map((c) => c.id); + async function markBulk(ids: number[], isRead: boolean) { if (!ids.length) return; - await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error); - setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c)); + await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error); + setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead } : c)); } + const markAllAboveRead = (i: number) => + markBulk(sortedChapters.slice(0, i + 1).filter((c) => !c.isRead).map((c) => c.id), true); + const markAllBelowRead = (i: number) => + markBulk(sortedChapters.slice(i).filter((c) => !c.isRead).map((c) => c.id), true); + const markAllAboveUnread = (i: number) => + markBulk(sortedChapters.slice(0, i + 1).filter((c) => c.isRead).map((c) => c.id), false); + const markAllBelowUnread = (i: number) => + markBulk(sortedChapters.slice(i).filter((c) => c.isRead).map((c) => c.id), false); + async function deleteDownloaded(chapterId: number) { await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error); setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c)); @@ -412,37 +446,67 @@ export default function SeriesDetail() { setDeletingAll(false); } - async function enqueueMultiple(chapterIds: number[]) { - await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); - if (activeManga) loadChapters(activeManga.id); + async function refreshChapters() { + if (!activeManga || refreshing) return; + setRefreshing(true); + await gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) + .then(() => loadChapters(activeManga.id)) + .then(() => addToast({ kind: "success", title: "Chapters refreshed" })) + .catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) })) + .finally(() => setRefreshing(false)); } + // ── FIX: restored missing function declaration ───────────────────────────── function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) { e.preventDefault(); setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted }); } function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] { + const aboveItems = sortedChapters.slice(0, indexInSorted + 1); + const belowItems = sortedChapters.slice(indexInSorted); + const unreadAbove = aboveItems.filter((c) => !c.isRead).length; + const unreadBelow = belowItems.filter((c) => !c.isRead).length; + const readAbove = aboveItems.filter((c) => c.isRead).length; + const readBelow = belowItems.filter((c) => c.isRead).length; + const lastIdx = sortedChapters.length - 1; + return [ { label: ch.isRead ? "Mark as unread" : "Mark as read", - icon: ch.isRead - ? - : , + icon: ch.isRead ? : , onClick: () => markRead(ch.id, !ch.isRead), }, + { separator: true }, { - label: "Mark all above as read", + label: "Mark above as read", icon: , onClick: () => markAllAboveRead(indexInSorted), - disabled: indexInSorted === 0, + disabled: indexInSorted === 0 || unreadAbove === 0, + }, + { + label: "Mark above as unread", + icon: , + onClick: () => markAllAboveUnread(indexInSorted), + disabled: indexInSorted === 0 || readAbove === 0, + }, + { separator: true }, + { + label: "Mark below as read", + icon: , + onClick: () => markAllBelowRead(indexInSorted), + disabled: indexInSorted === lastIdx || unreadBelow === 0, + }, + { + label: "Mark below as unread", + icon: , + onClick: () => markAllBelowUnread(indexInSorted), + disabled: indexInSorted === lastIdx || readBelow === 0, }, { separator: true }, { label: ch.isDownloaded ? "Delete download" : "Download", - icon: ch.isDownloaded - ? - : , + icon: ch.isDownloaded ? : , onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error), @@ -474,14 +538,19 @@ export default function SeriesDetail() { ]; } + // ── Early exit ───────────────────────────────────────────────────────────── + if (!activeManga) return null; const statusLabel = manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null; + // ── Render ───────────────────────────────────────────────────────────────── + return (
e.preventDefault()}> + {/* ── Sidebar ── */}
+ ))} + {manga.genre.length > 5 && ( + + )}
)} - {manga?.description &&

{manga.description}

} + {manga?.description && ( +
+

+ {manga.description} +

+ {manga.description.length > 120 && ( + + )} +
+ )}
)} - {/* Progress bar */} + {/* Progress */} {totalCount > 0 && (
@@ -557,8 +658,6 @@ export default function SeriesDetail() { )}
- {/* Folder picker moved to chapter list header */} - {continueChapter && ( + +
+ )} + + {/* Details (collapsible) */} {!loadingManga && manga?.source && (
- - {/* Delete all downloads */} {downloadedCount > 0 && ( {activeManga && } {/* Jump to chapter */} @@ -752,8 +881,7 @@ export default function SeriesDetail() { )) ) ) : viewMode === "grid" ? ( - sortedChapters.map((ch) => { - const idxInSorted = sortedChapters.indexOf(ch); + sortedChapters.map((ch, idxInSorted) => { const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0; return ( ) : enqueueing.has(ch.id) ? ( ) : ( - )}
- +
); }) )} diff --git a/src/components/sources/Explore.tsx b/src/components/sources/Explore.tsx index 65a3164..b66b26b 100644 --- a/src/components/sources/Explore.tsx +++ b/src/components/sources/Explore.tsx @@ -448,7 +448,8 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { .finally(() => setLoadingPopular(false)); }, []); - // Once library loaded AND sources ready, search each frecency genre across sources + const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama", "Sci-fi", "Horror"]; + const frecencyGenres = useMemo(() => { const mangaScores = new Map(); const mangaReadAt = new Map(); @@ -469,6 +470,10 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1))); } + // If still empty (new user, no library), fall back to foundational genres + if (genreWeights.size === 0) { + return FOUNDATIONAL_GENRES.slice(0, 5); + } return Array.from(genreWeights.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) // top 3 genres only diff --git a/src/store/index.ts b/src/store/index.ts index 2fff536..a510de5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -20,6 +20,14 @@ export interface HistoryEntry { readAt: number; } +export interface Toast { + id: string; + kind: "success" | "error" | "info" | "download"; + title: string; + body?: string; + duration?: number; +} + export interface ActiveDownload { chapterId: number; mangaId: number; @@ -119,6 +127,9 @@ interface Store { history: HistoryEntry[]; addHistory: (entry: HistoryEntry) => void; clearHistory: () => void; + toasts: Toast[]; + addToast: (toast: Omit) => void; + dismissToast: (id: string) => void; settings: Settings; updateSettings: (patch: Partial) => void; resetKeybinds: () => void; @@ -177,6 +188,13 @@ export const useStore = create()( return { history: [entry, ...deduped].slice(0, 300) }; }), clearHistory: () => set({ history: [] }), + toasts: [], + addToast: (toast) => + set((s) => ({ + toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5), + })), + dismissToast: (id) => + set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), settings: DEFAULT_SETTINGS, updateSettings: (patch) => set((s) => ({ settings: { ...s.settings, ...patch } })),