mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Added Explore Feature + Frecency Based Reccomendations
This commit is contained in:
@@ -89,7 +89,7 @@
|
|||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = frontendSrc;
|
src = frontendSrc;
|
||||||
fetcherVersion = 1;
|
fetcherVersion = 1;
|
||||||
hash = "sha256-2Hdzsjwbb+CKiRn/nGHwLeysKvpvEhd5C213YgWmOSU=";
|
hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = "pnpm build";
|
buildPhase = "pnpm build";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "~2",
|
"@tauri-apps/plugin-shell": "~2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+20
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
specifier: ^1.2.8
|
||||||
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.18
|
||||||
|
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
@@ -837,6 +840,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.18':
|
||||||
|
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.18':
|
||||||
|
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1':
|
'@tauri-apps/api@2.10.1':
|
||||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||||
|
|
||||||
@@ -2107,6 +2119,14 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.13.18
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.18': {}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1': {}
|
'@tauri-apps/api@2.10.1': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
'@tauri-apps/cli-darwin-arm64@2.10.0':
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Library from "../pages/Library";
|
|||||||
import SeriesDetail from "../pages/SeriesDetail";
|
import SeriesDetail from "../pages/SeriesDetail";
|
||||||
import History from "../pages/History";
|
import History from "../pages/History";
|
||||||
import Search from "../pages/Search";
|
import Search from "../pages/Search";
|
||||||
import SourceList from "../sources/SourceList";
|
import Explore from "../sources/Explore";
|
||||||
import SourceBrowse from "../sources/SourceBrowse";
|
|
||||||
import DownloadQueue from "../downloads/DownloadQueue";
|
import DownloadQueue from "../downloads/DownloadQueue";
|
||||||
import ExtensionList from "../extensions/ExtensionList";
|
import ExtensionList from "../extensions/ExtensionList";
|
||||||
import s from "./Layout.module.css";
|
import s from "./Layout.module.css";
|
||||||
@@ -13,16 +12,15 @@ import s from "./Layout.module.css";
|
|||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const navPage = useStore((s) => s.navPage);
|
const navPage = useStore((s) => s.navPage);
|
||||||
const activeManga = useStore((s) => s.activeManga);
|
const activeManga = useStore((s) => s.activeManga);
|
||||||
const activeSource = useStore((s) => s.activeSource);
|
|
||||||
|
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
if (navPage === "library" && activeManga) return <SeriesDetail />;
|
||||||
if (navPage === "sources" && activeSource) return <SourceBrowse />;
|
|
||||||
switch (navPage) {
|
switch (navPage) {
|
||||||
case "library": return <Library />;
|
case "library": return <Library />;
|
||||||
case "search": return <Search />;
|
case "search": return <Search />;
|
||||||
case "history": return <History />;
|
case "history": return <History />;
|
||||||
case "sources": return <SourceList />;
|
case "sources": return <Explore />;
|
||||||
|
case "explore": return <Explore />;
|
||||||
case "downloads": return <DownloadQueue />;
|
case "downloads": return <DownloadQueue />;
|
||||||
case "extensions": return <ExtensionList />;
|
case "extensions": return <ExtensionList />;
|
||||||
default: return <Library />;
|
default: return <Library />;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
|||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
{ id: "explore", icon: <Compass size={18} weight="light" />, label: "Explore" },
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||||
];
|
];
|
||||||
@@ -24,7 +24,7 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
setNavPage(id);
|
setNavPage(id);
|
||||||
if (id !== "sources") setActiveSource(null);
|
if (id !== "explore") setActiveSource(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
|
|||||||
@@ -107,22 +107,32 @@
|
|||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
/* Grid */
|
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
||||||
.grid {
|
.virtualRow {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: var(--sp-4);
|
gap: var(--sp-4);
|
||||||
/* Contain stacking contexts for GPU layers */
|
padding: 0 var(--sp-6);
|
||||||
contain: layout style;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Individual card fills its flex slot */
|
||||||
.card {
|
.card {
|
||||||
|
flex: 1 1 130px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 200px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
/* Promote to own GPU layer on hover only */
|
}
|
||||||
|
|
||||||
|
.ghostCard {
|
||||||
|
flex: 1 1 130px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 200px;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
.card:hover .cover { filter: brightness(1.06); }
|
||||||
@@ -177,38 +187,12 @@
|
|||||||
transition: color var(--t-base);
|
transition: color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show more */
|
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
||||||
.showMore {
|
.grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
padding: var(--sp-6) 0 var(--sp-4);
|
gap: var(--sp-4);
|
||||||
}
|
padding: var(--sp-4) var(--sp-6) 0;
|
||||||
|
|
||||||
.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 */
|
/* Skeleton */
|
||||||
@@ -225,6 +209,14 @@
|
|||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost cards fill trailing grid space without taking interaction */
|
||||||
|
.ghostCard {
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+113
-107
@@ -1,16 +1,18 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react";
|
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import s from "./Library.module.css";
|
import s from "./Library.module.css";
|
||||||
|
|
||||||
const INITIAL_PAGE_SIZE = 48;
|
// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap
|
||||||
const PAGE_INCREMENT = 48;
|
const CARD_MIN_W = 130;
|
||||||
|
const CARD_GAP = 16;
|
||||||
|
const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer
|
||||||
|
|
||||||
// Memoized card to prevent re-renders when siblings change
|
|
||||||
const MangaCard = memo(function MangaCard({
|
const MangaCard = memo(function MangaCard({
|
||||||
manga,
|
manga,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -43,78 +45,89 @@ const MangaCard = memo(function MangaCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Library() {
|
export default function Library() {
|
||||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
const settings = useStore((state) => state.settings);
|
const settings = useStore((state) => state.settings);
|
||||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||||
const folders = useStore((state) => state.settings.folders);
|
const folders = useStore((state) => state.settings.folders);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
.then((lib) => setAllManga(lib.mangas.nodes))
|
||||||
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))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
|
// Reset scroll when filter/search changes
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: 0 });
|
||||||
|
}, [libraryFilter, search]);
|
||||||
|
|
||||||
// Reset filter if the active folder tab gets hidden
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||||
if (activeFolder && !activeFolder.showTab) {
|
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||||
setLibraryFilter("library");
|
|
||||||
}
|
|
||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let items = allManga;
|
let items = allManga;
|
||||||
|
|
||||||
if (libraryFilter === "library") {
|
if (libraryFilter === "library") {
|
||||||
items = items.filter((m) => m.inLibrary);
|
items = items.filter((m) => m.inLibrary);
|
||||||
} else if (libraryFilter === "downloaded") {
|
} else if (libraryFilter === "downloaded") {
|
||||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||||
} else if (!isBuiltinFilter) {
|
} else if (!isBuiltinFilter) {
|
||||||
// folder filter
|
|
||||||
const folder = folders.find((f) => f.id === libraryFilter);
|
const folder = folders.find((f) => f.id === libraryFilter);
|
||||||
if (folder) {
|
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||||
items = items.filter((m) => folder.mangaIds.includes(m.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (libraryTagFilter.length > 0)
|
||||||
// tag filter only applies to library/all/folder views
|
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||||
if (libraryTagFilter.length > 0) {
|
|
||||||
items = items.filter((m) =>
|
|
||||||
libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||||
|
|
||||||
const visible = filtered.slice(0, visibleCount);
|
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||||
const hasMore = visibleCount < filtered.length;
|
// We need to know columns to chunk filtered into rows.
|
||||||
|
// Use a ResizeObserver on the scroll container to get real width.
|
||||||
|
const [containerWidth, setContainerWidth] = useState(800);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
setContainerWidth(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const result: Manga[][] = [];
|
||||||
|
for (let i = 0; i < filtered.length; i += cols)
|
||||||
|
result.push(filtered.slice(i, i + cols));
|
||||||
|
return result;
|
||||||
|
}, [filtered, cols]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 3,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(m: Manga) => () => setActiveManga(m),
|
(m: Manga) => () => setActiveManga(m),
|
||||||
@@ -129,34 +142,23 @@ export default function Library() {
|
|||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||||
const downloadedIds = data.chapters.nodes
|
const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id);
|
||||||
.filter((c) => c.isDownloaded)
|
if (!ids.length) return;
|
||||||
.map((c) => c.id);
|
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||||
if (!downloadedIds.length) return;
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: downloadedIds });
|
} catch (e) { console.error(e); }
|
||||||
setAllManga((prev) =>
|
|
||||||
prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const menuW = 200;
|
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||||
const menuH = 160;
|
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||||
const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
|
|
||||||
const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
|
|
||||||
setCtx({ x, y, manga: m });
|
setCtx({ x, y, manga: m });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{
|
{ label: "Open", onClick: () => setActiveManga(m) },
|
||||||
label: "Open",
|
|
||||||
onClick: () => setActiveManga(m),
|
|
||||||
},
|
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||||
@@ -189,9 +191,7 @@ export default function Library() {
|
|||||||
library: allManga.filter((m) => m.inLibrary).length,
|
library: allManga.filter((m) => m.inLibrary).length,
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||||
};
|
};
|
||||||
folders.forEach((f) => {
|
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||||
result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length;
|
|
||||||
});
|
|
||||||
return result;
|
return result;
|
||||||
}, [allManga, folders]);
|
}, [allManga, folders]);
|
||||||
|
|
||||||
@@ -203,12 +203,11 @@ export default function Library() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root} ref={scrollRef}>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<div className={s.headerLeft}>
|
<div className={s.headerLeft}>
|
||||||
<h1 className={s.heading}>Library</h1>
|
<h1 className={s.heading}>Library</h1>
|
||||||
<div className={s.tabs}>
|
<div className={s.tabs}>
|
||||||
{/* Built-in tabs */}
|
|
||||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
@@ -219,13 +218,10 @@ export default function Library() {
|
|||||||
<><Books size={11} weight="bold" /> Saved</>
|
<><Books size={11} weight="bold" /> Saved</>
|
||||||
) : f === "downloaded" ? (
|
) : f === "downloaded" ? (
|
||||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||||
) : (
|
) : <>All</>}
|
||||||
<>All</>
|
|
||||||
)}
|
|
||||||
<span className={s.tabCount}>{counts[f]}</span>
|
<span className={s.tabCount}>{counts[f]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* Folder tabs — only shown if the folder has showTab enabled */}
|
|
||||||
{folders.filter((f) => f.showTab).map((folder) => (
|
{folders.filter((f) => f.showTab).map((folder) => (
|
||||||
<button
|
<button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
@@ -250,13 +246,11 @@ export default function Library() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag filter panel */}
|
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<div className={s.tagPanel}>
|
<div className={s.tagPanel}>
|
||||||
{libraryTagFilter.length > 0 && (
|
{libraryTagFilter.length > 0 && (
|
||||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||||
<X size={11} weight="bold" />
|
<X size={11} weight="bold" /> Clear
|
||||||
Clear
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{allTags.map((tag) => {
|
{allTags.map((tag) => {
|
||||||
@@ -264,13 +258,9 @@ export default function Library() {
|
|||||||
return (
|
return (
|
||||||
<button key={tag}
|
<button key={tag}
|
||||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||||
onClick={() =>
|
onClick={() => setLibraryTagFilter(
|
||||||
setLibraryTagFilter(
|
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
||||||
active
|
)}>
|
||||||
? libraryTagFilter.filter((t) => t !== tag)
|
|
||||||
: [...libraryTagFilter, tag]
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -298,31 +288,47 @@ export default function Library() {
|
|||||||
: "No manga found."}
|
: "No manga found."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
/* Virtual scroll container */
|
||||||
<div className={s.grid}>
|
<div
|
||||||
{visible.map((m) => (
|
style={{
|
||||||
<MangaCard
|
height: virtualizer.getTotalSize(),
|
||||||
key={m.id}
|
position: "relative",
|
||||||
manga={m}
|
}}
|
||||||
onClick={handleCardClick(m)}
|
>
|
||||||
onContextMenu={(e) => openCtx(e, m)}
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
cropCovers={settings.libraryCropCovers}
|
const rowManga = rows[virtualRow.index];
|
||||||
/>
|
return (
|
||||||
))}
|
<div
|
||||||
</div>
|
key={virtualRow.key}
|
||||||
{hasMore && (
|
style={{
|
||||||
<div className={s.showMore}>
|
position: "absolute",
|
||||||
<button
|
top: virtualRow.start,
|
||||||
className={s.showMoreBtn}
|
left: 0,
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_INCREMENT)}
|
right: 0,
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
className={s.virtualRow}
|
||||||
>
|
>
|
||||||
Show more
|
{rowManga.map((m) => (
|
||||||
<span className={s.showMoreCount}>{filtered.length - visibleCount} remaining</span>
|
<MangaCard
|
||||||
</button>
|
key={m.id}
|
||||||
</div>
|
manga={m}
|
||||||
)}
|
onClick={handleCardClick(m)}
|
||||||
</>
|
onContextMenu={(e) => openCtx(e, m)}
|
||||||
|
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 />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctx && (
|
{ctx && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={ctx.x}
|
x={ctx.x}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header / Tab switcher ───────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Source picker */
|
||||||
|
.sourcePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcePickerLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceSelect:focus { border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
/* ── Scrollable body ─────────────────────────────────────────────────────── */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-5) 0 var(--sp-6);
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section ─────────────────────────────────────────────────────────────── */
|
||||||
|
.section {
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-6) var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitleIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seeAll:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
/* ── Horizontal scroll row ───────────────────────────────────────────────── */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* ── Card (shared by all rows) ───────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
will-change: filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inLibraryBadge {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
border-radius: 0 2px 0 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost card — invisible placeholder to fill row trailing space */
|
||||||
|
.ghostCard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 110px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
|
||||||
|
.skeletonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSkeleton { flex-shrink: 0; width: 110px; }
|
||||||
|
|
||||||
|
.coverSkeleton {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleSkeleton {
|
||||||
|
height: 11px;
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Genre drill-down grid ───────────────────────────────────────────────── */
|
||||||
|
.drillRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.14s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.drillTitle {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 14vw, 140px), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
align-content: start;
|
||||||
|
will-change: scroll-position;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drillCard:hover .cover { filter: brightness(1.06); }
|
||||||
|
.drillCard:hover .title { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-8) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── No source state ─────────────────────────────────────────────────────── */
|
||||||
|
.noSource {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import { useEffect, useState, useMemo, memo } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire } from "@phosphor-icons/react";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import SourceList from "./SourceList";
|
||||||
|
import SourceBrowse from "./SourceBrowse";
|
||||||
|
import s from "./Explore.module.css";
|
||||||
|
|
||||||
|
// ── Frecency ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function frecencyScore(readAt: number, count: number): number {
|
||||||
|
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||||
|
return count / Math.log(hoursSince + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ghost card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GhostCard() {
|
||||||
|
return <div className={s.ghostCard} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GHOST_COUNT = 3;
|
||||||
|
|
||||||
|
// ── Skeleton row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={s.skeletonRow}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={s.cardSkeleton}>
|
||||||
|
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||||
|
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mini card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MiniCard = memo(function MiniCard({
|
||||||
|
manga,
|
||||||
|
onClick,
|
||||||
|
subtitle,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
manga: Manga;
|
||||||
|
onClick: () => void;
|
||||||
|
subtitle?: string;
|
||||||
|
progress?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={s.card} onClick={onClick}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
|
alt={manga.title}
|
||||||
|
className={s.cover}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
{progress !== undefined && progress > 0 && (
|
||||||
|
<div className={s.progressBar}>
|
||||||
|
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={s.title}>{manga.title}</p>
|
||||||
|
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Genre drill-down ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GenreDrill({
|
||||||
|
genre,
|
||||||
|
manga,
|
||||||
|
sourceManga,
|
||||||
|
onBack,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
genre: string;
|
||||||
|
manga: Manga[];
|
||||||
|
sourceManga: Manga[];
|
||||||
|
onBack: () => void;
|
||||||
|
onOpen: (m: Manga) => void;
|
||||||
|
}) {
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const combined = new Map<number, Manga>();
|
||||||
|
[...manga, ...sourceManga]
|
||||||
|
.filter((m) => (m.genre ?? []).includes(genre))
|
||||||
|
.forEach((m) => combined.set(m.id, m));
|
||||||
|
return Array.from(combined.values());
|
||||||
|
}, [manga, sourceManga, genre]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.drillRoot}>
|
||||||
|
<div className={s.drillHeader}>
|
||||||
|
<button className={s.back} onClick={onBack}>
|
||||||
|
<ArrowLeft size={13} weight="light" />
|
||||||
|
<span>Explore</span>
|
||||||
|
</button>
|
||||||
|
<span className={s.drillTitle}>{genre}</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.drillGrid}>
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)}>
|
||||||
|
<div className={s.coverWrap}>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(m.thumbnailUrl)}
|
||||||
|
alt={m.title}
|
||||||
|
className={s.cover}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||||
|
</div>
|
||||||
|
<p className={s.title}>{m.title}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className={s.empty}>No manga found for {genre}.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onSeeAll,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onSeeAll?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.section}>
|
||||||
|
<div className={s.sectionHeader}>
|
||||||
|
<span className={s.sectionTitle}>
|
||||||
|
<span className={s.sectionTitleIcon}>
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{onSeeAll && (
|
||||||
|
<button className={s.seeAll} onClick={onSeeAll}>
|
||||||
|
See all <ArrowRight size={11} weight="light" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? <SkeletonRow /> : children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ExploreMode = "explore" | "sources";
|
||||||
|
type DrillState = { type: "genre"; genre: string } | null;
|
||||||
|
|
||||||
|
export default function Explore() {
|
||||||
|
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||||
|
const [drill, setDrill] = useState<DrillState>(null);
|
||||||
|
const activeSource = useStore((s) => s.activeSource);
|
||||||
|
|
||||||
|
if (activeSource) return <SourceBrowse />;
|
||||||
|
|
||||||
|
if (drill?.type === "genre" && mode === "explore") {
|
||||||
|
return <DrillWrapper drill={drill} onBack={() => setDrill(null)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.root}>
|
||||||
|
<div className={s.header}>
|
||||||
|
<div className={s.headerLeft}>
|
||||||
|
<h1 className={s.heading}>Explore</h1>
|
||||||
|
<div className={s.tabs}>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("explore")}
|
||||||
|
>
|
||||||
|
<Compass size={11} weight="bold" />
|
||||||
|
Explore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => setMode("sources")}
|
||||||
|
>
|
||||||
|
<List size={11} weight="bold" />
|
||||||
|
Sources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "explore" ? <ExploreFeed onDrill={setDrill} /> : <SourceList />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drill wrapper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
|
||||||
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
|
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||||
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
|
const setNavPage = useStore((s) => s.setNavPage);
|
||||||
|
const settings = useStore((s) => s.settings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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(console.error);
|
||||||
|
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => {
|
||||||
|
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of all) {
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find((s) => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
return Promise.allSettled(
|
||||||
|
picked.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||||
|
}).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then((results) => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled")
|
||||||
|
for (const m of r.value)
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||||
|
}
|
||||||
|
setSourceManga(merged);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!drill) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenreDrill
|
||||||
|
genre={drill.genre}
|
||||||
|
manga={allManga}
|
||||||
|
sourceManga={sourceManga}
|
||||||
|
onBack={onBack}
|
||||||
|
onOpen={(m) => { setActiveManga(m); setNavPage("library"); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
||||||
|
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingLib, setLoadingLib] = useState(true);
|
||||||
|
// Popular row: deduped results from POPULAR fetch across all sources
|
||||||
|
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||||
|
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||||
|
// Genre search results: genre → merged Manga[] from SEARCH per source
|
||||||
|
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||||
|
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||||
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
|
|
||||||
|
const history = useStore((s) => s.history);
|
||||||
|
const settings = useStore((s) => s.settings);
|
||||||
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
|
const setNavPage = useStore((s) => s.setNavPage);
|
||||||
|
|
||||||
|
// Load library
|
||||||
|
useEffect(() => {
|
||||||
|
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(console.error)
|
||||||
|
.finally(() => setLoadingLib(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load sources → fetch POPULAR from all (for popular row),
|
||||||
|
// then once we know frecency genres, fire SEARCH per genre per source
|
||||||
|
useEffect(() => {
|
||||||
|
const preferredLang = settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => {
|
||||||
|
const all = d.sources.nodes.filter((src) => src.id !== "0");
|
||||||
|
|
||||||
|
// Dedupe by name, pick preferred lang
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of all) {
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find((s) => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSources(picked);
|
||||||
|
if (picked.length === 0) { setLoadingPopular(false); return; }
|
||||||
|
|
||||||
|
// Fetch POPULAR from all sources for the popular row
|
||||||
|
return Promise.allSettled(
|
||||||
|
picked.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "POPULAR", page: 1, query: null,
|
||||||
|
}).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled")
|
||||||
|
for (const m of r.value)
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||||
|
setPopularManga(merged.slice(0, 30));
|
||||||
|
// Return picked sources for genre search phase
|
||||||
|
return picked;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoadingPopular(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Once library loaded AND sources ready, search each frecency genre across sources
|
||||||
|
const frecencyGenres = useMemo(() => {
|
||||||
|
const mangaScores = new Map<number, number>();
|
||||||
|
const mangaReadAt = new Map<number, number>();
|
||||||
|
for (const entry of history) {
|
||||||
|
mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1);
|
||||||
|
if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0))
|
||||||
|
mangaReadAt.set(entry.mangaId, entry.readAt);
|
||||||
|
}
|
||||||
|
const genreWeights = new Map<string, number>();
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
for (const [mangaId, count] of mangaScores.entries()) {
|
||||||
|
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
||||||
|
for (const genre of mangaMap.get(mangaId)?.genre ?? [])
|
||||||
|
genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score);
|
||||||
|
}
|
||||||
|
if (genreWeights.size === 0) {
|
||||||
|
allManga.filter((m) => m.inLibrary).forEach((m) =>
|
||||||
|
(m.genre ?? []).forEach((g) =>
|
||||||
|
genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
||||||
|
}
|
||||||
|
return Array.from(genreWeights.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3) // top 3 genres only
|
||||||
|
.map(([g]) => g);
|
||||||
|
}, [allManga, history]);
|
||||||
|
|
||||||
|
// Fire genre searches once we have both genres and sources
|
||||||
|
useEffect(() => {
|
||||||
|
if (frecencyGenres.length === 0 || sources.length === 0) return;
|
||||||
|
setLoadingGenres(true);
|
||||||
|
|
||||||
|
// For each genre, search all sources concurrently, then merge results
|
||||||
|
// Cap to top 3 sources to limit requests (3 genres × 3 sources = 9 searches max)
|
||||||
|
const searchSources = sources.slice(0, 3);
|
||||||
|
|
||||||
|
Promise.allSettled(
|
||||||
|
frecencyGenres.map((genre) =>
|
||||||
|
Promise.allSettled(
|
||||||
|
searchSources.map((src) =>
|
||||||
|
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "SEARCH", page: 1, query: genre,
|
||||||
|
}).then((d) => d.fetchSourceManga.mangas)
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const merged: Manga[] = [];
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled")
|
||||||
|
for (const m of r.value)
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
||||||
|
return { genre, mangas: merged.slice(0, 24) };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const map = new Map<string, Manga[]>();
|
||||||
|
for (const r of results)
|
||||||
|
if (r.status === "fulfilled")
|
||||||
|
map.set(r.value.genre, r.value.mangas);
|
||||||
|
setGenreResults(map);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoadingGenres(false));
|
||||||
|
}, [frecencyGenres.join(","), sources.map((s) => s.id).join(",")]);
|
||||||
|
|
||||||
|
function openManga(m: Manga) {
|
||||||
|
setActiveManga(m);
|
||||||
|
setNavPage("library");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Continue reading ────────────────────────────────────────────────────
|
||||||
|
const continueReading = useMemo(() => {
|
||||||
|
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
||||||
|
for (const entry of history) {
|
||||||
|
if (seen.has(entry.mangaId)) continue;
|
||||||
|
seen.add(entry.mangaId);
|
||||||
|
const manga = mangaMap.get(entry.mangaId);
|
||||||
|
if (!manga) continue;
|
||||||
|
result.push({
|
||||||
|
manga,
|
||||||
|
chapterName: entry.chapterName,
|
||||||
|
progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0,
|
||||||
|
});
|
||||||
|
if (result.length >= 12) break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [history, allManga]);
|
||||||
|
|
||||||
|
// ── Recommended (frecency) ──────────────────────────────────────────────
|
||||||
|
const recommended = useMemo(() => {
|
||||||
|
if (allManga.length === 0 || frecencyGenres.length === 0) return [];
|
||||||
|
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
||||||
|
return allManga
|
||||||
|
.filter((m) => m.inLibrary && !continueIds.has(m.id) &&
|
||||||
|
frecencyGenres.some((g) => (m.genre ?? []).includes(g)))
|
||||||
|
.slice(0, 20);
|
||||||
|
}, [allManga, frecencyGenres, continueReading]);
|
||||||
|
|
||||||
|
const genresLoading = loadingLib || loadingGenres;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.body}>
|
||||||
|
|
||||||
|
{/* Continue Reading */}
|
||||||
|
{(continueReading.length > 0 || loadingLib) && (
|
||||||
|
<Section
|
||||||
|
title="Continue Reading"
|
||||||
|
icon={<BookOpen size={11} weight="bold" />}
|
||||||
|
loading={loadingLib}
|
||||||
|
>
|
||||||
|
<div className={s.row}>
|
||||||
|
{continueReading.map(({ manga, chapterName, progress }) => (
|
||||||
|
<MiniCard
|
||||||
|
key={manga.id}
|
||||||
|
manga={manga}
|
||||||
|
onClick={() => openManga(manga)}
|
||||||
|
subtitle={chapterName}
|
||||||
|
progress={progress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
|
<GhostCard key={`ghost-cr-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommended */}
|
||||||
|
{(recommended.length > 0 || loadingLib) && (
|
||||||
|
<Section
|
||||||
|
title="Recommended for You"
|
||||||
|
icon={<Star size={11} weight="bold" />}
|
||||||
|
loading={loadingLib}
|
||||||
|
>
|
||||||
|
<div className={s.row}>
|
||||||
|
{recommended.map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
|
<GhostCard key={`ghost-rec-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Popular across deduplicated sources */}
|
||||||
|
{(popularManga.length > 0 || loadingPopular) && (
|
||||||
|
<Section
|
||||||
|
title={
|
||||||
|
sources.length === 1
|
||||||
|
? `Popular on ${sources[0].displayName}`
|
||||||
|
: sources.length > 1
|
||||||
|
? `Popular across ${sources.length} sources`
|
||||||
|
: "Popular"
|
||||||
|
}
|
||||||
|
icon={<Fire size={11} weight="bold" />}
|
||||||
|
loading={loadingPopular}
|
||||||
|
>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||||
|
) : (
|
||||||
|
<div className={s.row}>
|
||||||
|
{popularManga.map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
|
<GhostCard key={`ghost-pop-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Genre rows — searched from sources by genre name */}
|
||||||
|
{frecencyGenres.map((genre) => {
|
||||||
|
const items = genreResults.get(genre) ?? [];
|
||||||
|
const isLoading = genresLoading && items.length === 0;
|
||||||
|
if (!isLoading && items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
key={genre}
|
||||||
|
title={genre}
|
||||||
|
onSeeAll={() => onDrill({ type: "genre", genre })}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<div className={s.row}>
|
||||||
|
{items.map((m) => (
|
||||||
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||||
|
<GhostCard key={`ghost-${genre}-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||||
|
continueReading.length === 0 && recommended.length === 0 &&
|
||||||
|
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||||
|
<div className={s.empty}>
|
||||||
|
<span>Nothing to explore yet</span>
|
||||||
|
<span className={s.emptyHint}>
|
||||||
|
Add manga to your library or install sources to get started.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const GET_LIBRARY = `
|
|||||||
inLibrary
|
inLibrary
|
||||||
downloadCount
|
downloadCount
|
||||||
unreadCount
|
unreadCount
|
||||||
|
genre
|
||||||
chapters {
|
chapters {
|
||||||
totalCount
|
totalCount
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
|||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
|
||||||
export type NavPage = "library" | "sources" | "downloads" | "extensions" | "history" | "search";
|
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user