[BETA] Initial Commit (Nix Support Only)

This commit is contained in:
Youwes09
2026-02-20 23:34:10 -06:00
commit 09554c68df
113 changed files with 14400 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
.root {
display: flex; flex-direction: column; height: 100%;
overflow: hidden; animation: fadeIn 0.14s ease both;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
.searchWrap { position: relative; display: flex; align-items: center; }
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.clearBtn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
}
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.group { margin-bottom: var(--sp-5); }
.groupLabel {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
}
.row {
display: flex; align-items: center; gap: var(--sp-3);
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .playIcon { opacity: 1; }
.thumb {
width: 36px; height: 52px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
border: 1px solid var(--border-dim);
}
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.mangaTitle {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chapterName {
font-size: var(--text-sm); color: var(--text-muted);
display: flex; align-items: center; gap: var(--sp-2);
}
.pageBadge {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.time {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
flex-shrink: 0; white-space: nowrap;
}
.playIcon {
color: var(--text-faint); flex-shrink: 0;
opacity: 0; transition: opacity var(--t-base);
}
.empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: var(--sp-2);
}
.emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
+123
View File
@@ -0,0 +1,123 @@
import { useMemo, useState } from "react";
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play } from "@phosphor-icons/react";
import { thumbUrl } from "../../lib/client";
import { useStore, type HistoryEntry } from "../../store";
import s from "./History.module.css";
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// Group entries by day
function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] {
const groups = new Map<string, HistoryEntry[]>();
for (const e of entries) {
const d = new Date(e.readAt);
const now = new Date();
let label: string;
if (d.toDateString() === now.toDateString()) label = "Today";
else {
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) label = "Yesterday";
else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(e);
}
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
}
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 [search, setSearch] = useState("");
const filtered = useMemo(() =>
search.trim()
? history.filter((e) =>
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
e.chapterName.toLowerCase().includes(search.toLowerCase()))
: history,
[history, search]
);
const groups = useMemo(() => groupByDay(filtered), [filtered]);
function resumeReading(entry: HistoryEntry) {
// Navigate to manga detail — user can continue from there
setActiveManga({
id: entry.mangaId,
title: entry.mangaTitle,
thumbnailUrl: entry.thumbnailUrl,
} as any);
setNavPage("library");
}
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>History</h1>
<div className={s.headerRight}>
<div className={s.searchWrap}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input className={s.search} placeholder="Search history…"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{history.length > 0 && (
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
<Trash size={14} weight="light" />
</button>
)}
</div>
</div>
{history.length === 0 ? (
<div className={s.empty}>
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>No reading history yet.</p>
<p className={s.emptyHint}>Chapters you read will appear here.</p>
</div>
) : filtered.length === 0 ? (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{search}"</p>
</div>
) : (
<div className={s.list}>
{groups.map(({ label, items }) => (
<div key={label} className={s.group}>
<p className={s.groupLabel}>{label}</p>
{items.map((entry) => (
<button key={`${entry.chapterId}-${entry.readAt}`}
className={s.row} onClick={() => resumeReading(entry)}>
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
className={s.thumb} />
<div className={s.info}>
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
<span className={s.chapterName}>{entry.chapterName}
{entry.pageNumber > 1 && (
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
)}
</span>
</div>
<span className={s.time}>{timeAgo(entry.readAt)}</span>
<Play size={12} weight="fill" className={s.playIcon} />
</button>
))}
</div>
))}
</div>
)}
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
.root {
padding: var(--sp-6);
overflow-y: auto;
height: 100%;
animation: fadeIn 0.14s ease both;
/* GPU acceleration for smooth scrolling */
will-change: scroll-position;
-webkit-overflow-scrolling: touch;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
gap: var(--sp-4);
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: var(--sp-4);
flex-wrap: wrap;
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-normal);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
flex-shrink: 0;
}
/* Filter tabs */
.tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 2px;
}
.tab {
display: flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 4px 10px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.tab:hover { color: var(--text-muted); }
.tabActive {
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
}
.tabActive:hover { color: var(--accent-fg); }
.tabCount {
font-size: var(--text-2xs);
color: inherit;
opacity: 0.6;
}
/* Search */
.searchWrap {
position: relative;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
left: 10px;
color: var(--text-faint);
pointer-events: none;
}
.search {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 5px 10px 5px 28px;
color: var(--text-primary);
font-size: var(--text-sm);
width: 180px;
outline: none;
transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: var(--sp-4);
/* Contain stacking contexts for GPU layers */
contain: layout style;
}
.card {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
/* Promote to own GPU layer on hover only */
}
.card:hover .cover { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.coverWrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
/* GPU-accelerated compositing */
transform: translateZ(0);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
transition: filter var(--t-base);
/* Hint to compositor */
will-change: filter;
}
.downloadedBadge {
position: absolute;
bottom: var(--sp-1);
right: var(--sp-1);
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
background: var(--accent-dim);
color: var(--accent-fg);
border-radius: var(--radius-sm);
border: 1px solid var(--accent-muted);
}
.title {
margin-top: var(--sp-2);
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color var(--t-base);
}
/* Show more */
.showMore {
display: flex;
justify-content: center;
padding: var(--sp-6) 0 var(--sp-4);
}
.showMoreBtn {
display: flex;
align-items: center;
gap: var(--sp-3);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
color: var(--text-muted);
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
padding: 7px 20px;
background: var(--bg-raised);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.showMoreBtn:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-overlay);
}
.showMoreCount {
color: var(--text-faint);
font-size: var(--text-2xs);
}
/* Skeleton */
.cardSkeleton { padding: 0; }
.coverSkeletonWrap {
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
}
.titleSkeleton {
height: 12px;
margin-top: var(--sp-2);
width: 80%;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60%;
color: var(--text-muted);
font-size: var(--text-sm);
gap: var(--sp-2);
text-align: center;
line-height: var(--leading-base);
}
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
/* ── Tag filter ── */
.tagPanel {
display: flex; flex-wrap: wrap; gap: var(--sp-1);
padding: 0 var(--sp-6) var(--sp-3);
flex-shrink: 0;
}
.tagChip {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 3px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
.tagChipActive {
background: var(--accent-muted); border-color: var(--accent-dim);
color: var(--accent-fg);
}
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.tagClear {
display: flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 3px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
background: none; color: var(--color-error); cursor: pointer;
transition: background var(--t-base);
}
.tagClear:hover { background: var(--color-error-bg); }
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useState, useMemo, useCallback, memo } from "react";
import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_ALL_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { LibraryFilter } from "../../store";
import type { Manga } from "../../lib/types";
import s from "./Library.module.css";
const INITIAL_PAGE_SIZE = 48;
const PAGE_INCREMENT = 48;
// Memoized card to prevent re-renders when siblings change
const MangaCard = memo(function MangaCard({
manga,
onClick,
cropCovers,
}: {
manga: Manga;
onClick: () => void;
cropCovers: boolean;
}) {
return (
<button className={s.card} onClick={onClick}>
<div className={s.coverWrap}>
<img
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.cover}
style={{ objectFit: cropCovers ? "cover" : "contain" }}
loading="lazy"
decoding="async"
/>
{!!manga.downloadCount && (
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
)}
</div>
<p className={s.title}>{manga.title}</p>
</button>
);
});
export default function Library() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
const setActiveManga = useStore((state) => state.setActiveManga);
const libraryFilter = useStore((state) => state.libraryFilter);
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
const settings = useStore((state) => state.settings);
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
useEffect(() => {
// Fetch all manga (for downloaded filter on non-library entries) and
// library manga (for unreadCount/chapter progress). Merge: library wins.
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
])
.then(([all, lib]) => {
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
// Reset visible count when filter/search changes
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
const filtered = useMemo(() => {
let items = allManga;
// Apply filter tab
if (libraryFilter === "library") {
items = items.filter((m) => m.inLibrary);
} else if (libraryFilter === "downloaded") {
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
}
// Apply search
if (search.trim()) {
const q = search.toLowerCase();
items = items.filter((m) => m.title.toLowerCase().includes(q));
}
return items;
}, [allManga, libraryFilter, search]);
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
const handleCardClick = useCallback(
(m: Manga) => () => setActiveManga(m),
[setActiveManga]
);
// All genres present in current library
const allTags = useMemo(() => {
const tagSet = new Set<string>();
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
return Array.from(tagSet).sort();
}, [allManga]);
const counts = useMemo(() => ({
all: allManga.length,
library: allManga.filter((m) => m.inLibrary).length,
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
}), [allManga]);
if (error) return (
<div className={s.center}>
<p className={s.errorMsg}>Could not reach Suwayomi</p>
<p className={s.errorDetail}>{error}</p>
</div>
);
return (
<div className={s.root}>
<div className={s.header}>
<div className={s.headerLeft}>
<h1 className={s.heading}>Library</h1>
<div className={s.tabs}>
{(["library", "downloaded", "all"] as LibraryFilter[]).map((f) => (
<button
key={f}
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
onClick={() => setLibraryFilter(f)}
>
{f === "library" ? (
<><Books size={11} weight="bold" /> Saved</>
) : f === "downloaded" ? (
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
) : (
<>All</>
)}
<span className={s.tabCount}>{counts[f]}</span>
</button>
))}
</div>
</div>
<div className={s.searchWrap}>
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
<input
className={s.search}
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Tag filter panel */}
{allTags.length > 0 && (
<div className={s.tagPanel}>
{libraryTagFilter.length > 0 && (
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
<X size={11} weight="bold" />
Clear
</button>
)}
{allTags.map((tag) => {
const active = libraryTagFilter.includes(tag);
return (
<button key={tag}
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
onClick={() =>
setLibraryTagFilter(
active
? libraryTagFilter.filter((t) => t !== tag)
: [...libraryTagFilter, tag]
)
}>
{tag}
</button>
);
})}
</div>
)}
{loading ? (
<div className={s.grid}>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className={s.center}>
{libraryFilter === "library"
? "No manga saved to library. Browse sources to add some."
: libraryFilter === "downloaded"
? "No downloaded manga."
: "No manga found."}
</div>
) : (
<>
<div className={s.grid}>
{visible.map((m) => (
<MangaCard
key={m.id}
manga={m}
onClick={handleCardClick(m)}
cropCovers={settings.libraryCropCovers}
/>
))}
</div>
{hasMore && (
<div className={s.showMore}>
<button
className={s.showMoreBtn}
onClick={() => setVisibleCount((c) => c + PAGE_INCREMENT)}
>
Show more
<span className={s.showMoreCount}>{filtered.length - visibleCount} remaining</span>
</button>
</div>
)}
</>
)}
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
.root {
position: fixed; inset: 0;
background: #000;
display: flex; flex-direction: column;
z-index: var(--z-reader);
transform: translateZ(0); will-change: transform;
}
/* ── Topbar ── */
.topbar {
display: flex; align-items: center; gap: var(--sp-1);
padding: 0 var(--sp-3); height: 40px;
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; overflow: hidden;
}
.iconBtn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-muted); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.2; cursor: default; }
.chLabel {
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
font-size: var(--text-sm); color: var(--text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
.chSep { color: var(--text-faint); }
.pageLabel {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.topSep {
width: 1px; height: 16px;
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
}
.modeBtn {
display: flex; align-items: center; gap: 4px;
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
color: var(--text-muted); flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
transition: color var(--t-base), background var(--t-base);
}
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.modeBtnLabel { text-transform: capitalize; }
.zoomBtn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
flex-shrink: 0; min-width: 36px; text-align: center;
transition: color var(--t-base), background var(--t-base);
}
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
/* ── Viewer ── */
.viewer {
flex: 1; overflow-y: auto; overflow-x: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
-webkit-overflow-scrolling: touch;
}
.viewerStrip {
justify-content: flex-start;
padding: var(--sp-4) 0;
}
/* ── Images ── */
.img {
display: block; user-select: none;
image-rendering: auto;
}
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
/* Fit modes */
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; }
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; }
.fitOriginal { max-width: none; width: auto; height: auto; }
/* Longstrip */
.stripGap { margin-bottom: 8px; }
/* ── Double page ── */
.doubleWrap {
display: flex; align-items: flex-start; justify-content: center;
max-width: calc(var(--max-page-width) * 2);
width: 100%;
}
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
.gapLeft { margin-right: 2px; }
.gapRight { margin-left: 2px; }
/* ── Bottom nav ── */
.bottombar {
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
background: var(--bg-void); flex-shrink: 0;
}
.navBtn {
display: flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: var(--radius-md);
border: 1px solid var(--border-strong); color: var(--text-muted);
transition: background var(--t-base), color var(--t-base);
}
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
.navBtn:disabled { opacity: 0.25; cursor: default; }
/* ── States ── */
.center {
display: flex; flex-direction: column; align-items: center; justify-content: center;
position: fixed; inset: 0; background: #000;
}
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
/* ── Download modal ── */
.dlBackdrop {
position: fixed; inset: 0;
z-index: calc(var(--z-reader) + 10);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 48px var(--sp-4) 0;
}
.dlModal {
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); padding: var(--sp-3);
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
animation: scaleIn 0.12s ease both; transform-origin: top right;
}
.dlTitle {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
}
.dlOption {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary);
background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.dlOption:disabled { opacity: 0.3; cursor: default; }
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
.dlInput {
width: 48px; padding: 4px var(--sp-2);
background: var(--bg-overlay); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs);
text-align: center; outline: none;
}
.dlInput:focus { border-color: var(--border-focus); }
+500
View File
@@ -0,0 +1,500 @@
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
import {
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Columns, Rows, Download, ArrowsLeftRight,
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { useStore, type FitMode } from "../../store";
import { matchesKeybind } from "../../lib/keybinds";
import s from "./Reader.module.css";
function preloadImage(url: string) {
const img = new Image(); img.src = url;
}
// Returns aspect ratio once image loads; wide (>1.2 w:h) = likely double spread
function measureAspect(url: string): Promise<number> {
return new Promise((res) => {
const img = new Image();
img.onload = () => res(img.naturalWidth / img.naturalHeight);
img.onerror = () => res(0.67);
img.src = url;
});
}
// ── Download modal ────────────────────────────────────────────────────────────
function DownloadModal({
chapter,
remaining,
onClose,
}: {
chapter: { id: number; name: string };
remaining: { id: number }[];
onClose: () => void;
}) {
const [nextN, setNextN] = useState(5);
const [busy, setBusy] = useState(false);
const run = async (fn: () => Promise<unknown>) => {
setBusy(true);
await fn().catch(console.error);
setBusy(false);
onClose();
};
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 }))}>
This chapter
<span className={s.dlSub}>{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),
}))}>
Next chapters
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
</button>
<input type="number" className={s.dlInput} min={1}
max={remaining.length || 1} value={nextN}
onChange={(e) => setNextN(Math.max(1, Number(e.target.value)))}
onClick={(e) => e.stopPropagation()} />
</div>
<button className={s.dlOption} disabled={busy || !remaining.length}
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
chapterIds: remaining.map((c) => c.id),
}))}>
All remaining
<span className={s.dlSub}>{remaining.length} chapters</span>
</button>
</div>
</div>
);
}
// ── Reader ────────────────────────────────────────────────────────────────────
export default function Reader() {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const pageNumRef = useRef(1);
const pageCache = useRef<Map<number, string[]>>(new Map());
const aspectCache = useRef<Map<string, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dlOpen, setDlOpen] = useState(false);
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
const [pageGroups, setPageGroups] = useState<number[][]>([]);
const {
activeManga, activeChapter, activeChapterList,
pageUrls, pageNumber, settings,
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
updateSettings, addHistory,
} = useStore();
const kb = settings.keybinds;
const rtl = settings.readingDirection === "rtl";
const fit = settings.fitMode ?? "width";
const style = settings.pageStyle ?? "single";
const maxW = settings.maxPageWidth ?? 900;
useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);
// ── Load pages ──────────────────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter) return;
setLoading(true); setError(null); setPageGroups([]);
const cached = pageCache.current.get(activeChapter.id);
if (cached) { setPageUrls(cached); setLoading(false); return; }
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id })
.then((d) => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(activeChapter.id, urls);
setPageUrls(urls);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [activeChapter?.id]);
// ── Double-page grouping ─────────────────────────────────────────────────────
// Rule: page 1 (cover) always solo. Wide pages (aspect>1.2) always solo.
// Normal portrait pages pair with next portrait page.
useEffect(() => {
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
let cancelled = false;
(async () => {
const aspects: number[] = [];
for (const url of pageUrls) {
if (aspectCache.current.has(url)) {
aspects.push(aspectCache.current.get(url)!);
} else {
const a = await measureAspect(url);
aspectCache.current.set(url, a);
aspects.push(a);
}
}
if (cancelled) return;
const groups: number[][] = [];
// Page 1 always solo (cover)
groups.push([1]);
let i = 2;
while (i <= pageUrls.length) {
const a = aspects[i - 1];
if (a > 1.2 || i === pageUrls.length) {
// Wide or last page — solo
groups.push([i]); i++;
} else {
const next = aspects[i]; // aspects[i] = page i+1 (0-indexed)
if (next !== undefined && next <= 1.2) {
groups.push([i, i + 1]); i += 2;
} else {
groups.push([i]); i++;
}
}
}
setPageGroups(groups);
})();
return () => { cancelled = true; };
}, [pageUrls, style, settings.offsetDoubleSpreads]);
const currentGroup = useMemo(() => {
if (style !== "double" || !pageGroups.length) return null;
return pageGroups.find((g) => g.includes(pageNumber)) ?? null;
}, [pageGroups, pageNumber, style]);
// ── Preload ─────────────────────────────────────────────────────────────────
useEffect(() => {
for (let i = 1; i <= (settings.preloadPages ?? 3); i++) {
const url = pageUrls[pageNumber - 1 + i];
if (url) preloadImage(url);
}
}, [pageNumber, pageUrls, settings.preloadPages]);
// ── Adjacent chapters ────────────────────────────────────────────────────────
const adjacent = useMemo(() => {
if (!activeChapter || !activeChapterList.length)
return { prev: null, next: null, remaining: [] };
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
return {
prev: idx > 0 ? activeChapterList[idx - 1] : null,
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
remaining: activeChapterList.slice(idx + 1),
};
}, [activeChapter, activeChapterList]);
useEffect(() => {
const preload = (id: number) => {
if (pageCache.current.has(id)) return;
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id })
.then((d) => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(id, urls);
urls.slice(0, 2).forEach(preloadImage);
}).catch(() => {});
};
if (adjacent.next) preload(adjacent.next.id);
if (adjacent.prev) preload(adjacent.prev.id);
}, [adjacent.next?.id, adjacent.prev?.id]);
const lastPage = pageUrls.length;
// ── Auto-mark read + history ─────────────────────────────────────────────────
useEffect(() => {
if (!activeChapter || !lastPage) return;
if (activeManga) {
addHistory({
mangaId: activeManga.id, mangaTitle: activeManga.title,
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
});
}
if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
setMarkedRead((p) => new Set(p).add(activeChapter.id));
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
}
}, [pageNumber, lastPage, activeChapter?.id]);
// ── Navigation ──────────────────────────────────────────────────────────────
const advanceGroup = useCallback((forward: boolean) => {
if (!pageGroups.length) return;
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
else closeReader();
} else {
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
}
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
const goForward = useCallback(() => {
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (pageNumber < lastPage) setPageNumber(pageNumber + 1);
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
else closeReader();
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
const goBack = useCallback(() => {
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
if (pageNumber > 1) setPageNumber(pageNumber - 1);
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
}, [pageNumber, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
const goNext = rtl ? goBack : goForward;
const goPrev = rtl ? goForward : goBack;
function cycleStyle() {
const cycle = ["single", "double", "longstrip"] as const;
const next = cycle[(cycle.indexOf(style as any) + 1) % cycle.length];
updateSettings({ pageStyle: next });
}
function cycleFit() {
const cycle: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
}
// Ctrl+scroll → zoom maxPageWidth
useEffect(() => {
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY < 0 ? 50 : -50;
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + delta)) });
};
window.addEventListener("wheel", onWheel, { passive: false });
return () => window.removeEventListener("wheel", onWheel);
}, [maxW]);
// Keybinds
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)){ e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList]);
// Longstrip scroll — rAF throttled, no flushSync
useEffect(() => {
const el = containerRef.current;
if (!el || style !== "longstrip") return;
const onScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
if (!el) return;
const midY = el.scrollTop + el.clientHeight * 0.5;
let cumH = 0;
const children = Array.from(el.children) as HTMLElement[];
for (let i = 0; i < children.length; i++) {
cumH += children[i].clientHeight;
if (cumH >= midY) {
const n = i + 1;
if (n !== pageNumRef.current) setPageNumber(n);
break;
}
}
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); };
}, [style]);
useEffect(() => {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [pageNumber, style]);
function handleTap(e: React.MouseEvent) {
if (style === "longstrip") return;
const x = e.clientX / window.innerWidth;
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
}
// ── CSS vars ─────────────────────────────────────────────────────────────────
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
const imgCls = [
s.img,
fit === "width" && s.fitWidth,
fit === "height" && s.fitHeight,
fit === "screen" && s.fitScreen,
fit === "original" && s.fitOriginal,
settings.optimizeContrast && s.optimizeContrast,
].filter(Boolean).join(" ");
// ── Double page render ────────────────────────────────────────────────────────
function renderDouble() {
if (!currentGroup) {
return <img src={pageUrls[pageNumber - 1]} alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />;
}
const ordered = rtl ? [...currentGroup].reverse() : currentGroup;
const [left, right] = ordered;
return (
<div className={s.doubleWrap}>
<img src={pageUrls[left - 1]} alt={`Page ${left}`}
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapLeft : ""].join(" ")} decoding="async" />
{right && (
<img src={pageUrls[right - 1]} alt={`Page ${right}`}
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapRight : ""].join(" ")} decoding="async" />
)}
</div>
);
}
// ── Icons ────────────────────────────────────────────────────────────────────
const fitIcon =
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
<ArrowsOut size={14} weight="light" />;
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
const styleIcon =
style === "single" ? <Square size={14} weight="light" /> :
style === "double" ? <Columns size={14} weight="light" /> :
<Rows size={14} weight="light" />;
if (loading) return (
<div className={s.center}>
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
);
if (error) return (
<div className={s.center}><p className={s.errorMsg}>{error}</p></div>
);
return (
<div className={s.root}>
{/* ── Topbar ── */}
<div className={s.topbar}>
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
<X size={15} weight="light" />
</button>
<button className={s.iconBtn} onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
disabled={!adjacent.prev} title="Previous chapter">
<CaretLeft size={14} weight="light" />
</button>
<span className={s.chLabel}>
<span className={s.chTitle}>{activeManga?.title}</span>
<span className={s.chSep}>/</span>
<span>{activeChapter?.name}</span>
</span>
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
<button className={s.iconBtn} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
disabled={!adjacent.next} title="Next chapter">
<CaretRight size={14} weight="light" />
</button>
<div className={s.topSep} />
{/* Fit mode */}
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
{fitIcon}
<span className={s.modeBtnLabel}>{fitLabel}</span>
</button>
{/* Zoom — click resets */}
<button className={s.zoomBtn} onClick={() => updateSettings({ maxPageWidth: 900 })}
title="Click to reset zoom (Ctrl+scroll to zoom)">
{Math.round((maxW / 900) * 100)}%
</button>
{/* RTL */}
<button
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
<ArrowsLeftRight size={14} weight="light" />
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
</button>
{/* Page style */}
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
{styleIcon}
<span className={s.modeBtnLabel}>{style}</span>
</button>
{/* Page gap toggle — only meaningful in double/longstrip */}
{style !== "single" && (
<button
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
title="Toggle page gap">
<span className={s.modeBtnLabel}>Gap</span>
</button>
)}
{/* Download */}
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
<Download size={14} weight="light" />
</button>
</div>
{/* ── Viewer ── */}
<div
ref={containerRef}
className={[
s.viewer,
style === "longstrip" ? s.viewerStrip : "",
].join(" ")}
style={cssVars}
onClick={handleTap}
>
{style === "longstrip" ? (
pageUrls.map((url, i) => (
<img key={i} src={url} alt={`Page ${i + 1}`}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={i < 3 ? "eager" : "lazy"} decoding="async" />
))
) : style === "double" ? (
renderDouble()
) : (
<img key={pageNumber} src={pageUrls[pageNumber - 1]}
alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />
)}
</div>
{/* ── Bottom nav ── */}
<div className={s.bottombar}>
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
<ArrowLeft size={13} weight="light" />
</button>
<button className={s.navBtn} onClick={goNext} disabled={pageNumber === lastPage && !adjacent.next}>
<ArrowRight size={13} weight="light" />
</button>
</div>
{dlOpen && activeChapter && (
<DownloadModal
chapter={activeChapter}
remaining={adjacent.remaining}
onClose={() => setDlOpen(false)}
/>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
.root {
display: flex; flex-direction: column; height: 100%;
overflow: hidden; animation: fadeIn 0.14s ease both;
}
.header {
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; flex-shrink: 0;
}
.searchBar {
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2);
transition: border-color var(--t-base);
}
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput {
flex: 1; background: none; border: none; outline: none;
color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0;
}
.searchInput::placeholder { color: var(--text-faint); }
.searchBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0;
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); cursor: pointer;
transition: filter var(--t-base); display: flex; align-items: center; gap: var(--sp-2);
}
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
.searchBtn:disabled { opacity: 0.4; cursor: default; }
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
.sourceHeader {
display: flex; align-items: center; gap: var(--sp-2);
}
.sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
.sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.resultCount {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); margin-left: auto;
}
.sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); }
.sourceRow {
display: flex; gap: var(--sp-3); overflow-x: auto;
padding-bottom: var(--sp-2);
scrollbar-width: thin;
}
.card {
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
cursor: pointer; text-align: left;
}
.card:hover .cover { filter: brightness(1.06); }
.coverWrap {
position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md);
overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim);
}
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
.inLibBadge {
position: absolute; bottom: var(--sp-1); left: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm);
}
.cardTitle {
margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-muted);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
line-height: var(--leading-snug);
}
/* Skeletons */
.skCard { flex-shrink: 0; width: 110px; }
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
.empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: var(--sp-2);
}
.emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
+168
View File
@@ -0,0 +1,168 @@
import { useState, useRef, useCallback } from "react";
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import s from "./Search.module.css";
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
}
export default function Search() {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const setActiveManga = useStore((s) => s.setActiveManga);
const setNavPage = useStore((s) => s.setNavPage);
const loadSources = useCallback(async () => {
if (sources.length) return sources;
setLoadingSources(true);
const data = await gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.finally(() => setLoadingSources(false));
const nodes = data.sources.nodes.filter((s) => s.id !== "0");
setSources(nodes);
return nodes;
}, [sources]);
async function runSearch() {
const q = query.trim();
if (!q) return;
setSubmitted(q);
const srcs = await loadSources();
// Initialise loading state for each source
setResults(srcs.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
// Fire all source queries in parallel, update each independently
srcs.forEach((src) => {
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: q,
})
.then((d) => {
setResults((prev) => prev.map((r) =>
r.source.id === src.id
? { ...r, mangas: d.fetchSourceManga.mangas, loading: false }
: r
));
})
.catch((e) => {
setResults((prev) => prev.map((r) =>
r.source.id === src.id
? { ...r, loading: false, error: e.message }
: r
));
});
});
}
function openManga(m: Manga) {
setActiveManga(m);
setNavPage("library");
}
const hasResults = results.some((r) => r.mangas.length > 0);
const allDone = results.every((r) => !r.loading);
return (
<div className={s.root}>
{/* ── Search bar ── */}
<div className={s.header}>
<h1 className={s.heading}>Search</h1>
<div className={s.searchBar}>
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
<input ref={inputRef} className={s.searchInput}
placeholder="Search across all sources…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && runSearch()}
autoFocus />
<button className={s.searchBtn}
onClick={runSearch}
disabled={!query.trim() || loadingSources}>
{loadingSources
? <CircleNotch size={13} weight="light" className="anim-spin" />
: "Search"}
</button>
</div>
</div>
{/* ── Empty state ── */}
{!submitted && (
<div className={s.empty}>
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>Search across all installed sources at once</p>
<p className={s.emptyHint}>Results from each source appear as they load.</p>
</div>
)}
{/* ── Results ── */}
{submitted && (
<div className={s.results}>
{results.length === 0 && (
<div className={s.empty}>
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
)}
{results
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
.map(({ source, mangas, loading, error }) => (
<div key={source.id} className={s.sourceSection}>
<div className={s.sourceHeader}>
<img src={thumbUrl(source.iconUrl)} alt={source.displayName}
className={s.sourceIcon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span className={s.sourceName}>{source.displayName}</span>
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
{!loading && mangas.length > 0 && (
<span className={s.resultCount}>{mangas.length} results</span>
)}
</div>
{error ? (
<p className={s.sourceError}>{error}</p>
) : loading ? (
<div className={s.sourceRow}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={s.skCard}>
<div className={["skeleton", s.skCover].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
</div>
) : mangas.length > 0 ? (
<div className={s.sourceRow}>
{mangas.slice(0, 8).map((m) => (
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
<div className={s.coverWrap}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
</div>
<p className={s.cardTitle}>{m.title}</p>
</button>
))}
</div>
) : null}
</div>
))}
{allDone && !hasResults && submitted && (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{submitted}"</p>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,430 @@
.root {
display: flex;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
/* ── Sidebar ── */
.sidebar {
width: 200px;
flex-shrink: 0;
padding: var(--sp-5);
border-right: 1px solid var(--border-dim);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--sp-4);
background: var(--bg-base);
}
.back {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
transition: color var(--t-base);
}
.back:hover { color: var(--text-secondary); }
.coverWrap {
width: 100%;
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
flex-shrink: 0;
}
.cover { width: 100%; height: 100%; object-fit: cover; }
.metaSkeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.skLine { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
.title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-primary);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
}
.byline {
font-size: var(--text-xs);
color: var(--text-muted);
font-family: var(--font-ui);
}
.statusBadge {
display: inline-block;
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: 2px 7px;
border-radius: var(--radius-sm);
width: fit-content;
}
.statusOngoing {
background: var(--accent-muted);
color: var(--accent-fg);
border: 1px solid var(--accent-dim);
}
.statusEnded {
background: var(--bg-raised);
color: var(--text-faint);
border: 1px solid var(--border-dim);
}
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre {
font-size: var(--text-2xs);
font-family: var(--font-ui);
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);
}
.sourceLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.description {
font-size: var(--text-xs);
color: var(--text-muted);
line-height: var(--leading-base);
display: -webkit-box;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Progress ── */
.progressSection {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.progressHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.progressLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progressPct {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
}
.progressTrack {
height: 3px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
/* ── Actions ── */
.actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.libraryBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
padding: 5px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
color: var(--text-muted);
background: var(--bg-raised);
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
flex: 1;
}
.libraryBtn:hover { border-color: var(--accent); color: var(--accent-fg); }
.libraryBtn:disabled { opacity: 0.4; cursor: default; }
.libraryBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.externalLink {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
color: var(--text-faint);
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.externalLink:hover { color: var(--text-muted); border-color: var(--border-strong); }
/* ── Start/Continue reading button ── */
.readBtn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
width: 100%;
padding: 8px var(--sp-3);
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid var(--accent);
color: var(--accent-fg);
font-size: var(--text-xs);
font-family: var(--font-ui);
letter-spacing: var(--tracking-wide);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.readBtn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
.chapterCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin-top: auto;
padding-top: var(--sp-2);
}
/* ── Chapter list ── */
.listWrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.listHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.sortBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
padding: 4px 8px;
border-radius: var(--radius-md);
transition: background var(--t-base), color var(--t-base);
}
.sortBtn:hover { background: var(--bg-raised); color: var(--text-secondary); }
.pagination {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.paginationBottom {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border-dim);
flex-shrink: 0;
}
.pageBtn {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.pageBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.pageBtn:disabled { opacity: 0.3; cursor: default; }
.pageNum {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
min-width: 40px;
text-align: center;
}
.list {
flex: 1;
overflow-y: auto;
padding: var(--sp-2) var(--sp-4);
display: flex;
flex-direction: column;
gap: 1px;
}
.rowSkeleton {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: 12px var(--sp-3);
border-radius: var(--radius-md);
background: var(--bg-raised);
margin-bottom: 1px;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
background: none;
border: none;
border-radius: var(--radius-md);
padding: 10px var(--sp-3);
cursor: pointer;
text-align: left;
width: 100%;
color: var(--text-primary);
transition: background var(--t-fast);
}
.row:hover { background: var(--bg-raised); }
.rowRead .chName { color: var(--text-faint); }
.chLeft {
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.chName {
font-size: var(--text-base);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--t-fast);
}
.row:hover .chName { color: var(--text-primary); }
.chMeta { display: flex; align-items: center; gap: var(--sp-3); }
.chMetaItem {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.chRight {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
margin-left: var(--sp-3);
}
.bookmarkIcon { color: var(--accent); }
.readIcon { color: var(--text-faint); }
.downloadedIcon { color: var(--accent-fg); }
.enqueuingIcon { color: var(--text-faint); }
.dlBtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.dlBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
/* ── Download section ── */
.downloadSection {
position: relative; margin-top: var(--sp-2);
}
.downloadToggle {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
font-size: var(--text-sm); cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.downloadToggle:hover { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.downloadMenu {
margin-top: var(--sp-1);
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1);
display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: fadeIn 0.1s ease both;
}
.dlItem {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary);
background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); }
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import {
ArrowLeft, BookmarkSimple, Download, CheckCircle,
ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD,
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Chapter } from "../../lib/types";
import s from "./SeriesDetail.module.css";
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
interface CtxState {
x: number;
y: number;
chapter: Chapter;
indexInSorted: number;
}
const CHAPTERS_PER_PAGE = 25;
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);
const [loadingChapters, setLoadingChapters] = useState(true);
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
const [dlOpen, setDlOpen] = useState(false);
const [togglingLibrary, setTogglingLibrary] = useState(false);
const [chapterPage, setChapterPage] = useState(1);
const [ctx, setCtx] = useState<CtxState | null>(null);
const sortDir = settings.chapterSortDir;
useEffect(() => {
if (!activeManga) return;
setLoadingManga(true);
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
.then((data) => setManga(data.manga))
.catch(console.error)
.finally(() => setLoadingManga(false));
}, [activeManga?.id]);
const loadChapters = useCallback((mangaId: number) => {
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
.then((data) => {
// Always store in natural order (ascending sourceOrder), sort in render
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
setChapters(sorted);
return sorted;
});
}, []);
useEffect(() => {
if (!activeManga) return;
setLoadingChapters(true);
setChapters([]);
setChapterPage(1);
loadChapters(activeManga.id)
.catch(console.error)
.finally(() => setLoadingChapters(false));
// Fetch from source in background
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => loadChapters(activeManga.id))
.catch(console.error);
}, [activeManga?.id]);
// Sorted chapters based on setting
const sortedChapters = useMemo(() =>
sortDir === "desc"
? [...chapters].reverse()
: [...chapters],
[chapters, sortDir]
);
const totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
const pageChapters = sortedChapters.slice(
(chapterPage - 1) * CHAPTERS_PER_PAGE,
chapterPage * CHAPTERS_PER_PAGE
);
// Progress stats
const readCount = chapters.filter((c) => c.isRead).length;
const totalCount = chapters.length;
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
// Start / Continue reading logic
const continueChapter = useMemo(() => {
if (!chapters.length) return null;
// Find first unread chapter (in ascending order)
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
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);
if (firstUnread) return { chapter: firstUnread, type: "start" as const };
return { chapter: asc[0], type: "reread" as const };
}, [chapters]);
async function toggleLibrary() {
if (!manga) return;
setTogglingLibrary(true);
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
setManga((prev) => prev ? { ...prev, inLibrary: next } : prev);
setTogglingLibrary(false);
}
async function enqueue(chapter: Chapter, e: React.MouseEvent) {
e.stopPropagation();
setEnqueueing((prev) => new Set(prev).add(chapter.id));
await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error);
setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; });
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) {
// "above" = all chapters that appear before this one in the current sort
const targets = sortedChapters.slice(0, indexInSorted + 1);
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
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));
}
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));
}
async function enqueueMultiple(chapterIds: number[]) {
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
if (activeManga) loadChapters(activeManga.id);
}
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[] {
return [
{
label: ch.isRead ? "Mark as unread" : "Mark as read",
onClick: () => markRead(ch.id, !ch.isRead),
},
{
label: "Mark all above as read",
onClick: () => markAllAboveRead(indexInSorted),
disabled: indexInSorted === 0,
},
{ separator: true },
{
label: ch.isDownloaded ? "Delete download" : "Download",
onClick: () => ch.isDownloaded
? deleteDownloaded(ch.id)
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
danger: ch.isDownloaded,
},
{ separator: true },
{
label: "Download all from here",
onClick: () => {
const fromHere = sortedChapters
.slice(indexInSorted)
.filter((c) => !c.isDownloaded)
.map((c) => c.id);
enqueueMultiple(fromHere);
},
},
];
}
if (!activeManga) return null;
const statusLabel = manga?.status
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
: null;
return (
<div className={s.root} onContextMenu={(e) => e.preventDefault()}>
{/* ── Sidebar ── */}
<div className={s.sidebar}>
<button className={s.back} onClick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" />
<span>Library</span>
</button>
<div className={s.coverWrap}>
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} className={s.cover} />
</div>
{loadingManga ? (
<div className={s.metaSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "90%", height: 14 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "60%", height: 11 }} />
</div>
) : (
<div className={s.meta}>
<p className={s.title}>{manga?.title}</p>
{(manga?.author || manga?.artist) && (
<p className={s.byline}>
{[manga.author, manga.artist]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join(" · ")}
</p>
)}
{statusLabel && (
<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>)}
</div>
)}
{manga?.source && <p className={s.sourceLabel}>{manga.source.displayName}</p>}
{manga?.description && <p className={s.description}>{manga.description}</p>}
</div>
)}
{/* Progress bar */}
{totalCount > 0 && (
<div className={s.progressSection}>
<div className={s.progressHeader}>
<span className={s.progressLabel}>{readCount} / {totalCount} read</span>
<span className={s.progressPct}>{Math.round(progressPct)}%</span>
</div>
<div className={s.progressTrack}>
<div className={s.progressFill} style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className={s.actions}>
<button
className={[s.libraryBtn, manga?.inLibrary ? s.libraryBtnActive : ""].join(" ").trim()}
onClick={toggleLibrary}
disabled={togglingLibrary || loadingManga}
>
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
{manga?.inLibrary ? "In Library" : "Add to Library"}
</button>
{manga?.realUrl && (
<a href={manga.realUrl} target="_blank" rel="noreferrer" className={s.externalLink}>
<ArrowSquareOut size={13} weight="light" />
</a>
)}
</div>
{/* Start / Continue reading button */}
{continueChapter && (
<button
className={s.readBtn}
onClick={() => openReader(continueChapter.chapter, sortedChapters)}
>
<Play size={12} weight="fill" />
{continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${
(continueChapter.chapter.lastPageRead ?? 0) > 0
? ` p.${continueChapter.chapter.lastPageRead}`
: ""
}`
: continueChapter.type === "reread"
? "Read again"
: "Start reading"
}
</button>
)}
{/* ── Download panel ── */}
{chapters.length > 0 && (
<div className={s.downloadSection}>
<button className={s.downloadToggle} onClick={() => setDlOpen((p) => !p)}>
<Download size={13} weight="light" />
Download
</button>
{dlOpen && (
<div className={s.downloadMenu}>
{continueChapter && (
<button className={s.dlItem}
onClick={() => {
const from = sortedChapters.indexOf(continueChapter.chapter);
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>From current</span>
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
</button>
)}
<button className={s.dlItem}
onClick={() => {
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>Unread chapters</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
</button>
<button className={s.dlItem}
onClick={() => {
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
enqueueMultiple(ids);
setDlOpen(false);
}}>
<span>Download all</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
</button>
</div>
)}
</div>
)}
<p className={s.chapterCount}>
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
</p>
</div>
{/* ── Chapter list ── */}
<div className={s.listWrap}>
{/* List header with sort + pagination */}
<div className={s.listHeader}>
<button
className={s.sortBtn}
onClick={() => {
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
setChapterPage(1);
}}
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
>
{sortDir === "desc"
? <SortDescending size={14} weight="light" />
: <SortAscending size={14} weight="light" />
}
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
{totalPages > 1 && (
<div className={s.pagination}>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
disabled={chapterPage === 1}
></button>
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
disabled={chapterPage === totalPages}
></button>
</div>
)}
</div>
<div className={s.list}>
{loadingChapters && chapters.length === 0 ? (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={s.rowSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
</div>
))
) : (
pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
return (
<button
key={ch.id}
className={[s.row, ch.isRead ? s.rowRead : ""].join(" ").trim()}
onClick={() => openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
>
<div className={s.chLeft}>
<span className={s.chName}>{ch.name}</span>
<div className={s.chMeta}>
{ch.scanlator && <span className={s.chMetaItem}>{ch.scanlator}</span>}
{ch.uploadDate && <span className={s.chMetaItem}>{formatDate(ch.uploadDate)}</span>}
{ch.lastPageRead != null && ch.lastPageRead > 0 && !ch.isRead && (
<span className={s.chMetaItem}>p.{ch.lastPageRead}</span>
)}
</div>
</div>
<div className={s.chRight}>
{ch.isBookmarked && (
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
)}
{ch.isRead ? (
<CheckCircle size={14} weight="light" className={s.readIcon} />
) : ch.isDownloaded ? (
<BookOpen size={14} weight="light" className={s.downloadedIcon} />
) : 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">
<Download size={13} weight="light" />
</button>
)}
</div>
</button>
);
})
)}
</div>
{/* Bottom pagination */}
{totalPages > 1 && (
<div className={s.paginationBottom}>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
disabled={chapterPage === 1}
> Prev</button>
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
<button
className={s.pageBtn}
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
disabled={chapterPage === totalPages}
>Next </button>
</div>
)}
</div>
{/* Context menu */}
{ctx && (
<ContextMenu
x={ctx.x}
y={ctx.y}
items={buildCtxItems(ctx.chapter, ctx.indexInSorted)}
onClose={() => setCtx(null)}
/>
)}
</div>
);
}