[V1] Created Toaster & Augmented Explore Tab

This commit is contained in:
Youwes09
2026-02-23 11:36:52 -06:00
parent cd2d79f80c
commit 7b61f85833
11 changed files with 704 additions and 137 deletions
+14 -23
View File
@@ -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
+85
View File
@@ -0,0 +1,85 @@
.toaster {
position: fixed;
bottom: var(--sp-5);
right: var(--sp-5);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--sp-2);
pointer-events: none;
max-width: 320px;
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-base);
background: var(--bg-raised);
box-shadow: 0 4px 24px rgba(0,0,0,0.45), 0 0 0 1px rgba(0,0,0,0.08);
pointer-events: all;
animation: toastIn 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
min-width: 220px;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(24px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
/* Kind variants */
.toast_success { border-color: var(--accent-dim); }
.toast_success .toastIcon { color: var(--accent-fg); }
.toast_error { border-color: var(--color-error); }
.toast_error .toastIcon { color: var(--color-error); }
.toast_download .toastIcon { color: var(--accent-fg); }
.toast_info .toastIcon { color: var(--text-muted); }
.toastIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--text-faint);
}
.toastBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.toastTitle {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
line-height: 1.3;
}
.toastSub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toastClose {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
transition: color var(--t-base), background var(--t-base);
}
.toastClose:hover { color: var(--text-muted); background: var(--bg-overlay); }
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { CheckCircle, X, WarningCircle, Info, DownloadSimple } from "@phosphor-icons/react";
import { useStore } from "../../store";
import s from "./Toaster.module.css";
export type ToastKind = "success" | "error" | "info" | "download";
export interface Toast {
id: string;
kind: ToastKind;
title: string;
body?: string;
duration?: number; // ms, 0 = persistent
}
// ── icons per kind ──────────────────────────────────────────────────────────
function ToastIcon({ kind }: { kind: ToastKind }) {
const size = 15;
const w = "light" as const;
if (kind === "success") return <CheckCircle size={size} weight={w} />;
if (kind === "error") return <WarningCircle size={size} weight={w} />;
if (kind === "download") return <DownloadSimple size={size} weight={w} />;
return <Info size={size} weight={w} />;
}
// ── individual toast ─────────────────────────────────────────────────────────
function ToastItem({ toast }: { toast: Toast }) {
const dismissToast = useStore((s) => s.dismissToast);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const duration = toast.duration ?? 3500;
useEffect(() => {
if (duration === 0) return;
timerRef.current = setTimeout(() => dismissToast(toast.id), duration);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [toast.id, duration]);
return (
<div className={[s.toast, s[`toast_${toast.kind}`]].join(" ")} role="alert">
<span className={s.toastIcon}><ToastIcon kind={toast.kind} /></span>
<div className={s.toastBody}>
<p className={s.toastTitle}>{toast.title}</p>
{toast.body && <p className={s.toastSub}>{toast.body}</p>}
</div>
<button className={s.toastClose} onClick={() => dismissToast(toast.id)} title="Dismiss">
<X size={12} weight="light" />
</button>
</div>
);
}
// ── toaster container ────────────────────────────────────────────────────────
export default function Toaster() {
const toasts = useStore((s) => s.toasts);
if (!toasts.length) return null;
return createPortal(
<div className={s.toaster} aria-live="polite">
{toasts.map((t) => <ToastItem key={t.id} toast={t} />)}
</div>,
document.body
);
}
+38
View File
@@ -38,6 +38,44 @@
}
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
.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); }
+53 -6
View File
@@ -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} />
+56 -22
View File
@@ -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();
+82 -2
View File
@@ -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;
+226 -81
View File
@@ -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>
);
})
)}
+6 -1
View File
@@ -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