[V1] Created Toaster & Augmented Explore Tab

This commit is contained in:
Youwes09
2026-02-23 11:36:52 -06:00
parent cd2d79f80c
commit 7b61f85833
11 changed files with 704 additions and 137 deletions
+57 -2
View File
@@ -1,12 +1,16 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import "./styles/global.css"; import "./styles/global.css";
import { useStore } from "./store"; import { useStore } from "./store";
import Layout from "./components/layout/Layout"; import Layout from "./components/layout/Layout";
import Reader from "./components/pages/Reader"; import Reader from "./components/pages/Reader";
import Settings from "./components/settings/Settings"; import Settings from "./components/settings/Settings";
import TitleBar from "./components/layout/TitleBar"; 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"; import s from "./App.module.css";
export default function App() { export default function App() {
@@ -14,6 +18,41 @@ export default function App() {
const settingsOpen = useStore((s) => s.settingsOpen); const settingsOpen = useStore((s) => s.settingsOpen);
const settings = useStore((s) => s.settings); const settings = useStore((s) => s.settings);
const setActiveDownloads = useStore((s) => s.setActiveDownloads); 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<DownloadQueueItem[]>([]);
/** 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(() => { useEffect(() => {
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
@@ -33,7 +72,22 @@ export default function App() {
return () => { invoke("kill_server").catch(() => {}); }; return () => { invoke("kill_server").catch(() => {}); };
}, [settings.autoStartServer, settings.serverBinary]); }, [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(() => { useEffect(() => {
type DlPayload = { chapterId: number; mangaId: number; progress: number }[]; type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
const unsub = listen<DlPayload>("download-progress", (e) => { const unsub = listen<DlPayload>("download-progress", (e) => {
@@ -49,6 +103,7 @@ export default function App() {
{activeChapter ? <Reader /> : <Layout />} {activeChapter ? <Reader /> : <Layout />}
</div> </div>
{settingsOpen && <Settings />} {settingsOpen && <Settings />}
<Toaster />
</div> </div>
); );
} }
+14 -23
View File
@@ -10,14 +10,15 @@ import type { DownloadStatus } from "../../lib/types";
import s from "./DownloadQueue.module.css"; import s from "./DownloadQueue.module.css";
export default function DownloadQueue() { export default function DownloadQueue() {
const [status, setStatus] = useState<DownloadStatus | null>(null); const [status, setStatus] = useState<DownloadStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [togglingPlay, setTogglingPlay] = useState(false); const [togglingPlay, setTogglingPlay] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set()); const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
const setActiveDownloads = useStore((s) => s.setActiveDownloads); 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) => { const applyStatus = useCallback((ds: DownloadStatus) => {
setStatus(ds); setStatus(ds);
setActiveDownloads( setActiveDownloads(
@@ -47,7 +48,6 @@ export default function DownloadQueue() {
async function togglePlay() { async function togglePlay() {
if (togglingPlay) return; if (togglingPlay) return;
setTogglingPlay(true); setTogglingPlay(true);
// Optimistic flip so button responds instantly
const wasRunning = status?.state === "STARTED"; const wasRunning = status?.state === "STARTED";
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev); setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
try { try {
@@ -60,7 +60,7 @@ export default function DownloadQueue() {
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
poll(); // resync on error poll();
} finally { } finally {
setTogglingPlay(false); setTogglingPlay(false);
} }
@@ -69,7 +69,6 @@ export default function DownloadQueue() {
async function clear() { async function clear() {
if (clearing) return; if (clearing) return;
setClearing(true); setClearing(true);
// Optimistic clear
setStatus((prev) => prev ? { ...prev, queue: [] } : prev); setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
setActiveDownloads([]); setActiveDownloads([]);
try { try {
@@ -77,7 +76,7 @@ export default function DownloadQueue() {
applyStatus(d.clearDownloader.downloadStatus); applyStatus(d.clearDownloader.downloadStatus);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
poll(); // resync on error poll();
} finally { } finally {
setClearing(false); setClearing(false);
} }
@@ -86,13 +85,11 @@ export default function DownloadQueue() {
async function dequeue(chapterId: number) { async function dequeue(chapterId: number) {
if (dequeueing.has(chapterId)) return; if (dequeueing.has(chapterId)) return;
setDequeueing((prev) => new Set(prev).add(chapterId)); setDequeueing((prev) => new Set(prev).add(chapterId));
// Optimistic remove
setStatus((prev) => setStatus((prev) =>
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
); );
try { try {
await gql(DEQUEUE_DOWNLOAD, { chapterId }); await gql(DEQUEUE_DOWNLOAD, { chapterId });
// Sync authoritative state after dequeue
poll(); poll();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -118,7 +115,6 @@ 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}>
{/* Play / Pause toggle */}
<button <button
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()} className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={togglePlay} onClick={togglePlay}
@@ -134,7 +130,6 @@ export default function DownloadQueue() {
)} )}
</button> </button>
{/* Clear queue */}
<button <button
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()} className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
onClick={clear} onClick={clear}
@@ -169,10 +164,10 @@ export default function DownloadQueue() {
) : ( ) : (
<div className={s.list}> <div className={s.list}>
{queue.map((item, i) => { {queue.map((item, i) => {
const isActive = i === 0 && isRunning; const isActive = i === 0 && isRunning;
const pages = item.chapter.pageCount ?? 0; const pages = item.chapter.pageCount ?? 0;
const done = pagesDownloaded(item.progress, pages); const done = pagesDownloaded(item.progress, pages);
const manga = item.chapter.manga; const manga = item.chapter.manga;
const isRemoving = dequeueing.has(item.chapter.id); const isRemoving = dequeueing.has(item.chapter.id);
return ( return (
@@ -193,17 +188,13 @@ export default function DownloadQueue() {
)} )}
<div className={s.info}> <div className={s.info}>
{manga?.title && ( {manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
<span className={s.mangaTitle}>{manga.title}</span>
)}
<span className={s.chapterName}>{item.chapter.name}</span> <span className={s.chapterName}>{item.chapter.name}</span>
{pages > 0 && ( {pages > 0 && (
<span className={s.pagesLabel}> <span className={s.pagesLabel}>
{isActive ? `${done} / ${pages} pages` : `${pages} pages`} {isActive ? `${done} / ${pages} pages` : `${pages} pages`}
</span> </span>
)} )}
{isActive && ( {isActive && (
<div className={s.progressWrap}> <div className={s.progressWrap}>
<div <div
+85
View File
@@ -0,0 +1,85 @@
.toaster {
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--sp-2);
pointer-events: none;
max-width: 320px;
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-base);
background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
pointer-events: all;
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
min-width: 220px;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
/* Kind variants */
.toast_success { border-color: var(--accent-dim); }
.toast_success .toastIcon { color: var(--accent-fg); }
.toast_error { border-color: var(--color-error); }
.toast_error .toastIcon { color: var(--color-error); }
.toast_download .toastIcon { color: var(--accent-fg); }
.toast_info .toastIcon { color: var(--text-muted); }
.toastIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--text-faint);
}
.toastBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.toastTitle {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
line-height: 1.3;
}
.toastSub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toastClose {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
import { useStore } from "../../store";
import s from "./Toaster.module.css";
export type ToastKind = "success" | "error" | "info" | "download";
export interface Toast {
id: string;
kind: ToastKind;
title: string;
body?: string;
duration?: number; // ms, 0 = persistent
}
// ── icons per kind ──────────────────────────────────────────────────────────
function ToastIcon({ kind }: { kind: ToastKind }) {
const size = 15;
const w = "light" as const;
if (kind === "success") return <CheckCircle size={size} weight={w} />;
if (kind === "error") return <WarningCircle size={size} weight={w} />;
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
return <Info size={size} weight={w} />;
}
// ── individual toast ─────────────────────────────────────────────────────────
function ToastItem({ toast }: { toast: Toast }) {
const dismissToast = useStore((s) => s.dismissToast);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const duration = toast.duration ?? 3500;
useEffect(() => {
if (duration === 0) return;
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [toast.id, duration]);
return (
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
<div className={s.toastBody}>
<p className={s.toastTitle}>{toast.title}</p>
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
</div>
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
<X size={12} weight="light" />
</button>
</div>
);
}
// ── toaster container ────────────────────────────────────────────────────────
export default function Toaster() {
const toasts = useStore((s) => s.toasts);
if (!toasts.length) return null;
return createPortal(
<div className={s.toaster} aria-live="polite">
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
</div>,
document.body
);
}
+38
View File
@@ -38,6 +38,44 @@
} }
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); } .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); } .list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.group { margin-bottom: var(--sp-5); } .group { margin-bottom: var(--sp-5); }
+53 -6
View File
@@ -28,10 +28,18 @@ function dayLabel(ts: number): string {
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
} }
// ── Session grouping ────────────────────────────────────────────────────────── // Estimate reading time: ~8 seconds per page, counted from chapter entries
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed // Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
// into one session card showing the chapter range read. 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 const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
export interface ReadingSession { export interface ReadingSession {
@@ -97,7 +105,8 @@ export default function History() {
const history = useStore((s) => s.history); const history = useStore((s) => s.history);
const clearHistory = useStore((s) => s.clearHistory); const clearHistory = useStore((s) => s.clearHistory);
const setActiveManga = useStore((s) => s.setActiveManga); 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 [search, setSearch] = useState("");
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -111,9 +120,28 @@ export default function History() {
const sessions = useMemo(() => buildSessions(filtered), [filtered]); const sessions = useMemo(() => buildSessions(filtered), [filtered]);
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]); 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) { function resumeReading(session: ReadingSession) {
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any); // If the chapter list is available in store (user already visited this manga),
setNavPage("library"); // 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 ( return (
@@ -137,6 +165,25 @@ export default function History() {
</div> </div>
</div> </div>
{stats && (
<div className={s.statsBar}>
<span className={s.statItem}>
<span className={s.statVal}>{stats.uniqueChapters}</span>
<span className={s.statLabel}>chapters read</span>
</span>
<span className={s.statDivider} />
<span className={s.statItem}>
<span className={s.statVal}>{stats.uniqueManga}</span>
<span className={s.statLabel}>series</span>
</span>
<span className={s.statDivider} />
<span className={s.statItem}>
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
<span className={s.statLabel}>est. read time</span>
</span>
</div>
)}
{history.length === 0 ? ( {history.length === 0 ? (
<div className={s.empty}> <div className={s.empty}>
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} /> <ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
+56 -22
View File
@@ -77,53 +77,86 @@ function DownloadModal({
remaining, remaining,
onClose, onClose,
}: { }: {
chapter: { id: number; name: string }; chapter: { id: number; name: string; isDownloaded?: boolean };
remaining: { id: number }[]; remaining: { id: number; isDownloaded?: boolean }[];
onClose: () => void; onClose: () => void;
}) { }) {
const addToast = useStore((s) => s.addToast);
const [nextN, setNextN] = useState(5); const [nextN, setNextN] = useState(5);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const run = async (fn: () => Promise<unknown>) => { // Only offer chapters that aren't already downloaded
const queueable = remaining.filter((c) => !c.isDownloaded);
const run = async (fn: () => Promise<unknown>, toastBody: string) => {
setBusy(true); 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); setBusy(false);
onClose(); onClose();
}; };
const thisAlreadyDl = !!chapter.isDownloaded;
return ( return (
<div className={s.dlBackdrop} onClick={onClose}> <div className={s.dlBackdrop} onClick={onClose}>
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}> <div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
<p className={s.dlTitle}>Download</p> <p className={s.dlTitle}>Download</p>
<button className={s.dlOption} disabled={busy} <button
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}> className={s.dlOption}
disabled={busy || thisAlreadyDl}
onClick={() => run(
() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }),
thisAlreadyDl ? "" : chapter.name,
)}
>
This chapter This chapter
<span className={s.dlSub}>{chapter.name}</span> <span className={s.dlSub}>
{thisAlreadyDl ? "Already downloaded" : chapter.name}
</span>
</button> </button>
<div className={s.dlRow}> <div className={s.dlRow}>
<button className={s.dlOption} disabled={busy || !remaining.length} <button
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { className={s.dlOption}
chapterIds: remaining.slice(0, nextN).map((c) => c.id), disabled={busy || queueable.length === 0}
}))}> onClick={() => run(
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
chapterIds: queueable.slice(0, nextN).map((c) => c.id),
}),
`${Math.min(nextN, queueable.length)} chapters queued`,
)}
>
Next chapters Next chapters
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span> <span className={s.dlSub}>{Math.min(nextN, queueable.length)} not yet downloaded</span>
</button> </button>
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}> <div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
<button className={s.dlStepBtn} <button
className={s.dlStepBtn}
onClick={() => setNextN((n) => Math.max(1, n - 1))} onClick={() => setNextN((n) => Math.max(1, n - 1))}
disabled={nextN <= 1}></button> disabled={nextN <= 1}
></button>
<span className={s.dlStepVal}>{nextN}</span> <span className={s.dlStepVal}>{nextN}</span>
<button className={s.dlStepBtn} <button
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))} className={s.dlStepBtn}
disabled={nextN >= remaining.length}>+</button> onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))}
disabled={nextN >= queueable.length}
>+</button>
</div> </div>
</div> </div>
<button className={s.dlOption} disabled={busy || !remaining.length} <button
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { className={s.dlOption}
chapterIds: remaining.map((c) => c.id), disabled={busy || queueable.length === 0}
}))}> onClick={() => run(
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map((c) => c.id) }),
`${queueable.length} chapter${queueable.length !== 1 ? "s" : ""} queued`,
)}
>
All remaining All remaining
<span className={s.dlSub}>{remaining.length} chapters</span> <span className={s.dlSub}>{queueable.length} not yet downloaded</span>
</button> </button>
</div> </div>
</div> </div>
@@ -909,6 +942,7 @@ export default function Reader() {
style={cssVars} style={cssVars}
tabIndex={-1} tabIndex={-1}
onClick={handleTap} onClick={handleTap}
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === " " && style === "longstrip") { if (e.key === " " && style === "longstrip") {
e.preventDefault(); e.preventDefault();
+82 -2
View File
@@ -98,6 +98,16 @@
letter-spacing: var(--tracking-wide); 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 { .sourceLabel {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-2xs); font-size: var(--text-2xs);
@@ -111,11 +121,52 @@
color: var(--text-muted); color: var(--text-muted);
line-height: var(--leading-base); line-height: var(--leading-base);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 8; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; 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 ── */ /* ── Progress ── */
.progressSection { .progressSection {
display: flex; display: flex;
@@ -230,10 +281,39 @@
color: var(--text-faint); color: var(--text-faint);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
margin-top: auto;
padding-top: var(--sp-2); 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 ── */ /* ── Chapter list ── */
.listWrap { .listWrap {
flex: 1; flex: 1;
+226 -81
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { import {
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
ArrowSquareOut, BookOpen, CircleNotch, Play, ArrowSquareOut, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise, SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple, List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
@@ -17,6 +17,8 @@ import MigrateModal from "./MigrateModal";
import type { Manga, Chapter } from "../../lib/types"; import type { Manga, Chapter } from "../../lib/types";
import s from "./SeriesDetail.module.css"; import s from "./SeriesDetail.module.css";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(ts: string | null | undefined): string { function formatDate(ts: string | null | undefined): string {
if (!ts) return ""; if (!ts) return "";
const n = Number(ts); const n = Number(ts);
@@ -33,7 +35,8 @@ interface CtxState {
const CHAPTERS_PER_PAGE = 25; const CHAPTERS_PER_PAGE = 25;
// ── Download dropdown with range picker ────────────────────────────────────── // ── Download dropdown ─────────────────────────────────────────────────────────
interface DownloadDropdownProps { interface DownloadDropdownProps {
sortedChapters: Chapter[]; sortedChapters: Chapter[];
continueChapter: { chapter: Chapter; type: string } | null; continueChapter: { chapter: Chapter; type: string } | null;
@@ -91,12 +94,9 @@ function DownloadDropdown({
return ( return (
<div className={s.dlDropdown} ref={ref}> <div className={s.dlDropdown} ref={ref}>
{/* ── Next N from current ── */}
{continueChapter && continueIdx >= 0 && ( {continueChapter && continueIdx >= 0 && (
<> <>
<p className={s.dlSectionLabel}> <p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
From Ch.{continueChapter.chapter.chapterNumber}
</p>
<div className={s.dlNextRow}> <div className={s.dlNextRow}>
{[5, 10, 25].map((n) => { {[5, 10, 25].map((n) => {
const avail = sortedChapters const avail = sortedChapters
@@ -119,7 +119,6 @@ function DownloadDropdown({
</> </>
)} )}
{/* ── Custom range ── */}
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}> <button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
<span>Custom range</span> <span>Custom range</span>
<span className={s.dlItemSub}>Enter chapter numbers</span> <span className={s.dlItemSub}>Enter chapter numbers</span>
@@ -153,14 +152,11 @@ function DownloadDropdown({
<div className={s.dlDivider} /> <div className={s.dlDivider} />
{/* ── Standard options ── */} <button className={s.dlItem} onClick={() => onEnqueue(unreadNotDl.map((c) => c.id))}>
<button className={s.dlItem}
onClick={() => onEnqueue(unreadNotDl.map((c) => c.id))}>
<span>Unread chapters</span> <span>Unread chapters</span>
<span className={s.dlItemSub}>{unreadNotDl.length} remaining</span> <span className={s.dlItemSub}>{unreadNotDl.length} remaining</span>
</button> </button>
<button className={s.dlItem} <button className={s.dlItem} onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
<span>Download all</span> <span>Download all</span>
<span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span> <span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span>
</button> </button>
@@ -182,18 +178,20 @@ function DownloadDropdown({
); );
} }
// ── Folder picker (icon button for list header) ─────────────────────────────── // ── Folder picker ─────────────────────────────────────────────────────────────
function FolderPicker({ mangaId }: { mangaId: number }) { 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<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const folders = useStore((st) => st.settings.folders); const folders = useStore((st) => st.settings.folders);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder); const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
const addFolder = useStore((st) => st.addFolder); 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; const hasAssigned = assigned.length > 0;
useEffect(() => { useEffect(() => {
@@ -283,31 +281,39 @@ function FolderPicker({ mangaId }: { mangaId: number }) {
} }
// ── Main component ──────────────────────────────────────────────────────────── // ── 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<Manga | null>(activeManga); export default function SeriesDetail() {
const [chapters, setChapters] = useState<Chapter[]>([]); const activeManga = useStore((state) => state.activeManga);
const [loadingManga, setLoadingManga] = useState(true); 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<Manga | null>(activeManga);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [loadingManga, setLoadingManga] = useState(false);
const [loadingChapters, setLoadingChapters] = useState(true); const [loadingChapters, setLoadingChapters] = useState(true);
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set()); const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
const [dlOpen, setDlOpen] = useState(false); const [dlOpen, setDlOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [migrateOpen, setMigrateOpen] = useState(false); const [migrateOpen, setMigrateOpen] = useState(false);
const [togglingLibrary, setTogglingLibrary] = useState(false); const [togglingLibrary, setTogglingLibrary] = useState(false);
const [chapterPage, setChapterPage] = useState(1); const [chapterPage, setChapterPage] = useState(1);
const [ctx, setCtx] = useState<CtxState | null>(null); const [ctx, setCtx] = useState<CtxState | null>(null);
const [jumpOpen, setJumpOpen] = useState(false); const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState(""); const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
const [genresExpanded, setGenresExpanded] = useState(false);
const sortDir = settings.chapterSortDir; const sortDir = settings.chapterSortDir;
// Load extended manga details
useEffect(() => { useEffect(() => {
if (!activeManga) return; if (!activeManga) return;
setLoadingManga(true); setLoadingManga(true);
@@ -326,6 +332,7 @@ export default function SeriesDetail() {
}); });
}, []); }, []);
// Load chapters: show cache immediately, then silently refresh from source
useEffect(() => { useEffect(() => {
if (!activeManga) return; if (!activeManga) return;
setLoadingChapters(true); setLoadingChapters(true);
@@ -333,34 +340,40 @@ export default function SeriesDetail() {
setChapterPage(1); setChapterPage(1);
loadChapters(activeManga.id) 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) .catch(console.error)
.finally(() => setLoadingChapters(false)); .finally(() => setLoadingChapters(false));
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => loadChapters(activeManga.id))
.catch(console.error);
}, [activeManga?.id]); }, [activeManga?.id]);
// ── Derived state ──────────────────────────────────────────────────────────
const sortedChapters = useMemo(() => const sortedChapters = useMemo(() =>
sortDir === "desc" ? [...chapters].reverse() : [...chapters], sortDir === "desc" ? [...chapters].reverse() : [...chapters],
[chapters, sortDir] [chapters, sortDir]
); );
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE); const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
const pageChapters = sortedChapters.slice( const pageChapters = sortedChapters.slice(
(chapterPage - 1) * CHAPTERS_PER_PAGE, (chapterPage - 1) * CHAPTERS_PER_PAGE,
chapterPage * CHAPTERS_PER_PAGE chapterPage * CHAPTERS_PER_PAGE
); );
const readCount = chapters.filter((c) => c.isRead).length;
const readCount = chapters.filter((c) => c.isRead).length; const totalCount = chapters.length;
const totalCount = chapters.length; const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length; const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const continueChapter = useMemo(() => { const continueChapter = useMemo(() => {
if (!chapters.length) return null; if (!chapters.length) return null;
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); 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); const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const }; if (inProgress) return { chapter: inProgress, type: "continue" as const };
const firstUnread = asc.find((c) => !c.isRead); const firstUnread = asc.find((c) => !c.isRead);
@@ -368,6 +381,8 @@ export default function SeriesDetail() {
return { chapter: asc[0], type: "reread" as const }; return { chapter: asc[0], type: "reread" as const };
}, [chapters]); }, [chapters]);
// ── Actions ────────────────────────────────────────────────────────────────
async function toggleLibrary() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
setTogglingLibrary(true); setTogglingLibrary(true);
@@ -381,23 +396,42 @@ export default function SeriesDetail() {
e.stopPropagation(); e.stopPropagation();
setEnqueueing((prev) => new Set(prev).add(chapter.id)); setEnqueueing((prev) => new Set(prev).add(chapter.id));
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error); 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; }); setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
if (activeManga) loadChapters(activeManga.id); 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) { async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c)); setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
} }
async function markAllAboveRead(indexInSorted: number) { async function markBulk(ids: number[], isRead: boolean) {
const targets = sortedChapters.slice(0, indexInSorted + 1);
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
if (!ids.length) return; if (!ids.length) return;
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error); await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c)); 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) { async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error); await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c)); setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
@@ -412,37 +446,67 @@ export default function SeriesDetail() {
setDeletingAll(false); setDeletingAll(false);
} }
async function enqueueMultiple(chapterIds: number[]) { async function refreshChapters() {
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); if (!activeManga || refreshing) return;
if (activeManga) loadChapters(activeManga.id); 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) { function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
e.preventDefault(); e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted }); setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted });
} }
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] { 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 [ return [
{ {
label: ch.isRead ? "Mark as unread" : "Mark as read", label: ch.isRead ? "Mark as unread" : "Mark as read",
icon: ch.isRead icon: ch.isRead ? <Circle size={13} weight="light" /> : <CheckCircle size={13} weight="light" />,
? <Circle size={13} weight="light" />
: <CheckCircle size={13} weight="light" />,
onClick: () => markRead(ch.id, !ch.isRead), onClick: () => markRead(ch.id, !ch.isRead),
}, },
{ separator: true },
{ {
label: "Mark all above as read", label: "Mark above as read",
icon: <CheckCircle size={13} weight="duotone" />, icon: <CheckCircle size={13} weight="duotone" />,
onClick: () => markAllAboveRead(indexInSorted), onClick: () => markAllAboveRead(indexInSorted),
disabled: indexInSorted === 0, disabled: indexInSorted === 0 || unreadAbove === 0,
},
{
label: "Mark above as unread",
icon: <Circle size={13} weight="duotone" />,
onClick: () => markAllAboveUnread(indexInSorted),
disabled: indexInSorted === 0 || readAbove === 0,
},
{ separator: true },
{
label: "Mark below as read",
icon: <CheckCircle size={13} weight="duotone" />,
onClick: () => markAllBelowRead(indexInSorted),
disabled: indexInSorted === lastIdx || unreadBelow === 0,
},
{
label: "Mark below as unread",
icon: <Circle size={13} weight="duotone" />,
onClick: () => markAllBelowUnread(indexInSorted),
disabled: indexInSorted === lastIdx || readBelow === 0,
}, },
{ separator: true }, { separator: true },
{ {
label: ch.isDownloaded ? "Delete download" : "Download", label: ch.isDownloaded ? "Delete download" : "Download",
icon: ch.isDownloaded icon: ch.isDownloaded ? <Trash size={13} weight="light" /> : <Download size={13} weight="light" />,
? <Trash size={13} weight="light" />
: <Download size={13} weight="light" />,
onClick: () => ch.isDownloaded onClick: () => ch.isDownloaded
? deleteDownloaded(ch.id) ? deleteDownloaded(ch.id)
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error), : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
@@ -474,14 +538,19 @@ export default function SeriesDetail() {
]; ];
} }
// ── Early exit ─────────────────────────────────────────────────────────────
if (!activeManga) return null; if (!activeManga) return null;
const statusLabel = manga?.status const statusLabel = manga?.status
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
: null; : null;
// ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<div className={s.root} onContextMenu={(e) => e.preventDefault()}> <div className={s.root} onContextMenu={(e) => e.preventDefault()}>
{/* ── Sidebar ── */} {/* ── Sidebar ── */}
<div className={s.sidebar}> <div className={s.sidebar}>
<button className={s.back} onClick={() => setActiveManga(null)}> <button className={s.back} onClick={() => setActiveManga(null)}>
@@ -512,22 +581,54 @@ export default function SeriesDetail() {
)} )}
{statusLabel && ( {statusLabel && (
<span className={[s.statusBadge, manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded].join(" ").trim()}> <span className={[
s.statusBadge,
manga?.status === "ONGOING" ? s.statusOngoing : s.statusEnded,
].join(" ").trim()}>
{statusLabel} {statusLabel}
</span> </span>
)} )}
{manga?.genre && manga.genre.length > 0 && ( {manga?.genre && manga.genre.length > 0 && (
<div className={s.genres}> <div className={s.genres}>
{manga.genre.map((g) => <span key={g} className={s.genre}>{g}</span>)} {(genresExpanded ? manga.genre : manga.genre.slice(0, 5)).map((g) => (
<button
key={g}
className={[s.genre, s.genreClickable].join(" ")}
title={`Filter library by "${g}"`}
onClick={() => {
setLibraryTagFilter([g]);
setLibraryFilter("library");
setActiveManga(null);
}}
>
{g}
</button>
))}
{manga.genre.length > 5 && (
<button className={s.genreToggle} onClick={() => setGenresExpanded((p) => !p)}>
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
</button>
)}
</div> </div>
)} )}
{manga?.description && <p className={s.description}>{manga.description}</p>} {manga?.description && (
<div className={s.descriptionWrap}>
<p className={[s.description, descExpanded ? s.descriptionExpanded : ""].join(" ")}>
{manga.description}
</p>
{manga.description.length > 120 && (
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
{descExpanded ? "Less" : "More"}
</button>
)}
</div>
)}
</div> </div>
)} )}
{/* Progress bar */} {/* Progress */}
{totalCount > 0 && ( {totalCount > 0 && (
<div className={s.progressSection}> <div className={s.progressSection}>
<div className={s.progressHeader}> <div className={s.progressHeader}>
@@ -557,8 +658,6 @@ export default function SeriesDetail() {
)} )}
</div> </div>
{/* Folder picker moved to chapter list header */}
{continueChapter && ( {continueChapter && (
<button <button
className={s.readBtn} className={s.readBtn}
@@ -580,9 +679,34 @@ export default function SeriesDetail() {
<p className={s.chapterCount}> <p className={s.chapterCount}>
{totalCount} {totalCount === 1 ? "chapter" : "chapters"} {totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 && ` · ${readCount} read`}
</p> </p>
{/* ── Details (collapsible) ── */} {/* Quick mark-all */}
{totalCount > 0 && (
<div className={s.markAllRow}>
<button
className={s.markAllBtn}
onClick={() => markAllAboveRead(sortedChapters.length - 1)}
disabled={readCount === totalCount}
title="Mark all chapters as read"
>
<CheckCircle size={12} weight="light" />
All read
</button>
<button
className={s.markAllBtn}
onClick={() => markAllAboveUnread(sortedChapters.length - 1)}
disabled={readCount === 0}
title="Mark all chapters as unread"
>
<Circle size={12} weight="light" />
All unread
</button>
</div>
)}
{/* Details (collapsible) */}
{!loadingManga && manga?.source && ( {!loadingManga && manga?.source && (
<div className={s.detailsSection}> <div className={s.detailsSection}>
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}> <button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
@@ -607,8 +731,6 @@ export default function SeriesDetail() {
<ArrowsClockwise size={12} weight="light" /> <ArrowsClockwise size={12} weight="light" />
Switch source Switch source
</button> </button>
{/* Delete all downloads */}
{downloadedCount > 0 && ( {downloadedCount > 0 && (
<button <button
className={s.deleteAllBtn} className={s.deleteAllBtn}
@@ -657,7 +779,14 @@ export default function SeriesDetail() {
</div> </div>
<div className={s.listHeaderRight}> <div className={s.listHeaderRight}>
{/* Folder picker */} <button
className={s.viewToggleBtn}
onClick={refreshChapters}
disabled={refreshing}
title="Refresh chapters from source"
>
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
</button>
{activeManga && <FolderPicker mangaId={activeManga.id} />} {activeManga && <FolderPicker mangaId={activeManga.id} />}
{/* Jump to chapter */} {/* Jump to chapter */}
@@ -752,8 +881,7 @@ export default function SeriesDetail() {
)) ))
) )
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
sortedChapters.map((ch) => { sortedChapters.map((ch, idxInSorted) => {
const idxInSorted = sortedChapters.indexOf(ch);
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0; const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
return ( return (
<button <button
@@ -788,10 +916,14 @@ export default function SeriesDetail() {
pageChapters.map((ch) => { pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch); const idxInSorted = sortedChapters.indexOf(ch);
return ( return (
<button // div instead of button so the nested download/delete buttons are valid HTML
<div
key={ch.id} key={ch.id}
role="button"
tabIndex={0}
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()} className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
onClick={() => openReader(ch, sortedChapters)} onClick={() => openReader(ch, sortedChapters)}
onKeyDown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)} onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
> >
<div className={s.chLeft}> <div className={s.chLeft}>
@@ -809,19 +941,32 @@ export default function SeriesDetail() {
{ch.isBookmarked && ( {ch.isBookmarked && (
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} /> <BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
)} )}
{ch.isRead ? ( {/* Read indicator — always shown when read */}
{ch.isRead && (
<CheckCircle size={14} weight="light" className={s.readIcon} /> <CheckCircle size={14} weight="light" className={s.readIcon} />
) : ch.isDownloaded ? ( )}
<BookOpen size={14} weight="light" className={s.downloadedIcon} /> {/* Download / status indicator — independent of read state */}
{ch.isDownloaded ? (
<button
className={s.dlBtn}
onClick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}
title="Delete download"
>
<Trash size={13} weight="light" />
</button>
) : enqueueing.has(ch.id) ? ( ) : enqueueing.has(ch.id) ? (
<CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} /> <CircleNotch size={14} weight="light" className={[s.enqueuingIcon, "anim-spin"].join(" ")} />
) : ( ) : (
<button className={s.dlBtn} onClick={(e) => enqueue(ch, e)} title="Download"> <button
className={s.dlBtn}
onClick={(e) => enqueue(ch, e)}
title="Download"
>
<Download size={13} weight="light" /> <Download size={13} weight="light" />
</button> </button>
)} )}
</div> </div>
</button> </div>
); );
}) })
)} )}
+6 -1
View File
@@ -448,7 +448,8 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
.finally(() => setLoadingPopular(false)); .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 frecencyGenres = useMemo(() => {
const mangaScores = new Map<number, number>(); const mangaScores = new Map<number, number>();
const mangaReadAt = new Map<number, number>(); const mangaReadAt = new Map<number, number>();
@@ -469,6 +470,10 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
(m.genre ?? []).forEach((g) => (m.genre ?? []).forEach((g) =>
genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1))); 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()) return Array.from(genreWeights.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 3) // top 3 genres only .slice(0, 3) // top 3 genres only
+18
View File
@@ -20,6 +20,14 @@ export interface HistoryEntry {
readAt: number; readAt: number;
} }
export interface Toast {
id: string;
kind: "success" | "error" | "info" | "download";
title: string;
body?: string;
duration?: number;
}
export interface ActiveDownload { export interface ActiveDownload {
chapterId: number; chapterId: number;
mangaId: number; mangaId: number;
@@ -119,6 +127,9 @@ interface Store {
history: HistoryEntry[]; history: HistoryEntry[];
addHistory: (entry: HistoryEntry) => void; addHistory: (entry: HistoryEntry) => void;
clearHistory: () => void; clearHistory: () => void;
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
settings: Settings; settings: Settings;
updateSettings: (patch: Partial<Settings>) => void; updateSettings: (patch: Partial<Settings>) => void;
resetKeybinds: () => void; resetKeybinds: () => void;
@@ -177,6 +188,13 @@ export const useStore = create<Store>()(
return { history: [entry, ...deduped].slice(0, 300) }; return { history: [entry, ...deduped].slice(0, 300) };
}), }),
clearHistory: () => set({ history: [] }), 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, settings: DEFAULT_SETTINGS,
updateSettings: (patch) => updateSettings: (patch) =>
set((s) => ({ settings: { ...s.settings, ...patch } })), set((s) => ({ settings: { ...s.settings, ...patch } })),