[V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit)

This commit is contained in:
Youwes09
2026-02-23 22:40:00 -06:00
parent fb82abaf21
commit 523fb40538
19 changed files with 3096 additions and 983 deletions
+46 -48
View File
@@ -3,21 +3,30 @@ import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Tr
import { useVirtualizer } from "@tanstack/react-virtual";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { useStore } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import s from "./Library.module.css";
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
const CARD_MIN_W = 130;
const CARD_GAP = 16;
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
const ROW_HEIGHT = 260;
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
onLoad={() => setLoaded(true)}
/>
);
}
const MangaCard = memo(function MangaCard({
manga,
onClick,
onContextMenu,
cropCovers,
manga, onClick, onContextMenu, cropCovers,
}: {
manga: Manga;
onClick: () => void;
@@ -27,13 +36,11 @@ const MangaCard = memo(function MangaCard({
return (
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
<div className={s.coverWrap}>
<img
<FadeImg
src={thumbUrl(manga.thumbnailUrl)}
alt={manga.title}
className={s.cover}
style={{ objectFit: cropCovers ? "cover" : "contain" }}
loading="lazy"
decoding="async"
objectFit={cropCovers ? "cover" : "contain"}
/>
{!!manga.downloadCount && (
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
@@ -44,6 +51,12 @@ const MangaCard = memo(function MangaCard({
);
});
function fetchLibrary() {
return cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
);
}
export default function Library() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loading, setLoading] = useState(true);
@@ -59,19 +72,27 @@ export default function Library() {
const settings = useStore((state) => state.settings);
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
const setGenreFilter = useStore((state) => state.setGenreFilter);
const folders = useStore((state) => state.settings.folders);
const addFolder = useStore((state) => state.addFolder);
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
useEffect(() => {
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
.then((lib) => setAllManga(lib.mangas.nodes))
const loadData = useCallback((showLoading = false) => {
if (showLoading) setLoading(true);
fetchLibrary()
.then((nodes) => { setAllManga(nodes); setError(null); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
// Reset scroll when filter/search changes
useEffect(() => {
loadData(true);
// Re-fetch when library cache is invalidated (e.g. by Explore or GenreDrillPage)
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
return unsub;
}, [loadData]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [libraryFilter, search]);
@@ -138,7 +159,9 @@ export default function Library() {
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
// Optimistic update first, then invalidate cache
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
cache.clear(CACHE_KEYS.LIBRARY);
}
async function deleteAllDownloads(manga: Manga) {
@@ -147,14 +170,8 @@ export default function Library() {
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
const ids = downloadedChapters.map((c) => c.id);
if (!ids.length) return;
// Delete the downloaded files
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
// Also remove these chapters from the download queue (fix #12)
// Fire-and-forget — queue removal is best-effort
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
} catch (e) { console.error(e); }
}
@@ -172,9 +189,7 @@ export default function Library() {
return {
label: inFolder ? `${f.name}` : f.name,
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
onClick: () => inFolder
? removeMangaFromFolder(f.id, m.id)
: assignMangaToFolder(f.id, m.id),
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
};
});
@@ -192,7 +207,10 @@ export default function Library() {
onClick: () => m.inLibrary
? removeFromLibrary(m)
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
.then(() => {
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
{
@@ -262,7 +280,6 @@ export default function Library() {
className={s.root}
ref={scrollRef}
onContextMenu={(e) => {
// Only fire on the bare background, not on cards
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
setEmptyCtx({ x: e.clientX, y: e.clientY });
@@ -322,9 +339,7 @@ export default function Library() {
return (
<button key={tag}
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
onClick={() => setLibraryTagFilter(
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
)}>
onClick={() => setGenreFilter(tag)}>
{tag}
</button>
);
@@ -352,13 +367,7 @@ export default function Library() {
: "No manga found."}
</div>
) : (
/* Virtual scroll container */
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const rowManga = rows[virtualRow.index];
return (
@@ -382,7 +391,6 @@ export default function Library() {
cropCovers={settings.libraryCropCovers}
/>
))}
{/* Ghost cards on last row to fill grid */}
{virtualRow.index === rows.length - 1 &&
Array.from({ length: cols - rowManga.length }).map((_, i) => (
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
@@ -394,20 +402,10 @@ export default function Library() {
)}
{ctx && (
<ContextMenu
x={ctx.x}
y={ctx.y}
items={buildCtxItems(ctx.manga)}
onClose={() => setCtx(null)}
/>
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
{emptyCtx && (
<ContextMenu
x={emptyCtx.x}
y={emptyCtx.y}
items={buildEmptyCtxItems()}
onClose={() => setEmptyCtx(null)}
/>
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
)}
</div>
);
+244 -48
View File
@@ -1,30 +1,79 @@
/* ── Root ────────────────────────────────────────────────────────────────── */
.root {
display: flex; flex-direction: column; height: 100%;
overflow: hidden; animation: fadeIn 0.14s ease both;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.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;
padding: var(--sp-3) var(--sp-6) var(--sp-3) 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;
}
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
.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); }
/* ── Keyword tab bar area ────────────────────────────────────────────────── */
.keywordBar {
flex-shrink: 0; display: flex; flex-direction: column;
border-bottom: 1px solid var(--border-dim);
}
/* ── Shared search bar ───────────────────────────────────────────────────── */
.searchBar {
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
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);
margin: var(--sp-3) var(--sp-6);
}
.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); }
.advancedBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
background: none; border: 1px solid transparent;
color: var(--text-faint); cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.advancedBtn:hover { color: var(--text-muted); border-color: var(--border-dim); }
.advancedBtnActive {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
.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;
@@ -35,74 +84,108 @@
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
.searchBtn:disabled { opacity: 0.4; cursor: default; }
.langBar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
.clearSearchBtn {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 50%;
font-size: 15px; line-height: 1;
color: var(--text-faint); background: var(--bg-overlay); border: none;
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
}
.clearSearchBtn:hover { color: var(--text-muted); }
/* ── Advanced filter panel ───────────────────────────────────────────────── */
.advancedPanel {
display: flex; flex-direction: column; gap: var(--sp-3);
padding: 0 var(--sp-6) var(--sp-4);
animation: fadeIn 0.1s ease both;
}
.langBtn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
color: var(--text-faint);
cursor: pointer;
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
.advancedTitle {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.advancedActions { display: flex; gap: var(--sp-3); }
.advancedLink {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--accent-fg);
background: none; border: none; cursor: pointer; padding: 0;
transition: opacity var(--t-base);
}
.advancedLink:hover { opacity: 0.7; }
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.langFilterRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-3) var(--sp-3) 0; }
.langChip {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); 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);
white-space: nowrap;
}
.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.langBtnActive {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.sourceCount {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-left: auto;
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
.langChipActive {
background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg);
}
.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); }
.advancedDivider { height: 1px; background: var(--border-dim); margin: 0 calc(-1 * var(--sp-6)); }
.advancedCheck {
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); cursor: pointer; user-select: none;
}
.checkbox { accent-color: var(--accent-fg); width: 13px; height: 13px; cursor: pointer; }
.advancedFooter {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.advancedFooter strong { color: var(--text-muted); font-weight: var(--weight-medium); }
/* ── Keyword results list ────────────────────────────────────────────────── */
.results {
flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-6);
scrollbar-width: thin;
}
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
.sourceHeader {
display: flex; align-items: center; gap: var(--sp-2);
}
.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 {
.sourceLang {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); margin-left: auto;
letter-spacing: var(--tracking-wider); padding: 1px 5px;
border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
}
.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;
padding-bottom: var(--sp-2); scrollbar-width: thin;
}
/* ── Shared manga card ───────────────────────────────────────────────────── */
.card {
flex-shrink: 0; width: 110px; background: none; border: none; padding: 0;
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);
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);
@@ -115,14 +198,127 @@
line-height: var(--leading-snug);
}
/* ── Skeleton cards ──────────────────────────────────────────────────────── */
.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%; }
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; }
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; border-radius: 3px; }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: var(--sp-2);
padding: var(--sp-6);
}
.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); }
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); text-align: center; max-width: 280px; }
.advancedLinkStandalone {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none;
cursor: pointer; padding: 0; margin-top: var(--sp-1);
transition: color var(--t-base);
}
.advancedLinkStandalone:hover { color: var(--accent-fg); }
/* ── Split layout (tag + source tabs) ───────────────────────────────────── */
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar {
width: 192px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column; overflow: hidden;
background: var(--bg-raised);
}
.splitSearchWrap {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput {
flex: 1; background: none; border: none; outline: none;
color: var(--text-primary); font-size: var(--text-xs);
font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
}
.splitSearchInput::placeholder { color: var(--text-faint); }
.splitList {
flex: 1; overflow-y: auto; padding: var(--sp-2);
display: flex; flex-direction: column; gap: 1px; scrollbar-width: thin;
}
.splitItem {
display: flex; align-items: center; width: 100%;
padding: 7px var(--sp-2); border-radius: var(--radius-md);
border: none; background: none;
color: var(--text-muted); font-size: var(--text-sm);
cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.splitItem:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.splitItemActive { background: var(--accent-muted); color: var(--accent-fg); }
.splitItemActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.splitItemSource { gap: var(--sp-2); }
.splitItemLabel { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitSourceIcon { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
.splitEmpty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-3) var(--sp-2);
}
.splitLoading {
flex: 1; display: flex; align-items: center; justify-content: center;
}
/* ── Split right content ─────────────────────────────────────────────────── */
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
flex-wrap: wrap;
min-height: 52px;
}
.splitContentTitle {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-secondary); letter-spacing: var(--tracking-tight);
}
.splitResultCount {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.splitSourceTitle {
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
}
.sourceBrowseBar {
display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0;
}
/* ── Grid (tag + source results) ─────────────────────────────────────────── */
.tagGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 9vw, 120px), 1fr));
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
overflow-y: auto; flex: 1;
align-content: start;
scrollbar-width: thin;
}
/* In the grid, cards stretch to fill the column */
.tagGrid .card { width: auto; }
.tagGrid .skCard { width: auto; }
.tagGrid .skCover { width: 100%; }
/* ── NSFW badge ──────────────────────────────────────────────────────────── */
.nsfwBadge {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 1px 5px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); flex-shrink: 0;
}
+605 -101
View File
@@ -1,11 +1,19 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
import {
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import s from "./Search.module.css";
// ── Types ─────────────────────────────────────────────────────────────────────
type SearchTab = "keyword" | "tag" | "source";
interface SourceResult {
source: Source;
mangas: Manga[];
@@ -13,15 +21,30 @@ interface SourceResult {
error: string | null;
}
const CONCURRENCY = 3;
// ── Constants ─────────────────────────────────────────────────────────────────
const CONCURRENCY = 4;
const RESULTS_PER_SOURCE = 8;
const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
"Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports",
"Supernatural","Mecha","Historical","Psychological","School Life",
"Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts",
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
];
// ── Concurrent fetch helper ───────────────────────────────────────────────────
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
@@ -29,84 +52,280 @@ async function runConcurrent<T>(
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── Shared card ───────────────────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({
src, alt, className,
}: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img src={src} alt={alt} className={className}
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) {
return (
<button className={s.card} onClick={onClick}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
{manga.inLibrary && <span className={s.inLibBadge}>Saved</span>}
</div>
<p className={s.cardTitle}>{manga.title}</p>
</button>
);
}
function GridSkeleton({ count = 18 }: { count?: number }) {
return (
<div className={s.tagGrid}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={s.skCard} style={{ width: "auto" }}>
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
</div>
);
}
function RowSkeleton({ count = 4 }: { count?: number }) {
return (
<div className={s.sourceRow}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={s.skCard}>
<div className={["skeleton", s.skCover].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
</div>
);
}
// ── Root ──────────────────────────────────────────────────────────────────────
export default function Search() {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]);
const [allSources, setAllSources] = useState<Source[]>([]);
const [tab, setTab] = useState<SearchTab>("keyword");
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
const searchPrefill = useStore((st) => st.searchPrefill ?? "");
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const [activeLang, setActiveLang] = useState<string>("preferred");
const inputRef = useRef<HTMLInputElement>(null);
const setActiveManga = useStore((st) => st.setActiveManga);
const setNavPage = useStore((st) => st.setNavPage);
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
const pendingPrefill = useRef<string>("");
// Consume searchPrefill → route to keyword tab
useEffect(() => {
if (!searchPrefill) return;
pendingPrefill.current = searchPrefill;
setTab("keyword");
setSearchPrefill("");
}, [searchPrefill, setSearchPrefill]);
// Load sources once, shared across all tabs
useEffect(() => {
setLoadingSources(true);
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0")))
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
)
.then(setAllSources)
.catch(console.error)
.finally(() => setLoadingSources(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"];
const visibleSources = allSources.filter((src) => {
if (activeLang === "all") return true;
if (activeLang === "preferred") return src.lang === preferredLang;
return src.lang === activeLang;
});
const runSearch = useCallback(async () => {
const q = query.trim();
if (!q || !visibleSources.length) return;
setSubmitted(q);
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
await runConcurrent(visibleSources, async (src) => {
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: q,
});
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
));
} catch (e: any) {
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
));
}
});
}, [query, visibleSources]);
function openManga(m: Manga) {
setActiveManga(m);
setNavPage("library");
}
const hasResults = results.some((r) => r.mangas.length > 0);
const allDone = results.every((r) => !r.loading);
const availableLangs = useMemo(() =>
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
const hasMultipleLangs = availableLangs.length > 1;
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Search</h1>
<div className={s.tabs}>
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
<MagnifyingGlass size={11} weight="bold" /> Keyword
</button>
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
<Hash size={11} weight="bold" /> Tags
</button>
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
<List size={11} weight="bold" /> Sources
</button>
</div>
</div>
{tab === "keyword" && (
<KeywordTab
allSources={allSources}
loadingSources={loadingSources}
availableLangs={availableLangs}
hasMultipleLangs={hasMultipleLangs}
preferredLang={preferredLang}
pendingPrefill={pendingPrefill}
onMangaClick={setPreviewManga}
/>
)}
{tab === "tag" && (
<TagTab
allSources={allSources}
loadingSources={loadingSources}
preferredLang={preferredLang}
onMangaClick={setPreviewManga}
/>
)}
{tab === "source" && (
<SourceTab
allSources={allSources}
loadingSources={loadingSources}
availableLangs={availableLangs}
hasMultipleLangs={hasMultipleLangs}
onMangaClick={setPreviewManga}
/>
)}
</div>
);
}
// ── Keyword tab ───────────────────────────────────────────────────────────────
function KeywordTab({
allSources, loadingSources, availableLangs, hasMultipleLangs,
preferredLang, pendingPrefill, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
hasMultipleLangs: boolean;
preferredLang: string;
pendingPrefill: React.MutableRefObject<string>;
onMangaClick: (m: Manga) => void;
}) {
const [query, setQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [results, setResults] = useState<SourceResult[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
const [includeNsfw, setIncludeNsfw] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allSourcesRef = useRef<Source[]>([]);
const selectedLangsRef = useRef<Set<string>>(new Set());
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
// Set default lang selection once sources load
useEffect(() => {
if (!allSources.length) return;
const available = new Set(allSources.map((s) => s.lang));
setSelectedLangs(available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1))
);
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
// Consume prefill once sources are ready
useEffect(() => {
if (loadingSources || !pendingPrefill.current || submitted) return;
if (!allSourcesRef.current.length) return;
const q = pendingPrefill.current;
pendingPrefill.current = "";
setQuery(q);
doSearch(q);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadingSources]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
const getVisibleSources = useCallback((): Source[] => {
let filtered = allSourcesRef.current;
if (selectedLangsRef.current.size > 0)
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
if (!includeNsfw)
filtered = filtered.filter((s) => !s.isNsfw);
return filtered;
}, [includeNsfw]);
const doSearch = useCallback(async (q: string) => {
const trimmed = q.trim();
if (!trimmed) return;
const visible = getVisibleSources();
if (!visible.length) return;
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setSubmitted(trimmed);
setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
));
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
setResults((prev) => prev.map((r) =>
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
));
}
}, ctrl.signal);
}, [getVisibleSources]);
function toggleLang(lang: string) {
setSelectedLangs((prev) => {
const next = new Set(prev);
if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); }
else next.add(lang);
return next;
});
}
const visibleCount = getVisibleSources().length;
const hasResults = results.some((r) => r.mangas.length > 0);
const allDone = results.every((r) => !r.loading);
return (
<>
<div className={s.keywordBar}>
<div className={s.searchBar}>
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
<input
ref={inputRef}
ref={inputRef} autoFocus
className={s.searchInput}
placeholder="Search across sources…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && runSearch()}
autoFocus
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
/>
{hasMultipleLangs && (
<button
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
onClick={() => setShowAdvanced((v) => !v)}
title="Language & filter options"
>
<SlidersHorizontal size={13} weight="light" />
</button>
)}
<button
className={s.searchBtn}
onClick={runSearch}
onClick={() => doSearch(query)}
disabled={!query.trim() || loadingSources}
>
{loadingSources
@@ -114,20 +333,36 @@ export default function Search() {
: "Search"}
</button>
</div>
</div>
<div className={s.langBar}>
{langs.map((l) => (
<button
key={l}
onClick={() => setActiveLang(l)}
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
>
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
</button>
))}
{visibleSources.length > 0 && (
<span className={s.sourceCount}>{visibleSources.length} sources</span>
{hasMultipleLangs && showAdvanced && (
<div className={s.advancedPanel}>
<div className={s.advancedHeader}>
<span className={s.advancedTitle}>Languages</span>
<div className={s.advancedActions}>
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set(availableLangs))}>All</button>
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set([preferredLang]))}>Reset</button>
</div>
</div>
<div className={s.langGrid}>
{availableLangs.map((lang) => (
<button key={lang}
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
onClick={() => toggleLang(lang)}
>
{lang === preferredLang ? `${lang.toUpperCase()}` : lang.toUpperCase()}
</button>
))}
</div>
<div className={s.advancedDivider} />
<label className={s.advancedCheck}>
<input type="checkbox" checked={includeNsfw}
onChange={(e) => setIncludeNsfw(e.target.checked)} className={s.checkbox} />
Include NSFW sources
</label>
<div className={s.advancedFooter}>
Searching <strong>{visibleCount}</strong> source{visibleCount !== 1 ? "s" : ""}
</div>
</div>
)}
</div>
@@ -136,8 +371,15 @@ export default function Search() {
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>Search across sources</p>
<p className={s.emptyHint}>
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
{hasMultipleLangs
? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}`
: `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`}
</p>
{hasMultipleLangs && !showAdvanced && (
<button className={s.advancedLinkStandalone} onClick={() => setShowAdvanced(true)}>
<SlidersHorizontal size={12} weight="light" /> Adjust language filters
</button>
)}
</div>
)}
@@ -148,59 +390,321 @@ export default function Search() {
<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"; }}
/>
<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>
)}
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
{!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>
<RowSkeleton />
) : 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>
{mangas.slice(0, RESULTS_PER_SOURCE).map((m) => (
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
))}
</div>
) : null}
</div>
))}
{allDone && !hasResults && submitted && (
{allDone && !hasResults && (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{submitted}"</p>
</div>
)}
</div>
)}
</>
);
}
// ── Tag tab ───────────────────────────────────────────────────────────────────
function TagTab({
preferredLang, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
preferredLang: string;
onMangaClick: (m: Manga) => void;
}) {
const [activeTag, setActiveTag] = useState<string | null>(null);
const [tagResults, setTagResults] = useState<Manga[]>([]);
const [loadingTag, setLoadingTag] = useState(false);
const [tagFilter, setTagFilter] = useState("");
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => { abortRef.current?.abort(); }, []);
async function drillTag(tag: string) {
if (tag === activeTag && !loadingTag) return;
setActiveTag(tag);
setTagResults([]);
setLoadingTag(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const sources = await cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
);
const deduped = dedupeSources(sources, preferredLang);
const top = getTopSources(deduped);
const results = await cache.get(CACHE_KEYS.GENRE(tag), () =>
Promise.allSettled(
top.map((src) =>
gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: tag },
ctrl.signal,
).then((d) => d.fetchSourceManga.mangas)
)
).then((settled) => {
const merged: Manga[] = [];
for (const r of settled)
if (r.status === "fulfilled") merged.push(...r.value);
return dedupeMangaByTitle(merged);
})
);
if (!ctrl.signal.aborted) setTagResults(results);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) setLoadingTag(false);
}
}
const filteredGenres = useMemo(() => {
const q = tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
}, [tagFilter]);
return (
<div className={s.splitRoot}>
<div className={s.splitSidebar}>
<div className={s.splitSearchWrap}>
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
<input
className={s.splitSearchInput}
placeholder="Filter tags…"
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
/>
</div>
<div className={s.splitList}>
{filteredGenres.map((tag) => (
<button
key={tag}
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
onClick={() => drillTag(tag)}
>
{tag}
</button>
))}
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
</div>
</div>
<div className={s.splitContent}>
{!activeTag ? (
<div className={s.empty}>
<Hash size={32} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>Browse by tag</p>
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
</div>
) : (
<>
<div className={s.splitContentHeader}>
<span className={s.splitContentTitle}>{activeTag}</span>
{loadingTag
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
: <span className={s.splitResultCount}>{tagResults.length} results</span>}
</div>
{loadingTag ? (
<GridSkeleton />
) : tagResults.length > 0 ? (
<div className={s.tagGrid}>
{tagResults.map((m) => (
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
))}
</div>
) : (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{activeTag}"</p>
</div>
)}
</>
)}
</div>
</div>
);
}
// ── Source tab ────────────────────────────────────────────────────────────────
function SourceTab({
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
hasMultipleLangs: boolean;
onMangaClick: (m: Manga) => void;
}) {
const [selectedLang, setSelectedLang] = useState<string>("all");
const [activeSource, setActiveSource] = useState<Source | null>(null);
const [browseResults, setBrowseResults] = useState<Manga[]>([]);
const [loadingBrowse, setLoadingBrowse] = useState(false);
const [browseQuery, setBrowseQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => { abortRef.current?.abort(); }, []);
const visibleSources = useMemo(() =>
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
[allSources, selectedLang]
);
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingBrowse(true);
setBrowseResults([]);
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page: 1, query: q ?? null },
ctrl.signal,
);
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) setLoadingBrowse(false);
}
}
function selectSource(src: Source) {
setActiveSource(src);
setBrowseQuery("");
setSubmitted("");
fetchBrowse(src, "POPULAR");
}
function handleSearch() {
if (!activeSource || !browseQuery.trim()) return;
setSubmitted(browseQuery.trim());
fetchBrowse(activeSource, "SEARCH", browseQuery.trim());
}
function clearSearch() {
setBrowseQuery("");
setSubmitted("");
if (activeSource) fetchBrowse(activeSource, "POPULAR");
}
return (
<div className={s.splitRoot}>
<div className={s.splitSidebar}>
{hasMultipleLangs && (
<div className={s.langFilterRow}>
{["all", ...availableLangs].map((lang) => (
<button key={lang}
className={[s.langChip, selectedLang === lang ? s.langChipActive : ""].join(" ")}
onClick={() => setSelectedLang(lang)}
>
{lang === "all" ? "All" : lang.toUpperCase()}
</button>
))}
</div>
)}
{loadingSources ? (
<div className={s.splitLoading}>
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : (
<div className={s.splitList}>
{visibleSources.map((src) => (
<button key={src.id}
className={[s.splitItem, s.splitItemSource, activeSource?.id === src.id ? s.splitItemActive : ""].join(" ")}
onClick={() => selectSource(src)}
>
<img src={thumbUrl(src.iconUrl)} alt="" className={s.splitSourceIcon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span className={s.splitItemLabel}>{src.displayName}</span>
{src.isNsfw && <span className={s.nsfwBadge}>18+</span>}
</button>
))}
{visibleSources.length === 0 && <p className={s.splitEmpty}>No sources for this language</p>}
</div>
)}
</div>
<div className={s.splitContent}>
{!activeSource ? (
<div className={s.empty}>
<List size={32} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>Browse a source</p>
<p className={s.emptyHint}>Select a source to see its popular titles, or search within it.</p>
</div>
) : (
<>
<div className={s.splitContentHeader}>
<div className={s.splitSourceTitle}>
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
</div>
<div className={s.sourceBrowseBar}>
<div className={s.searchBar} style={{ flex: 1 }}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input
className={s.searchInput}
placeholder={`Search ${activeSource.displayName}`}
value={browseQuery}
onChange={(e) => setBrowseQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
{submitted && (
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
)}
</div>
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
Search
</button>
</div>
</div>
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
<div className={s.tagGrid}>
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
</div>
) : (
<div className={s.empty}>
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
</div>
)}
</>
)}
</div>
</div>
);
}
+23 -6
View File
@@ -724,6 +724,18 @@
pointer-events: none;
}
/* In-progress progress fill bar (width set inline) */
.gridCellProgress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: var(--accent);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
pointer-events: none;
z-index: 2;
}
/* In-progress — accent highlight on bottom edge */
.gridCellInProgress {
border-color: var(--accent-dim);
@@ -933,18 +945,23 @@
align-items: center;
gap: var(--sp-2);
width: 100%;
margin-top: var(--sp-2);
padding: 7px var(--sp-3);
padding: 6px var(--sp-2);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-error);
letter-spacing: var(--tracking-wide);
color: var(--text-faint);
background: none;
border: 1px solid var(--color-error);
border: 1px solid var(--border-dim);
cursor: pointer;
text-align: left;
transition: background var(--t-base);
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.deleteAllBtn:hover:not(:disabled) {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
}
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
+224 -86
View File
@@ -11,6 +11,7 @@ import {
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import MigrateModal from "./MigrateModal";
@@ -35,6 +36,18 @@ interface CtxState {
const CHAPTERS_PER_PAGE = 25;
// How long before we consider a manga detail / chapter list stale and silently re-fetch.
// This prevents hammering the server when rapidly opening/closing while still keeping
// data fresh enough for normal use.
const MANGA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — detail rarely changes mid-session
const CHAPTER_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — chapters update more often
// ── TTL-aware memory stores (cleared on page refresh, not persisted) ──────────
// These supplement the session `cache` with timestamp tracking so we know when
// to silently re-validate in the background.
const mangaDetailStore = new Map<number, { data: Manga; fetchedAt: number }>();
const chapterStore = new Map<number, { data: Chapter[]; fetchedAt: number }>();
// ── Download dropdown ─────────────────────────────────────────────────────────
interface DownloadDropdownProps {
@@ -93,7 +106,6 @@ function DownloadDropdown({
return (
<div className={s.dlDropdown} ref={ref}>
{continueChapter && continueIdx >= 0 && (
<>
<p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
@@ -289,10 +301,9 @@ export default function SeriesDetail() {
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 setGenreFilter = useStore((state) => state.setGenreFilter);
const [manga, setManga] = useState<Manga | null>(activeManga);
const [manga, setManga] = useState<Manga | null>(null);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [loadingManga, setLoadingManga] = useState(false);
const [loadingChapters, setLoadingChapters] = useState(true);
@@ -311,47 +322,140 @@ export default function SeriesDetail() {
const [descExpanded, setDescExpanded] = useState(false);
const [genresExpanded, setGenresExpanded] = useState(false);
// Track the abort controllers for in-flight requests so we can cancel on unmount/change
// Manga detail and chapters each get their own controller so they don't clobber each other
const mangaAbortRef = useRef<AbortController | null>(null);
const chapterAbortRef = useRef<AbortController | null>(null);
// Track the manga ID we're currently loading to discard stale results
const loadingForRef = useRef<number | null>(null);
const sortDir = settings.chapterSortDir;
// Load extended manga details
// ── Manga detail: serve from TTL cache, silently re-validate if stale ──────
useEffect(() => {
if (!activeManga) return;
const mangaId = activeManga.id;
// Cancel any in-flight manga detail request from a previous manga
mangaAbortRef.current?.abort();
const ctrl = new AbortController();
mangaAbortRef.current = ctrl;
loadingForRef.current = mangaId;
const cached = mangaDetailStore.get(mangaId);
const now = Date.now();
if (cached) {
// Serve from memory immediately — no loading state, no flash
setManga(cached.data);
setLoadingManga(false);
// If cache is fresh enough, skip the network entirely
if (now - cached.fetchedAt < MANGA_CACHE_TTL_MS) return;
// Stale: re-validate silently in the background (no spinner)
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
.then((data) => {
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
setManga(data.manga);
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
return;
}
// Nothing cached — show skeleton and fetch
setLoadingManga(true);
gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id })
.then((data) => setManga(data.manga))
.catch(console.error)
.finally(() => setLoadingManga(false));
gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal)
.then((data) => {
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() });
setManga(data.manga);
if (data.manga.source?.id) recordSourceAccess(data.manga.source.id);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => {
if (!ctrl.signal.aborted && loadingForRef.current === mangaId) setLoadingManga(false);
});
return () => { ctrl.abort(); mangaAbortRef.current = null; };
}, [activeManga?.id]);
const loadChapters = useCallback((mangaId: number) => {
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
.then((data) => {
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
setChapters(sorted);
return sorted;
});
// ── Chapter loading: cache-first, background refresh only when stale ────────
const applyChapters = useCallback((nodes: Chapter[]) => {
const sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
setChapters(sorted);
return sorted;
}, []);
// Load chapters: show cache immediately, then silently refresh from source
useEffect(() => {
if (!activeManga) return;
setLoadingChapters(true);
setChapters([]);
const mangaId = activeManga.id;
setChapterPage(1);
loadChapters(activeManga.id)
.then((cached) =>
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => loadChapters(activeManga.id))
// Cancel any previous in-flight chapter requests
chapterAbortRef.current?.abort();
const ctrl = new AbortController();
chapterAbortRef.current = ctrl;
loadingForRef.current = mangaId;
const cached = chapterStore.get(mangaId);
const now = Date.now();
if (cached) {
// Show cached data instantly
applyChapters(cached.data);
setLoadingChapters(false);
// Fresh enough — don't touch the network at all
if (now - cached.fetchedAt < CHAPTER_CACHE_TTL_MS) return;
// Stale — silently re-validate: fetch from source then re-read local DB
// We don't clear the chapter list while this happens (no flicker)
gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
.then((data) => {
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
applyChapters(data.chapters.nodes);
})
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
return;
}
// Nothing cached — show skeleton, load local DB first (fast), then source
setChapters([]);
setLoadingChapters(true);
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)
.then((data) => {
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
// Show local DB result immediately so the user isn't staring at a spinner
applyChapters(data.chapters.nodes);
setLoadingChapters(false);
// Now silently fetch from the source to pick up any new chapters
return gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal))
.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));
}, [activeManga?.id]);
if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return;
chapterStore.set(mangaId, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
applyChapters(fresh.chapters.nodes);
});
})
.catch((e) => {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
console.error(e);
setLoadingChapters(false);
});
return () => { ctrl.abort(); chapterAbortRef.current = null; };
}, [activeManga?.id, applyChapters]);
// ── Derived state ──────────────────────────────────────────────────────────
@@ -388,17 +492,32 @@ export default function SeriesDetail() {
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);
const updated = { ...manga, inLibrary: next };
setManga(updated);
// Update the detail cache so re-open reflects the new state
if (mangaDetailStore.has(manga.id)) {
const entry = mangaDetailStore.get(manga.id)!;
mangaDetailStore.set(manga.id, { ...entry, data: updated });
}
cache.clear(CACHE_KEYS.LIBRARY);
setTogglingLibrary(false);
}
const reloadChapters = useCallback((mangaId: number, signal?: AbortSignal) => {
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, signal)
.then((data) => {
chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() });
applyChapters(data.chapters.nodes);
});
}, [applyChapters]);
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);
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);
if (activeManga) reloadChapters(activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
@@ -409,18 +528,27 @@ export default function SeriesDetail() {
title: "Download queued",
body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`,
});
if (activeManga) loadChapters(activeManga.id);
if (activeManga) reloadChapters(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));
setChapters((prev) => {
const updated = prev.map((c) => c.id === chapterId ? { ...c, isRead } : c);
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
return updated;
});
}
async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return;
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead } : c));
setChapters((prev) => {
const idSet = new Set(ids);
const updated = prev.map((c) => idSet.has(c.id) ? { ...c, isRead } : c);
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
return updated;
});
}
const markAllAboveRead = (i: number) =>
@@ -434,7 +562,11 @@ export default function SeriesDetail() {
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));
setChapters((prev) => {
const updated = prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c);
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
return updated;
});
}
async function deleteAllDownloads() {
@@ -442,21 +574,26 @@ export default function SeriesDetail() {
if (!ids.length) return;
setDeletingAll(true);
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
setChapters((prev) => {
const updated = prev.map((c) => ({ ...c, isDownloaded: false }));
if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() });
return updated;
});
setDeletingAll(false);
}
async function refreshChapters() {
if (!activeManga || refreshing) return;
setRefreshing(true);
// Force-invalidate the chapter cache for this manga so we get a fresh fetch
chapterStore.delete(activeManga.id);
await gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => loadChapters(activeManga.id))
.then(() => reloadChapters(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 });
@@ -555,7 +692,7 @@ export default function SeriesDetail() {
<div className={s.sidebar}>
<button className={s.back} onClick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" />
<span>Library</span>
<span>Back</span>
</button>
<div className={s.coverWrap}>
@@ -596,11 +733,7 @@ export default function SeriesDetail() {
key={g}
className={[s.genre, s.genreClickable].join(" ")}
title={`Filter library by "${g}"`}
onClick={() => {
setLibraryTagFilter([g]);
setLibraryFilter("library");
setActiveManga(null);
}}
onClick={() => setGenreFilter(g)}
>
{g}
</button>
@@ -682,36 +815,20 @@ export default function SeriesDetail() {
{readCount > 0 && ` · ${readCount} read`}
</p>
{/* 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) */}
{/* Source info — collapsible details */}
{!loadingManga && manga?.source && (
<div className={s.detailsSection}>
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
<span>Details</span>
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
<CaretDown
size={11}
weight="light"
style={{
transform: detailsOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.15s ease",
flexShrink: 0,
}}
/>
</button>
{detailsOpen && (
<div className={s.detailsBody}>
@@ -719,14 +836,32 @@ export default function SeriesDetail() {
<span className={s.detailKey}>Source</span>
<span className={s.detailVal}>{manga.source.displayName}</span>
</div>
<div className={s.detailRow}>
<span className={s.detailKey}>Language</span>
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
</div>
<div className={s.detailRow}>
<span className={s.detailKey}>Source ID</span>
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
</div>
{manga.status && (
<div className={s.detailRow}>
<span className={s.detailKey}>Status</span>
<span className={s.detailVal}>
{manga.status.charAt(0) + manga.status.slice(1).toLowerCase()}
</span>
</div>
)}
{manga.author && (
<div className={s.detailRow}>
<span className={s.detailKey}>Author</span>
<span className={s.detailVal}>{manga.author}</span>
</div>
)}
{manga.artist && manga.artist !== manga.author && (
<div className={s.detailRow}>
<span className={s.detailKey}>Artist</span>
<span className={s.detailVal}>{manga.artist}</span>
</div>
)}
{totalCount > 0 && (
<div className={s.detailRow}>
<span className={s.detailKey}>Progress</span>
<span className={s.detailVal}>{readCount} / {totalCount} read</span>
</div>
)}
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
<ArrowsClockwise size={12} weight="light" />
Switch source
@@ -738,13 +873,20 @@ export default function SeriesDetail() {
disabled={deletingAll}
>
<Trash size={12} weight="light" />
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
{deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
</button>
)}
</div>
)}
</div>
)}
{manga && !manga.source && (
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
<ArrowsClockwise size={12} weight="light" />
Switch source
</button>
)}
</div>
{/* ── Chapter list ── */}
@@ -761,8 +903,7 @@ export default function SeriesDetail() {
>
{sortDir === "desc"
? <SortDescending size={14} weight="light" />
: <SortAscending size={14} weight="light" />
}
: <SortAscending size={14} weight="light" />}
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
@@ -916,7 +1057,6 @@ export default function SeriesDetail() {
pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
return (
// div instead of button so the nested download/delete buttons are valid HTML
<div
key={ch.id}
role="button"
@@ -941,11 +1081,9 @@ export default function SeriesDetail() {
{ch.isBookmarked && (
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
)}
{/* Read indicator — always shown when read */}
{ch.isRead && (
<CheckCircle size={14} weight="light" className={s.readIcon} />
)}
{/* Download / status indicator — independent of read state */}
{ch.isDownloaded ? (
<button
className={s.dlBtn}