mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -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 { listen } from "@tauri-apps/api/event";
|
||||
import { gql } from "./lib/client";
|
||||
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
|
||||
import "./styles/global.css";
|
||||
import { useStore } from "./store";
|
||||
import Layout from "./components/layout/Layout";
|
||||
import Reader from "./components/pages/Reader";
|
||||
import Settings from "./components/settings/Settings";
|
||||
import TitleBar from "./components/layout/TitleBar";
|
||||
import Toaster from "./components/layout/Toaster";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||
import s from "./App.module.css";
|
||||
|
||||
export default function App() {
|
||||
@@ -14,6 +18,41 @@ export default function App() {
|
||||
const settingsOpen = useStore((s) => s.settingsOpen);
|
||||
const settings = useStore((s) => s.settings);
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
|
||||
// Ref-based snapshot of the last known queue so we can diff across polls/events
|
||||
const prevQueueRef = useRef<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(() => {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
@@ -33,7 +72,22 @@ export default function App() {
|
||||
return () => { invoke("kill_server").catch(() => {}); };
|
||||
}, [settings.autoStartServer, settings.serverBinary]);
|
||||
|
||||
// Global Tauri download-progress listener — no polling, always current
|
||||
// Global download status poller — always running, regardless of which page is open.
|
||||
// This is the single source of truth for completion toasts.
|
||||
useEffect(() => {
|
||||
function poll() {
|
||||
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||
.then((d) => applyQueue(d.downloadStatus.queue))
|
||||
.catch(console.error);
|
||||
}
|
||||
poll(); // immediate first fetch
|
||||
const id = setInterval(poll, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Tauri real-time event — supplements the poller for instant UI badge updates.
|
||||
// The payload is a lighter shape (no chapter name/manga), so we only use it
|
||||
// for active download progress, not for completion detection.
|
||||
useEffect(() => {
|
||||
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
|
||||
const unsub = listen<DlPayload>("download-progress", (e) => {
|
||||
@@ -49,6 +103,7 @@ export default function App() {
|
||||
{activeChapter ? <Reader /> : <Layout />}
|
||||
</div>
|
||||
{settingsOpen && <Settings />}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,15 @@ import type { DownloadStatus } from "../../lib/types";
|
||||
import s from "./DownloadQueue.module.css";
|
||||
|
||||
export default function DownloadQueue() {
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<DownloadStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [togglingPlay, setTogglingPlay] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [dequeueing, setDequeueing] = useState<Set<number>>(new Set());
|
||||
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
|
||||
|
||||
// Apply status to local state + global store
|
||||
// Apply status to local state + global store.
|
||||
// Completion toasting is handled globally in App.tsx — no duplication here.
|
||||
const applyStatus = useCallback((ds: DownloadStatus) => {
|
||||
setStatus(ds);
|
||||
setActiveDownloads(
|
||||
@@ -47,7 +48,6 @@ export default function DownloadQueue() {
|
||||
async function togglePlay() {
|
||||
if (togglingPlay) return;
|
||||
setTogglingPlay(true);
|
||||
// Optimistic flip so button responds instantly
|
||||
const wasRunning = status?.state === "STARTED";
|
||||
setStatus((prev) => prev ? { ...prev, state: wasRunning ? "STOPPED" : "STARTED" } : prev);
|
||||
try {
|
||||
@@ -60,7 +60,7 @@ export default function DownloadQueue() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll(); // resync on error
|
||||
poll();
|
||||
} finally {
|
||||
setTogglingPlay(false);
|
||||
}
|
||||
@@ -69,7 +69,6 @@ export default function DownloadQueue() {
|
||||
async function clear() {
|
||||
if (clearing) return;
|
||||
setClearing(true);
|
||||
// Optimistic clear
|
||||
setStatus((prev) => prev ? { ...prev, queue: [] } : prev);
|
||||
setActiveDownloads([]);
|
||||
try {
|
||||
@@ -77,7 +76,7 @@ export default function DownloadQueue() {
|
||||
applyStatus(d.clearDownloader.downloadStatus);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
poll(); // resync on error
|
||||
poll();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
@@ -86,13 +85,11 @@ export default function DownloadQueue() {
|
||||
async function dequeue(chapterId: number) {
|
||||
if (dequeueing.has(chapterId)) return;
|
||||
setDequeueing((prev) => new Set(prev).add(chapterId));
|
||||
// Optimistic remove
|
||||
setStatus((prev) =>
|
||||
prev ? { ...prev, queue: prev.queue.filter((i) => i.chapter.id !== chapterId) } : prev
|
||||
);
|
||||
try {
|
||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
||||
// Sync authoritative state after dequeue
|
||||
poll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -118,7 +115,6 @@ export default function DownloadQueue() {
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Downloads</h1>
|
||||
<div className={s.headerActions}>
|
||||
{/* Play / Pause toggle */}
|
||||
<button
|
||||
className={[s.iconBtn, togglingPlay ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={togglePlay}
|
||||
@@ -134,7 +130,6 @@ export default function DownloadQueue() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Clear queue */}
|
||||
<button
|
||||
className={[s.iconBtn, clearing ? s.iconBtnLoading : ""].join(" ").trim()}
|
||||
onClick={clear}
|
||||
@@ -169,10 +164,10 @@ export default function DownloadQueue() {
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{queue.map((item, i) => {
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isActive = i === 0 && isRunning;
|
||||
const pages = item.chapter.pageCount ?? 0;
|
||||
const done = pagesDownloaded(item.progress, pages);
|
||||
const manga = item.chapter.manga;
|
||||
const isRemoving = dequeueing.has(item.chapter.id);
|
||||
|
||||
return (
|
||||
@@ -193,17 +188,13 @@ export default function DownloadQueue() {
|
||||
)}
|
||||
|
||||
<div className={s.info}>
|
||||
{manga?.title && (
|
||||
<span className={s.mangaTitle}>{manga.title}</span>
|
||||
)}
|
||||
{manga?.title && <span className={s.mangaTitle}>{manga.title}</span>}
|
||||
<span className={s.chapterName}>{item.chapter.name}</span>
|
||||
|
||||
{pages > 0 && (
|
||||
<span className={s.pagesLabel}>
|
||||
{isActive ? `${done} / ${pages} pages` : `${pages} pages`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<div className={s.progressWrap}>
|
||||
<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); }
|
||||
|
||||
.statsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statDivider {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.group { margin-bottom: var(--sp-5); }
|
||||
|
||||
@@ -28,10 +28,18 @@ function dayLabel(ts: number): string {
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
||||
// into one session card showing the chapter range read.
|
||||
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||
function formatReadTime(minutes: number): string {
|
||||
if (minutes < 1) return "< 1 min";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export interface ReadingSession {
|
||||
@@ -97,7 +105,8 @@ export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const setNavPage = useStore((s) => s.setNavPage);
|
||||
const openReader = useStore((s) => s.openReader);
|
||||
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -111,9 +120,28 @@ export default function History() {
|
||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
if (!history.length) return null;
|
||||
// Unique chapters read
|
||||
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||
// Unique manga read
|
||||
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||
}, [history]);
|
||||
|
||||
function resumeReading(session: ReadingSession) {
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
setNavPage("library");
|
||||
// If the chapter list is available in store (user already visited this manga),
|
||||
// open the reader directly for a snappier experience
|
||||
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||
if (chapterInList && activeChapterList.length > 0) {
|
||||
openReader(chapterInList, activeChapterList);
|
||||
} else {
|
||||
// Fall back to opening SeriesDetail — it will show the continue button
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,6 +165,25 @@ export default function History() {
|
||||
</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 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
|
||||
@@ -77,53 +77,86 @@ function DownloadModal({
|
||||
remaining,
|
||||
onClose,
|
||||
}: {
|
||||
chapter: { id: number; name: string };
|
||||
remaining: { id: number }[];
|
||||
chapter: { id: number; name: string; isDownloaded?: boolean };
|
||||
remaining: { id: number; isDownloaded?: boolean }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
const [nextN, setNextN] = useState(5);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<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);
|
||||
await fn().catch(console.error);
|
||||
try {
|
||||
await fn();
|
||||
addToast({ kind: "download", title: "Download queued", body: toastBody });
|
||||
} catch (e) {
|
||||
addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const thisAlreadyDl = !!chapter.isDownloaded;
|
||||
|
||||
return (
|
||||
<div className={s.dlBackdrop} onClick={onClose}>
|
||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
||||
<p className={s.dlTitle}>Download</p>
|
||||
<button className={s.dlOption} disabled={busy}
|
||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }))}>
|
||||
<button
|
||||
className={s.dlOption}
|
||||
disabled={busy || thisAlreadyDl}
|
||||
onClick={() => run(
|
||||
() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }),
|
||||
thisAlreadyDl ? "" : chapter.name,
|
||||
)}
|
||||
>
|
||||
This chapter
|
||||
<span className={s.dlSub}>{chapter.name}</span>
|
||||
<span className={s.dlSub}>
|
||||
{thisAlreadyDl ? "Already downloaded" : chapter.name}
|
||||
</span>
|
||||
</button>
|
||||
<div className={s.dlRow}>
|
||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||
chapterIds: remaining.slice(0, nextN).map((c) => c.id),
|
||||
}))}>
|
||||
<button
|
||||
className={s.dlOption}
|
||||
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
|
||||
<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>
|
||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
||||
<button className={s.dlStepBtn}
|
||||
<button
|
||||
className={s.dlStepBtn}
|
||||
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
||||
disabled={nextN <= 1}>−</button>
|
||||
disabled={nextN <= 1}
|
||||
>−</button>
|
||||
<span className={s.dlStepVal}>{nextN}</span>
|
||||
<button className={s.dlStepBtn}
|
||||
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
|
||||
disabled={nextN >= remaining.length}>+</button>
|
||||
<button
|
||||
className={s.dlStepBtn}
|
||||
onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))}
|
||||
disabled={nextN >= queueable.length}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||
chapterIds: remaining.map((c) => c.id),
|
||||
}))}>
|
||||
<button
|
||||
className={s.dlOption}
|
||||
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
|
||||
<span className={s.dlSub}>{remaining.length} chapters</span>
|
||||
<span className={s.dlSub}>{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -909,6 +942,7 @@ export default function Reader() {
|
||||
style={cssVars}
|
||||
tabIndex={-1}
|
||||
onClick={handleTap}
|
||||
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " && style === "longstrip") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -98,6 +98,16 @@
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.genreClickable {
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genreClickable:hover {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
@@ -111,11 +121,52 @@
|
||||
color: var(--text-muted);
|
||||
line-height: var(--leading-base);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 8;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.descriptionExpanded {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.descriptionWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.descToggle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.descToggle:hover { opacity: 1; }
|
||||
|
||||
.genreToggle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.genreToggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Progress ── */
|
||||
.progressSection {
|
||||
display: flex;
|
||||
@@ -230,10 +281,39 @@
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ── Sidebar mark-all quick actions ── */
|
||||
.markAllRow {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.markAllBtn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.markAllBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.markAllBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Chapter list ── */
|
||||
.listWrap {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import {
|
||||
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||
ArrowSquareOut, CircleNotch, Play,
|
||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
@@ -17,6 +17,8 @@ import MigrateModal from "./MigrateModal";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import s from "./SeriesDetail.module.css";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const n = Number(ts);
|
||||
@@ -33,7 +35,8 @@ interface CtxState {
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
|
||||
// ── Download dropdown with range picker ──────────────────────────────────────
|
||||
// ── Download dropdown ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DownloadDropdownProps {
|
||||
sortedChapters: Chapter[];
|
||||
continueChapter: { chapter: Chapter; type: string } | null;
|
||||
@@ -91,12 +94,9 @@ function DownloadDropdown({
|
||||
return (
|
||||
<div className={s.dlDropdown} ref={ref}>
|
||||
|
||||
{/* ── Next N from current ── */}
|
||||
{continueChapter && continueIdx >= 0 && (
|
||||
<>
|
||||
<p className={s.dlSectionLabel}>
|
||||
From Ch.{continueChapter.chapter.chapterNumber}
|
||||
</p>
|
||||
<p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||||
<div className={s.dlNextRow}>
|
||||
{[5, 10, 25].map((n) => {
|
||||
const avail = sortedChapters
|
||||
@@ -119,7 +119,6 @@ function DownloadDropdown({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Custom range ── */}
|
||||
<button className={s.dlItem} onClick={() => setShowRange((p) => !p)}>
|
||||
<span>Custom range…</span>
|
||||
<span className={s.dlItemSub}>Enter chapter numbers</span>
|
||||
@@ -153,14 +152,11 @@ function DownloadDropdown({
|
||||
|
||||
<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 className={s.dlItemSub}>{unreadNotDl.length} remaining</span>
|
||||
</button>
|
||||
<button className={s.dlItem}
|
||||
onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
|
||||
<button className={s.dlItem} onClick={() => onEnqueue(allNotDl.map((c) => c.id))}>
|
||||
<span>Download all</span>
|
||||
<span className={s.dlItemSub}>{allNotDl.length} not yet downloaded</span>
|
||||
</button>
|
||||
@@ -182,18 +178,20 @@ function DownloadDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Folder picker (icon button for list header) ───────────────────────────────
|
||||
// ── Folder picker ─────────────────────────────────────────────────────────────
|
||||
|
||||
function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
||||
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
|
||||
const hasAssigned = assigned.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -283,31 +281,39 @@ function FolderPicker({ mangaId }: { mangaId: number }) {
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
export default function SeriesDetail() {
|
||||
const activeManga = useStore((state) => state.activeManga);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const openReader = useStore((state) => state.openReader);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(activeManga);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingManga, setLoadingManga] = useState(true);
|
||||
export default function SeriesDetail() {
|
||||
const activeManga = useStore((state) => state.activeManga);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const openReader = useStore((state) => state.openReader);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
const addToast = useStore((state) => state.addToast);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(activeManga);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
const [loadingManga, setLoadingManga] = useState(false);
|
||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [migrateOpen, setMigrateOpen] = useState(false);
|
||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [migrateOpen, setMigrateOpen] = useState(false);
|
||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
||||
const [chapterPage, setChapterPage] = useState(1);
|
||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||
const [jumpOpen, setJumpOpen] = useState(false);
|
||||
const [jumpInput, setJumpInput] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [chapterPage, setChapterPage] = useState(1);
|
||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||
const [jumpOpen, setJumpOpen] = useState(false);
|
||||
const [jumpInput, setJumpInput] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
const [genresExpanded, setGenresExpanded] = useState(false);
|
||||
|
||||
const sortDir = settings.chapterSortDir;
|
||||
|
||||
// Load extended manga details
|
||||
useEffect(() => {
|
||||
if (!activeManga) return;
|
||||
setLoadingManga(true);
|
||||
@@ -326,6 +332,7 @@ export default function SeriesDetail() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load chapters: show cache immediately, then silently refresh from source
|
||||
useEffect(() => {
|
||||
if (!activeManga) return;
|
||||
setLoadingChapters(true);
|
||||
@@ -333,34 +340,40 @@ export default function SeriesDetail() {
|
||||
setChapterPage(1);
|
||||
|
||||
loadChapters(activeManga.id)
|
||||
.then((cached) =>
|
||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||
.then(() => loadChapters(activeManga.id))
|
||||
.then((fresh) => {
|
||||
// Suppress no-op: if count unchanged the state is already correct
|
||||
void (fresh.length === cached.length);
|
||||
})
|
||||
.catch(console.error)
|
||||
)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingChapters(false));
|
||||
|
||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||
.then(() => loadChapters(activeManga.id))
|
||||
.catch(console.error);
|
||||
}, [activeManga?.id]);
|
||||
|
||||
// ── Derived state ──────────────────────────────────────────────────────────
|
||||
|
||||
const sortedChapters = useMemo(() =>
|
||||
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
||||
[chapters, sortDir]
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
||||
const pageChapters = sortedChapters.slice(
|
||||
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
|
||||
const pageChapters = sortedChapters.slice(
|
||||
(chapterPage - 1) * CHAPTERS_PER_PAGE,
|
||||
chapterPage * CHAPTERS_PER_PAGE
|
||||
);
|
||||
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const totalCount = chapters.length;
|
||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||
const readCount = chapters.filter((c) => c.isRead).length;
|
||||
const totalCount = chapters.length;
|
||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||
|
||||
const continueChapter = useMemo(() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const anyRead = asc.some((c) => c.isRead);
|
||||
const anyRead = asc.some((c) => c.isRead);
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
@@ -368,6 +381,8 @@ export default function SeriesDetail() {
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
}, [chapters]);
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
setTogglingLibrary(true);
|
||||
@@ -381,23 +396,42 @@ export default function SeriesDetail() {
|
||||
e.stopPropagation();
|
||||
setEnqueueing((prev) => new Set(prev).add(chapter.id));
|
||||
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
|
||||
addToast({ kind: "download", title: "Download queued", body: chapter.name });
|
||||
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
|
||||
if (activeManga) loadChapters(activeManga.id);
|
||||
}
|
||||
|
||||
async function enqueueMultiple(chapterIds: number[]) {
|
||||
if (!chapterIds.length) return;
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||
addToast({
|
||||
kind: "download",
|
||||
title: "Download queued",
|
||||
body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`,
|
||||
});
|
||||
if (activeManga) loadChapters(activeManga.id);
|
||||
}
|
||||
|
||||
async function markRead(chapterId: number, isRead: boolean) {
|
||||
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c));
|
||||
}
|
||||
|
||||
async function markAllAboveRead(indexInSorted: number) {
|
||||
const targets = sortedChapters.slice(0, indexInSorted + 1);
|
||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
||||
async function markBulk(ids: number[], isRead: boolean) {
|
||||
if (!ids.length) return;
|
||||
await gql(MARK_CHAPTERS_READ, { ids, isRead: true }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead: true } : c));
|
||||
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead } : c));
|
||||
}
|
||||
|
||||
const markAllAboveRead = (i: number) =>
|
||||
markBulk(sortedChapters.slice(0, i + 1).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||
const markAllBelowRead = (i: number) =>
|
||||
markBulk(sortedChapters.slice(i).filter((c) => !c.isRead).map((c) => c.id), true);
|
||||
const markAllAboveUnread = (i: number) =>
|
||||
markBulk(sortedChapters.slice(0, i + 1).filter((c) => c.isRead).map((c) => c.id), false);
|
||||
const markAllBelowUnread = (i: number) =>
|
||||
markBulk(sortedChapters.slice(i).filter((c) => c.isRead).map((c) => c.id), false);
|
||||
|
||||
async function deleteDownloaded(chapterId: number) {
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
|
||||
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
|
||||
@@ -412,37 +446,67 @@ export default function SeriesDetail() {
|
||||
setDeletingAll(false);
|
||||
}
|
||||
|
||||
async function enqueueMultiple(chapterIds: number[]) {
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
|
||||
if (activeManga) loadChapters(activeManga.id);
|
||||
async function refreshChapters() {
|
||||
if (!activeManga || refreshing) return;
|
||||
setRefreshing(true);
|
||||
await gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||
.then(() => loadChapters(activeManga.id))
|
||||
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
|
||||
.catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) }))
|
||||
.finally(() => setRefreshing(false));
|
||||
}
|
||||
|
||||
// ── FIX: restored missing function declaration ─────────────────────────────
|
||||
function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) {
|
||||
e.preventDefault();
|
||||
setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted });
|
||||
}
|
||||
|
||||
function buildCtxItems(ch: Chapter, indexInSorted: number): ContextMenuEntry[] {
|
||||
const aboveItems = sortedChapters.slice(0, indexInSorted + 1);
|
||||
const belowItems = sortedChapters.slice(indexInSorted);
|
||||
const unreadAbove = aboveItems.filter((c) => !c.isRead).length;
|
||||
const unreadBelow = belowItems.filter((c) => !c.isRead).length;
|
||||
const readAbove = aboveItems.filter((c) => c.isRead).length;
|
||||
const readBelow = belowItems.filter((c) => c.isRead).length;
|
||||
const lastIdx = sortedChapters.length - 1;
|
||||
|
||||
return [
|
||||
{
|
||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||
icon: ch.isRead
|
||||
? <Circle size={13} weight="light" />
|
||||
: <CheckCircle size={13} weight="light" />,
|
||||
icon: ch.isRead ? <Circle size={13} weight="light" /> : <CheckCircle size={13} weight="light" />,
|
||||
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" />,
|
||||
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 },
|
||||
{
|
||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||
icon: ch.isDownloaded
|
||||
? <Trash size={13} weight="light" />
|
||||
: <Download size={13} weight="light" />,
|
||||
icon: ch.isDownloaded ? <Trash size={13} weight="light" /> : <Download size={13} weight="light" />,
|
||||
onClick: () => ch.isDownloaded
|
||||
? deleteDownloaded(ch.id)
|
||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||
@@ -474,14 +538,19 @@ export default function SeriesDetail() {
|
||||
];
|
||||
}
|
||||
|
||||
// ── Early exit ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (!activeManga) return null;
|
||||
|
||||
const statusLabel = manga?.status
|
||||
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
|
||||
: null;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<div className={s.sidebar}>
|
||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
||||
@@ -512,22 +581,54 @@ export default function SeriesDetail() {
|
||||
)}
|
||||
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{manga?.genre && manga.genre.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* Progress */}
|
||||
{totalCount > 0 && (
|
||||
<div className={s.progressSection}>
|
||||
<div className={s.progressHeader}>
|
||||
@@ -557,8 +658,6 @@ export default function SeriesDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder picker moved to chapter list header */}
|
||||
|
||||
{continueChapter && (
|
||||
<button
|
||||
className={s.readBtn}
|
||||
@@ -580,9 +679,34 @@ export default function SeriesDetail() {
|
||||
|
||||
<p className={s.chapterCount}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
</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 && (
|
||||
<div className={s.detailsSection}>
|
||||
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
||||
@@ -607,8 +731,6 @@ export default function SeriesDetail() {
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
Switch source
|
||||
</button>
|
||||
|
||||
{/* Delete all downloads */}
|
||||
{downloadedCount > 0 && (
|
||||
<button
|
||||
className={s.deleteAllBtn}
|
||||
@@ -657,7 +779,14 @@ export default function SeriesDetail() {
|
||||
</div>
|
||||
|
||||
<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} />}
|
||||
|
||||
{/* Jump to chapter */}
|
||||
@@ -752,8 +881,7 @@ export default function SeriesDetail() {
|
||||
))
|
||||
)
|
||||
) : viewMode === "grid" ? (
|
||||
sortedChapters.map((ch) => {
|
||||
const idxInSorted = sortedChapters.indexOf(ch);
|
||||
sortedChapters.map((ch, idxInSorted) => {
|
||||
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
|
||||
return (
|
||||
<button
|
||||
@@ -788,10 +916,14 @@ export default function SeriesDetail() {
|
||||
pageChapters.map((ch) => {
|
||||
const idxInSorted = sortedChapters.indexOf(ch);
|
||||
return (
|
||||
<button
|
||||
// div instead of button so the nested download/delete buttons are valid HTML
|
||||
<div
|
||||
key={ch.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
|
||||
onClick={() => openReader(ch, sortedChapters)}
|
||||
onKeyDown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
|
||||
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
|
||||
>
|
||||
<div className={s.chLeft}>
|
||||
@@ -809,19 +941,32 @@ export default function SeriesDetail() {
|
||||
{ch.isBookmarked && (
|
||||
<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} />
|
||||
) : 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) ? (
|
||||
<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" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -448,7 +448,8 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
||||
.finally(() => setLoadingPopular(false));
|
||||
}, []);
|
||||
|
||||
// Once library loaded AND sources ready, search each frecency genre across sources
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama", "Sci-fi", "Horror"];
|
||||
|
||||
const frecencyGenres = useMemo(() => {
|
||||
const mangaScores = new Map<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
@@ -469,6 +470,10 @@ function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
||||
(m.genre ?? []).forEach((g) =>
|
||||
genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||
}
|
||||
// If still empty (new user, no library), fall back to foundational genres
|
||||
if (genreWeights.size === 0) {
|
||||
return FOUNDATIONAL_GENRES.slice(0, 5);
|
||||
}
|
||||
return Array.from(genreWeights.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3) // top 3 genres only
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface HistoryEntry {
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
kind: "success" | "error" | "info" | "download";
|
||||
title: string;
|
||||
body?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ActiveDownload {
|
||||
chapterId: number;
|
||||
mangaId: number;
|
||||
@@ -119,6 +127,9 @@ interface Store {
|
||||
history: HistoryEntry[];
|
||||
addHistory: (entry: HistoryEntry) => void;
|
||||
clearHistory: () => void;
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
settings: Settings;
|
||||
updateSettings: (patch: Partial<Settings>) => void;
|
||||
resetKeybinds: () => void;
|
||||
@@ -177,6 +188,13 @@ export const useStore = create<Store>()(
|
||||
return { history: [entry, ...deduped].slice(0, 300) };
|
||||
}),
|
||||
clearHistory: () => set({ history: [] }),
|
||||
toasts: [],
|
||||
addToast: (toast) =>
|
||||
set((s) => ({
|
||||
toasts: [...s.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5),
|
||||
})),
|
||||
dismissToast: (id) =>
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
settings: DEFAULT_SETTINGS,
|
||||
updateSettings: (patch) =>
|
||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
||||
|
||||
Reference in New Issue
Block a user