mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Created Toaster & Augmented Explore Tab
This commit is contained in:
+57
-2
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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,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); }
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } })),
|
||||||
|
|||||||
Reference in New Issue
Block a user