mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
[V1] Patched MangaPreview & Added Themes (Contrast)
This commit is contained in:
@@ -85,6 +85,11 @@ export default function App() {
|
||||
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
|
||||
}, [settings.uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = settings.theme ?? "dark";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const p = (e: MouseEvent) => e.preventDefault();
|
||||
document.addEventListener("contextmenu", p);
|
||||
|
||||
@@ -384,4 +384,58 @@
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
/* ── Explore More end-cap card ───────────────────────────────────────────── */
|
||||
.exploreMoreCard {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
padding: 0;
|
||||
}
|
||||
.exploreMoreCard:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); }
|
||||
.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); }
|
||||
|
||||
.exploreMoreInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.exploreMoreIcon {
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
.exploreMoreLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exploreMoreGenre {
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
@@ -24,6 +24,18 @@ function frecencyScore(readAt: number, count: number): number {
|
||||
|
||||
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
const ROW_CAP = 25;
|
||||
|
||||
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
||||
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
const el = e.currentTarget;
|
||||
const canScrollLeft = el.scrollLeft > 0;
|
||||
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
||||
if (!canScrollLeft && !canScrollRight) return;
|
||||
e.stopPropagation();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
@@ -80,6 +92,22 @@ const MiniCard = memo(function MiniCard({
|
||||
);
|
||||
});
|
||||
|
||||
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
||||
|
||||
const ExploreMoreCard = memo(function ExploreMoreCard({
|
||||
genre, onClick,
|
||||
}: { genre: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
||||
<div className={s.exploreMoreInner}>
|
||||
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
||||
<span className={s.exploreMoreLabel}>Explore more</span>
|
||||
<span className={s.exploreMoreGenre}>{genre}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
@@ -416,8 +444,8 @@ function ExploreFeed() {
|
||||
|
||||
{(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 }) => (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
@@ -428,8 +456,8 @@ function ExploreFeed() {
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row}>
|
||||
{recommended.map((m) => (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{recommended.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
@@ -446,8 +474,8 @@ function ExploreFeed() {
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row}>
|
||||
{popularManga.map((m) => (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{popularManga.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
@@ -462,10 +490,13 @@ function ExploreFeed() {
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row}>
|
||||
{items.map((m) => (
|
||||
<div className={s.row} onWheel={handleRowWheel}>
|
||||
{items.slice(0, ROW_CAP).map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{items.length >= ROW_CAP && (
|
||||
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
||||
)}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -133,4 +133,44 @@
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* Show more — spans full grid width */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
import { useEffect, useState, useMemo, useRef, memo } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./GenreDrillPage.module.css";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
const PAGE_SIZE = 50; // how many items to show at once
|
||||
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
|
||||
const MAX_SOURCES = 12; // max sources to query concurrently
|
||||
const CONCURRENCY = 4; // parallel source fetches
|
||||
|
||||
async function runConcurrent<T>(
|
||||
items: T[],
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
// ── CoverImg ──────────────────────────────────────────────────────────────────
|
||||
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
@@ -21,6 +44,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string;
|
||||
);
|
||||
});
|
||||
|
||||
// ── GenreDrillPage ────────────────────────────────────────────────────────────
|
||||
export default function GenreDrillPage() {
|
||||
const genre = useStore((st) => st.genreFilter);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
@@ -30,12 +54,17 @@ export default function GenreDrillPage() {
|
||||
const addFolder = useStore((st) => st.addFolder);
|
||||
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
|
||||
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingLibrary, setLoadingLibrary] = useState(true);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Per-source next-page tracker; -1 means exhausted
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre) return;
|
||||
@@ -44,11 +73,15 @@ export default function GenreDrillPage() {
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
setLoadingLibrary(true);
|
||||
setLoadingSources(true);
|
||||
setLoadingInitial(true);
|
||||
setSourceManga([]);
|
||||
setLibraryManga([]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
// ── Library ────────────────────────────────────────────────────────────
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
|
||||
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
@@ -57,55 +90,122 @@ export default function GenreDrillPage() {
|
||||
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
|
||||
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
|
||||
})
|
||||
).then(setLibraryManga)
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => setLoadingLibrary(false));
|
||||
)
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
|
||||
|
||||
// ── Sources ────────────────────────────────────────────────────────────
|
||||
const preferredLang = settings.preferredExtensionLang || "en";
|
||||
// ── Sources: stream results in as each source responds ────────────────
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
||||
).then((allSources) => {
|
||||
// Use ALL deduped sources for drill pages (not just frecency top 4)
|
||||
// Cap at 8 to avoid hammering the server too hard
|
||||
const sourcesToQuery = allSources.slice(0, 8);
|
||||
return cache.get(CACHE_KEYS.GENRE(genre), () =>
|
||||
Promise.allSettled(
|
||||
// Fetch page 1 and page 2 from each source for a fuller result set
|
||||
sourcesToQuery.flatMap((src) =>
|
||||
[1, 2].map((page) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page, query: genre,
|
||||
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
)
|
||||
).then((results) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of results)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then((manga) => { if (!ctrl.signal.aborted) setSourceManga(manga); })
|
||||
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setLoadingSources(false); });
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
|
||||
).then(async (allSources) => {
|
||||
const sources = allSources.slice(0, MAX_SOURCES);
|
||||
sourcesRef.current = sources;
|
||||
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
|
||||
for (const src of sources) nextPageRef.current.set(src.id, -1);
|
||||
|
||||
await runConcurrent(sources, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
pageItems.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
} else if (page === INITIAL_PAGES) {
|
||||
// Has more pages beyond what we fetched upfront — mark for "load more"
|
||||
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
|
||||
// legitimate different-source results that share a common title (e.g. "Action" genre)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
|
||||
// Drop the skeleton as soon as we have anything
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
}).catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
if (!ctrl.signal.aborted) setLoadingInitial(false);
|
||||
});
|
||||
|
||||
return () => { ctrl.abort(); };
|
||||
}, [genre]);
|
||||
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Derived merged list ────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
// Library manga: only include if genre matches (we have full metadata)
|
||||
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
|
||||
// Source manga: include ALL results — they came from a genre search,
|
||||
// but the API often returns no genre tags in the brief response payload.
|
||||
// De-duplicate against library matches by id.
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
|
||||
return dedupeMangaById([...libMatches, ...srcAll]);
|
||||
}, [libraryManga, sourceManga, genre]);
|
||||
|
||||
// ── Load more ──────────────────────────────────────────────────────────────
|
||||
const hasMoreVisible = visibleCount < filtered.length;
|
||||
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
const hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
|
||||
// If there are buffered results, just reveal the next page
|
||||
if (hasMoreVisible) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch next pages from network
|
||||
const sources = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (!sources.length) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sources, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: genre },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
|
||||
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, hasMoreVisible, genre]);
|
||||
|
||||
// ── Context menu ──────────────────────────────────────────────────────────
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, manga: m });
|
||||
@@ -144,7 +244,7 @@ export default function GenreDrillPage() {
|
||||
];
|
||||
}
|
||||
|
||||
const showSkeleton = loadingLibrary && filtered.length === 0;
|
||||
const visibleItems = filtered.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
@@ -154,25 +254,30 @@ export default function GenreDrillPage() {
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{genre}</span>
|
||||
{loadingSources && !loadingLibrary && filtered.length > 0 && (
|
||||
<span className={s.loadingHint}>Loading more…</span>
|
||||
{loadingInitial && filtered.length === 0 ? null : (
|
||||
<span className={s.resultCount}>
|
||||
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
|
||||
</span>
|
||||
)}
|
||||
{!loadingInitial && hasMoreNetwork && (
|
||||
<span className={s.loadingHint}>More loading…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
{loadingInitial && filtered.length === 0 ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 && !loadingSources ? (
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.empty}>No manga found for "{genre}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{filtered.map((m) => (
|
||||
{visibleItems.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
@@ -181,6 +286,15 @@ export default function GenreDrillPage() {
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading…</>
|
||||
: `Show more`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -359,6 +359,16 @@
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
}
|
||||
|
||||
.genreTagClickable {
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genreTagClickable:hover {
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent-dim);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* ── Metadata table ──────────────────────────────────────────────────────── */
|
||||
.metaTable {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function MangaPreview() {
|
||||
const setPreviewManga = useStore((st) => st.setPreviewManga);
|
||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||
const setNavPage = useStore((st) => st.setNavPage);
|
||||
const setGenreFilter = useStore((st) => st.setGenreFilter);
|
||||
const openReader = useStore((st) => st.openReader);
|
||||
const addToast = useStore((st) => st.addToast);
|
||||
const folders = useStore((st) => st.settings.folders);
|
||||
@@ -476,7 +477,20 @@ export default function MangaPreview() {
|
||||
{/* ── Genre tags ── */}
|
||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{displayManga.genre.map((g) => <span key={g} className={s.genreTag}>{g}</span>)}
|
||||
{displayManga.genre.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={[s.genreTag, s.genreTagClickable].join(" ")}
|
||||
title={`Browse "${g}"`}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import logoUrl from "../../assets/moku-icon.svg";
|
||||
|
||||
export type SplashMode = "loading" | "idle";
|
||||
@@ -9,7 +10,7 @@ interface Props {
|
||||
ringFull?: boolean;
|
||||
failed?: boolean;
|
||||
showCards?: boolean;
|
||||
showFps?: boolean; // only passed from devSplash
|
||||
showFps?: boolean;
|
||||
onReady?: () => void;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
@@ -22,21 +23,13 @@ function hash(n: number): number {
|
||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
||||
}
|
||||
|
||||
// ── Dimensions ────────────────────────────────────────────────────────────────
|
||||
// Use window dimensions for card/stamp generation (reasonable at load time),
|
||||
// but the canvas itself will resize dynamically — see CardCanvas below.
|
||||
const VW = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||||
const VH = typeof window !== "undefined" ? window.innerHeight : 800;
|
||||
const BUF = 80;
|
||||
const COLS = 14;
|
||||
|
||||
// ── Card definition — lines stored here so stamps use the exact same value ───
|
||||
// ── Card definition ───────────────────────────────────────────────────────────
|
||||
interface CardDef {
|
||||
layer: 0 | 1 | 2;
|
||||
cx: number;
|
||||
w: number;
|
||||
h: number;
|
||||
lines: number; // 1‒3, stored once, used by both stamp builder & (future) draw
|
||||
lines: number;
|
||||
alpha: number;
|
||||
speed: number;
|
||||
cycleSec: number;
|
||||
@@ -47,15 +40,20 @@ interface CardDef {
|
||||
tilt: number;
|
||||
}
|
||||
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
|
||||
const LAYER_CFG = [
|
||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||
] as const;
|
||||
|
||||
const CARDS: CardDef[] = (() => {
|
||||
const out: CardDef[] = [];
|
||||
const laneW = VW / COLS;
|
||||
const BUF = 80;
|
||||
const COLS = 14;
|
||||
|
||||
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
|
||||
const cards: CardDef[] = [];
|
||||
const laneW = vw / COLS;
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
const cfg = LAYER_CFG[layer];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
@@ -65,49 +63,31 @@ const CARDS: CardDef[] = (() => {
|
||||
const maxNudge = (laneW - w) / 2 - 2;
|
||||
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge);
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
||||
const travel = VH + h + BUF;
|
||||
out.push({
|
||||
const travel = vh + h + BUF;
|
||||
cards.push({
|
||||
layer: layer as 0 | 1 | 2,
|
||||
cx, w, h,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3), // same seed+7 always
|
||||
alpha: cfg.alpha,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
speed,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
cycleSec: travel / speed,
|
||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||
travel,
|
||||
yStart: VH + h / 2 + BUF / 2,
|
||||
angleStart:hash(seed + 3) * 50 - 25,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
yStart: vh + h / 2 + BUF / 2,
|
||||
angleStart: hash(seed + 3) * 50 - 25,
|
||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
const trigs: CardTrig[] = cards.map(c => ({
|
||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||
tiltRad: c.tilt * (Math.PI / 180),
|
||||
}));
|
||||
return { cards, trigs };
|
||||
}
|
||||
|
||||
// ── Pre-computed per-card trig deltas ────────────────────────────────────────
|
||||
// angleStart and tilt are fixed; only p (0→1) scales the tilt.
|
||||
// We can't fully precompute because p changes per frame, but we CAN precompute
|
||||
// the per-radian cos/sin values and use small-angle linearisation... actually
|
||||
// the simplest win is to note angles are small (±43° max) and just avoid
|
||||
// recomputing Math.cos/sin of angleStart every frame — cache them, then
|
||||
// use rotation composition for the tilt delta which is tiny per frame.
|
||||
//
|
||||
// Simpler and sufficient: cache base angle cos/sin for each card at module init,
|
||||
// then compose with the tilt delta using the rotation formula:
|
||||
// cos(a+d) = cos(a)*cos(d) - sin(a)*sin(d)
|
||||
// sin(a+d) = sin(a)*cos(d) + cos(a)*sin(d)
|
||||
// Since the tilt delta is at most 18° total over the whole travel, per-frame
|
||||
// delta is tiny — Math.cos of a tiny number ≈ 1, Math.sin ≈ angle.
|
||||
// But the cleanest approach: just cache angleStart's cos/sin, and per frame
|
||||
// only compute cos/sin of the TILT FRACTION (small value).
|
||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||
const CARD_TRIG: CardTrig[] = CARDS.map(c => ({
|
||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||
tiltRad: c.tilt * (Math.PI / 180),
|
||||
}));
|
||||
|
||||
// ── Rounded rect path helper ──────────────────────────────────────────────────
|
||||
// ── Rounded rect ──────────────────────────────────────────────────────────────
|
||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
@@ -118,78 +98,78 @@ function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// ── Stamp builder — runs ONCE at module init ──────────────────────────────────
|
||||
// Each card is pre-rendered at full opacity to a tiny offscreen canvas.
|
||||
// Hot path does zero path ops — just globalAlpha + drawImage per card.
|
||||
// ── Stamp builder ─────────────────────────────────────────────────────────────
|
||||
const STAMP_PAD = 6;
|
||||
|
||||
const STAMPS: HTMLCanvasElement[] = (() => {
|
||||
if (typeof document === "undefined") return [];
|
||||
return CARDS.map(c => {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.ceil(c.w + STAMP_PAD * 2);
|
||||
oc.height = Math.ceil(c.h + STAMP_PAD * 2);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
const x0 = STAMP_PAD;
|
||||
const y0 = STAMP_PAD;
|
||||
const coverH = (c.w * 0.72) * 1.05;
|
||||
// Text lines start just below the cover rect
|
||||
const lineY0 = y0 + 3 + coverH + 5;
|
||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||
const logW = Math.ceil(c.w + STAMP_PAD * 2);
|
||||
const logH = Math.ceil(c.h + STAMP_PAD * 2);
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = Math.round(logW * dpr);
|
||||
oc.height = Math.round(logH * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||
const x0 = STAMP_PAD;
|
||||
const y0 = STAMP_PAD;
|
||||
const coverH = (c.w * 0.72) * 1.05;
|
||||
const lineY0 = y0 + 3 + coverH + 5;
|
||||
|
||||
// Body
|
||||
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
||||
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2;
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
||||
|
||||
// Cover area
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
||||
ctx.lineWidth = 1.2;
|
||||
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
||||
|
||||
// Cover tint band
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.15)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
||||
|
||||
// Text lines — use c.lines (same value as buildCards computed)
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
||||
|
||||
return oc;
|
||||
});
|
||||
})();
|
||||
for (let li = 0; li < c.lines; li++) {
|
||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
||||
}
|
||||
|
||||
// ── Pre-baked vignette canvas ─────────────────────────────────────────────────
|
||||
const VIGNETTE: HTMLCanvasElement | null = (() => {
|
||||
if (typeof document === "undefined") return null;
|
||||
return oc;
|
||||
}
|
||||
|
||||
// ── Vignette builder ──────────────────────────────────────────────────────────
|
||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = VW; oc.height = VH;
|
||||
oc.width = Math.round(vw * dpr);
|
||||
oc.height = Math.round(vh * dpr);
|
||||
const ctx = oc.getContext("2d")!;
|
||||
const g = ctx.createRadialGradient(VW / 2, VH / 2, 0, VW / 2, VH / 2, Math.max(VW, VH) * 0.65);
|
||||
ctx.scale(dpr, dpr);
|
||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
||||
g.addColorStop(0.15, "rgba(0,0,0,0)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0.82)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, VW, VH);
|
||||
ctx.fillRect(0, 0, vw, vh);
|
||||
return oc;
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Draw frame — hot path ─────────────────────────────────────────────────────
|
||||
// Uses setTransform() instead of manual translate/rotate undo.
|
||||
// setTransform sets the full matrix in one call — no floating-point drift,
|
||||
// no stack push/pop, one fewer operation than save+restore.
|
||||
function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number) {
|
||||
// ── Draw frame ────────────────────────────────────────────────────────────────
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
t: number,
|
||||
cw: number,
|
||||
ch: number,
|
||||
dpr: number,
|
||||
cards: CardDef[],
|
||||
trigs: CardTrig[],
|
||||
stamps: HTMLCanvasElement[],
|
||||
vignette: HTMLCanvasElement,
|
||||
) {
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
|
||||
for (let i = 0; i < CARDS.length; i++) {
|
||||
const c = CARDS[i];
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const c = cards[i];
|
||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
||||
|
||||
const alpha = p < 0.07
|
||||
@@ -200,27 +180,28 @@ function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: num
|
||||
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const cy = c.yStart - p * c.travel;
|
||||
|
||||
// Compose base rotation with tilt delta using trig identity —
|
||||
// avoids two Math.cos/sin calls; only one pair for the small delta.
|
||||
const tg = CARD_TRIG[i];
|
||||
const delta = tg.tiltRad * p; // small value (≤ 18° * 1)
|
||||
const cy = c.yStart - p * c.travel;
|
||||
const tg = trigs[i];
|
||||
const delta = tg.tiltRad * p;
|
||||
const cosDelta = Math.cos(delta);
|
||||
const sinDelta = Math.sin(delta);
|
||||
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
|
||||
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
// setTransform(a,b,c,d,e,f) = [cos,sin,-sin,cos,tx,ty]
|
||||
ctx.setTransform(cos, sin, -sin, cos, c.cx, cy);
|
||||
ctx.drawImage(STAMPS[i], -c.w / 2 - STAMP_PAD, -c.h / 2 - STAMP_PAD);
|
||||
ctx.setTransform(
|
||||
cos * dpr, sin * dpr,
|
||||
-sin * dpr, cos * dpr,
|
||||
c.cx * dpr, cy * dpr,
|
||||
);
|
||||
const sw = c.w + STAMP_PAD * 2;
|
||||
const sh = c.h + STAMP_PAD * 2;
|
||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
||||
}
|
||||
|
||||
// Reset to identity + full opacity in one call
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
if (VIGNETTE) ctx.drawImage(VIGNETTE, 0, 0, cw, ch);
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
||||
}
|
||||
|
||||
// ── Ring ──────────────────────────────────────────────────────────────────────
|
||||
@@ -243,10 +224,10 @@ function Ring({ progress }: { progress: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── FPS counter — only mounted when showFps=true (devSplash only) ─────────────
|
||||
// ── FPS counter ───────────────────────────────────────────────────────────────
|
||||
function FpsCounter() {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
@@ -279,36 +260,110 @@ function FpsCounter() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── CardCanvas — owns the single rAF loop ─────────────────────────────────────
|
||||
// ── CardCanvas ────────────────────────────────────────────────────────────────
|
||||
// Uses invoke("get_scale_factor") to get the real OS DPR from winit/Tauri
|
||||
// before building any bitmaps. window.devicePixelRatio is unreliable in
|
||||
// nix run and flatpak because WebKitGTK may not have received the HiDPI
|
||||
// hint from the compositor by the time the JS context initialises.
|
||||
// Tauri reads it from the native window handle, which is always correct.
|
||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
// Keep canvas resolution in sync with its CSS size
|
||||
function syncSize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = canvas.offsetWidth || window.innerWidth;
|
||||
canvas.height = canvas.offsetHeight || window.innerHeight;
|
||||
}
|
||||
syncSize();
|
||||
const ro = new ResizeObserver(syncSize);
|
||||
ro.observe(canvas);
|
||||
let cancelled = false;
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
if (t0 < 0) t0 = now;
|
||||
drawFrame(ctx!, (now - t0) / 1000, canvas!.width, canvas!.height);
|
||||
async function init() {
|
||||
// Prefer the Tauri-sourced scale factor; fall back to the JS value
|
||||
// when running outside Tauri (e.g. vite dev server in a browser).
|
||||
let dpr = window.devicePixelRatio || 1;
|
||||
try {
|
||||
const tauriDpr = await invoke<number>("get_scale_factor");
|
||||
if (tauriDpr > 0) dpr = tauriDpr;
|
||||
} catch {
|
||||
// Not in Tauri — window.devicePixelRatio is fine for the browser.
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
console.log(
|
||||
"[SplashScreen] DPR resolution:",
|
||||
`window.devicePixelRatio=${window.devicePixelRatio}`,
|
||||
`resolved dpr=${dpr}`,
|
||||
`logical=${window.innerWidth}x${window.innerHeight}`,
|
||||
`physical=${Math.round(window.innerWidth * dpr)}x${Math.round(window.innerHeight * dpr)}`,
|
||||
);
|
||||
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
const { cards, trigs } = buildCards(vw, vh);
|
||||
const stamps = cards.map(c => buildStamp(c, dpr));
|
||||
|
||||
let vignette = buildVignette(vw, vh, dpr);
|
||||
let lastLW = vw;
|
||||
let lastLH = vh;
|
||||
let lastDpr = dpr;
|
||||
let curDpr = dpr;
|
||||
|
||||
// syncSize is synchronous for the canvas resize, but fires an async
|
||||
// Tauri call to update curDpr so the next frame uses the right value.
|
||||
// This handles moving the window between monitors mid-session.
|
||||
function syncSize() {
|
||||
if (!canvas) return;
|
||||
const lw = canvas.offsetWidth || window.innerWidth;
|
||||
const lh = canvas.offsetHeight || window.innerHeight;
|
||||
canvas.width = Math.round(lw * curDpr);
|
||||
canvas.height = Math.round(lh * curDpr);
|
||||
|
||||
if (lw !== lastLW || lh !== lastLH || curDpr !== lastDpr) {
|
||||
vignette = buildVignette(lw, lh, curDpr);
|
||||
lastLW = lw;
|
||||
lastLH = lh;
|
||||
lastDpr = curDpr;
|
||||
}
|
||||
|
||||
// Async DPR refresh for next resize (e.g. monitor switch)
|
||||
invoke<number>("get_scale_factor")
|
||||
.then(d => { if (d > 0) curDpr = d; })
|
||||
.catch(() => { curDpr = window.devicePixelRatio || 1; });
|
||||
}
|
||||
|
||||
syncSize();
|
||||
const ro = new ResizeObserver(syncSize);
|
||||
if (canvas) ro.observe(canvas);
|
||||
|
||||
let raf = 0, t0 = -1;
|
||||
function frame(now: number) {
|
||||
if (t0 < 0) t0 = now;
|
||||
drawFrame(
|
||||
ctx!, (now - t0) / 1000,
|
||||
canvas!.width, canvas!.height,
|
||||
curDpr, cards, trigs, stamps, vignette,
|
||||
);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
// Stash cleanup so the synchronous useEffect return can reach it.
|
||||
(canvas as any).__splashCleanup = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
cancelled = true;
|
||||
(canvas as any).__splashCleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -364,7 +419,6 @@ export default function SplashScreen({
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Idle dismiss: keydown / mousedown / touchstart only — NO mousemove
|
||||
useEffect(() => {
|
||||
if (mode !== "idle" || !onDismiss) return;
|
||||
function handler() { triggerExit(onDismiss); }
|
||||
|
||||
@@ -475,4 +475,154 @@
|
||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||
}
|
||||
/* ── Source context pill (step 2 header) ── */
|
||||
.searchContext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextName {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchContextChange {
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.searchContextChange:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Result row: updated layout with similarity ── */
|
||||
.resultInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.bestMatchBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simBar {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.simLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Confirm step additions ── */
|
||||
.confirmDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmTag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.confirmTagNew {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.statGood { color: var(--color-success) !important; }
|
||||
.statWarn { color: #d97706 !important; }
|
||||
.statBad { color: var(--color-error) !important; }
|
||||
|
||||
.chapterDiff {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: #d97706;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
.warnBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: #d97706;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
@@ -18,20 +18,33 @@ interface Match {
|
||||
manga: Manga;
|
||||
chapters: Chapter[];
|
||||
readCount: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
// Simple title similarity: normalise → word overlap / Jaccard
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||
const union = new Set([...wordsA, ...wordsB]).size;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<Manga[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatch, setLoadingMatch] = useState(false);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
@@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
.finally(() => setLoadingSources(false));
|
||||
}, []);
|
||||
|
||||
async function searchSource() {
|
||||
if (!selectedSource || !query.trim()) return;
|
||||
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||
if (!src || !q.trim()) return;
|
||||
setSearching(true);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
|
||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||
});
|
||||
setResults(d.fetchSourceManga.mangas);
|
||||
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||
manga: m,
|
||||
similarity: titleSimilarity(manga.title, m.title),
|
||||
}));
|
||||
// Sort by similarity desc so best matches float to top
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
setResults(scored);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [manga.title]);
|
||||
|
||||
function pickSource(src: Source) {
|
||||
setSelectedSource(src);
|
||||
setStep("search");
|
||||
// Auto-search immediately with original title
|
||||
searchSource(src, query);
|
||||
}
|
||||
|
||||
async function selectMatch(m: Manga) {
|
||||
setLoadingMatch(true);
|
||||
async function selectMatch(m: Manga, similarity: number) {
|
||||
setLoadingMatchId(m.id);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
@@ -67,12 +93,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
return old?.isRead;
|
||||
}).length;
|
||||
setSelectedMatch({ manga: m, chapters, readCount });
|
||||
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||
setStep("confirm");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingMatch(false);
|
||||
setLoadingMatchId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
setError(null);
|
||||
try {
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
|
||||
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
|
||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
@@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead) {
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate read state
|
||||
if (toMarkRead.length) {
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
}
|
||||
// Migrate bookmarks
|
||||
if (toMarkBookmarked.length) {
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
}
|
||||
// Migrate in-progress pages one by one (different lastPageRead per chapter)
|
||||
for (const { id, lastPageRead } of progressUpdates) {
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
}
|
||||
|
||||
// Add new to library, remove old
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
@@ -125,33 +141,48 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
}
|
||||
}
|
||||
|
||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||
const totalCount = currentChapters.length;
|
||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||
const totalCount = currentChapters.length;
|
||||
|
||||
const chapterDiff = selectedMatch
|
||||
? selectedMatch.chapters.length - totalCount
|
||||
: 0;
|
||||
|
||||
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
return (
|
||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className={s.modal}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.modalTitle}>
|
||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={onClose}>
|
||||
<X size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* ── Step indicators ── */}
|
||||
<div className={s.steps}>
|
||||
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
|
||||
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
|
||||
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span>
|
||||
{STEPS.map((st, i) => (
|
||||
<div key={st}
|
||||
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>
|
||||
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||
</span>
|
||||
<span className={s.stepLabel}>
|
||||
{st === "source" ? "Pick source"
|
||||
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||
: "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
|
||||
{/* ── Step 1: Pick source ── */}
|
||||
{step === "source" && (
|
||||
<div className={s.sourceList}>
|
||||
@@ -163,11 +194,9 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||
) : (
|
||||
sources.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
<button key={src.id}
|
||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
||||
>
|
||||
onClick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.sourceInfo}>
|
||||
@@ -184,22 +213,34 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
{/* ── Step 2: Search & pick match ── */}
|
||||
{step === "search" && (
|
||||
<div className={s.searchStep}>
|
||||
|
||||
{/* Source context pill */}
|
||||
{selectedSource && (
|
||||
<div className={s.searchContext}>
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.searchRow}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||
<input
|
||||
className={s.searchInput}
|
||||
value={query}
|
||||
<input className={s.searchInput} value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
||||
autoFocus
|
||||
/>
|
||||
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
|
||||
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
|
||||
</button>
|
||||
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Back
|
||||
<button className={s.searchBtn}
|
||||
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
disabled={searching || !selectedSource}>
|
||||
{searching
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={s.skMeta}>
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!searching && results.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={s.resultRow}
|
||||
onClick={() => selectMatch(m)}
|
||||
disabled={loadingMatch}
|
||||
>
|
||||
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||
<button key={m.id} className={s.resultRow}
|
||||
onClick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<div className={s.resultCoverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||
</div>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||
<div className={s.resultInfo}>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
<div className={s.resultMeta}>
|
||||
{idx === 0 && similarity > 0.5 && (
|
||||
<span className={s.bestMatchBadge}>
|
||||
<Sparkle size={9} weight="fill" /> Best match
|
||||
</span>
|
||||
)}
|
||||
<span className={s.simBar}>
|
||||
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||
</span>
|
||||
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
{loadingMatchId === m.id
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||
</button>
|
||||
))}
|
||||
{!searching && results.length === 0 && query && (
|
||||
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
||||
{!searching && results.length === 0 && !error && (
|
||||
<div className={s.centered}>
|
||||
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{manga.title}</p>
|
||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span className={s.confirmTag}>Current</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
||||
<div className={s.confirmDivider}>
|
||||
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||
</div>
|
||||
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmStats}>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Title match</span>
|
||||
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||
{Math.round(selectedMatch.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Chapters on new source</span>
|
||||
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
|
||||
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||
{selectedMatch.chapters.length}
|
||||
{chapterDiff !== 0 && (
|
||||
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Read progress to migrate</span>
|
||||
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Matched chapters</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
|
||||
<span className={s.statLabel}>Read progress to carry over</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chapterDiff < -5 && (
|
||||
<div className={s.warnBox}>
|
||||
<Warning size={13} weight="light" />
|
||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={s.confirmNote}>
|
||||
The current entry will be removed from your library. Downloads are not transferred.
|
||||
</p>
|
||||
@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||
{migrating
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||
: "Migrate"}
|
||||
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +315,25 @@
|
||||
.tagGrid .skCard { width: auto; }
|
||||
.tagGrid .skCover { width: 100%; }
|
||||
|
||||
/* ── NSFW badge ──────────────────────────────────────────────────────────── */
|
||||
/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px; border-radius: var(--radius-md);
|
||||
background: var(--bg-raised); color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.showMoreBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.nsfwBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 1px 5px;
|
||||
|
||||
+117
-28
@@ -4,8 +4,8 @@ import {
|
||||
} 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 { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./Search.module.css";
|
||||
@@ -428,6 +428,10 @@ function KeywordTab({
|
||||
|
||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
||||
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
||||
const TAG_MAX_SOURCES = 12; // max sources to query
|
||||
|
||||
function TagTab({
|
||||
preferredLang, onMangaClick,
|
||||
}: {
|
||||
@@ -436,11 +440,16 @@ function TagTab({
|
||||
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);
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||
const [loadingTag, setLoadingTag] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
// Track next page to fetch per source for "load more from network"
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
@@ -449,6 +458,8 @@ function TagTab({
|
||||
setActiveTag(tag);
|
||||
setTagResults([]);
|
||||
setLoadingTag(true);
|
||||
setVisibleCount(TAG_PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
@@ -459,27 +470,44 @@ function TagTab({
|
||||
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 deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
||||
sourcesRef.current = deduped;
|
||||
|
||||
const results = await cache.get(CACHE_KEYS.GENRE(tag), () =>
|
||||
Promise.allSettled(
|
||||
top.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
||||
for (const src of deduped) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
|
||||
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
||||
await runConcurrent(deduped, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageResults: Manga[] = [];
|
||||
// Fetch TAG_FETCH_PAGES pages in series per source
|
||||
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page: 1, query: tag },
|
||||
{ source: src.id, type: "SEARCH", page, 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);
|
||||
);
|
||||
pageResults.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1); // no more pages
|
||||
break;
|
||||
} else if (page === TAG_FETCH_PAGES) {
|
||||
// Still has more pages beyond what we fetched upfront
|
||||
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
break; // source error — move on
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
@@ -487,11 +515,61 @@ function TagTab({
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!activeTag || loadingMore) return;
|
||||
|
||||
// First check if we have more buffered results to show
|
||||
if (visibleCount < tagResults.length) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch next pages from sources
|
||||
const sourcesToFetch = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (sourcesToFetch.length === 0) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sourcesToFetch, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGenres = useMemo(() => {
|
||||
const q = tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||
}, [tagFilter]);
|
||||
|
||||
const visibleResults = tagResults.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < tagResults.length ||
|
||||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
|
||||
return (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
@@ -531,15 +609,26 @@ function TagTab({
|
||||
<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>}
|
||||
: <span className={s.splitResultCount}>
|
||||
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
||||
</span>}
|
||||
</div>
|
||||
{loadingTag ? (
|
||||
<GridSkeleton />
|
||||
<GridSkeleton count={50} />
|
||||
) : tagResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{tagResults.map((m) => (
|
||||
{visibleResults.map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||
: "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
|
||||
@@ -302,6 +302,7 @@ export default function SeriesDetail() {
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
const addToast = useStore((state) => state.addToast);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
@@ -733,7 +734,11 @@ export default function SeriesDetail() {
|
||||
key={g}
|
||||
className={[s.genre, s.genreClickable].join(" ")}
|
||||
title={`Filter library by "${g}"`}
|
||||
onClick={() => setGenreFilter(g)}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
setActiveManga(null);
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
|
||||
@@ -458,4 +458,104 @@
|
||||
.folderTabToggleOn:hover {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
/* ─── Theme picker ── */
|
||||
.themeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.themeCard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.themeCardActive {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.themeCardActive:hover { border-color: var(--accent); }
|
||||
|
||||
.themePreview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0,0,0,0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.themePreviewBg {
|
||||
width: 100%; height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.themePreviewSidebar {
|
||||
width: 22%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.themePreviewContent {
|
||||
flex: 1;
|
||||
padding: 10% 12%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.themePreviewAccent {
|
||||
height: 14%;
|
||||
border-radius: 2px;
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.themePreviewText {
|
||||
height: 9%;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.themeCardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.themeCardLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.themeCardDesc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.themeCardCheck {
|
||||
position: absolute;
|
||||
top: var(--sp-1);
|
||||
right: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench } from "@phosphor-icons/react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Folder } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
import type { Settings, FitMode, Theme } from "../../store";
|
||||
import s from "./Settings.module.css";
|
||||
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
|
||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||
{ id: "appearance", label: "Appearance", icon: <PaintBrush size={14} weight="light" /> },
|
||||
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
|
||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||
{ id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
|
||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
||||
];
|
||||
|
||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||
@@ -728,6 +729,88 @@ function FoldersTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Appearance tab ────────────────────────────────────────────────────────────
|
||||
|
||||
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
||||
{
|
||||
id: "dark",
|
||||
label: "Dark",
|
||||
description: "Default near-black",
|
||||
swatches: ["#101010", "#151515", "#a8c4a8", "#f0efec"],
|
||||
},
|
||||
{
|
||||
id: "high-contrast",
|
||||
label: "High Contrast",
|
||||
description: "Darker base, sharper text",
|
||||
swatches: ["#080808", "#111111", "#bcd8bc", "#ffffff"],
|
||||
},
|
||||
{
|
||||
id: "light",
|
||||
label: "Light",
|
||||
description: "Warm off-white",
|
||||
swatches: ["#f4f2ee", "#faf8f4", "#2a5a2a", "#1a1916"],
|
||||
},
|
||||
{
|
||||
id: "light-contrast",
|
||||
label: "Light Contrast",
|
||||
description: "Light with maximum text contrast",
|
||||
swatches: ["#ece8e2", "#f5f2ec", "#183818", "#080806"],
|
||||
},
|
||||
{
|
||||
id: "midnight",
|
||||
label: "Midnight",
|
||||
description: "Deep blue-black tint",
|
||||
swatches: ["#0c1020", "#101428", "#a8b4e8", "#eeeef8"],
|
||||
},
|
||||
{
|
||||
id: "warm",
|
||||
label: "Warm",
|
||||
description: "Amber and sepia tones",
|
||||
swatches: ["#16130c", "#1c1810", "#e0b860", "#f5f0e0"],
|
||||
},
|
||||
];
|
||||
|
||||
function AppearanceTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
const current = settings.theme ?? "dark";
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Theme</p>
|
||||
<div className={s.themeGrid}>
|
||||
{THEMES.map((theme) => {
|
||||
const active = current === theme.id;
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={[s.themeCard, active ? s.themeCardActive : ""].join(" ")}
|
||||
onClick={() => update({ theme: theme.id })}
|
||||
title={theme.description}
|
||||
>
|
||||
<div className={s.themePreview}>
|
||||
{/* Mini UI preview using the theme swatches */}
|
||||
<div className={s.themePreviewBg} style={{ background: theme.swatches[0] }}>
|
||||
<div className={s.themePreviewSidebar} style={{ background: theme.swatches[1] }} />
|
||||
<div className={s.themePreviewContent}>
|
||||
<div className={s.themePreviewAccent} style={{ background: theme.swatches[2] }} />
|
||||
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "55" }} />
|
||||
<div className={s.themePreviewText} style={{ background: theme.swatches[3] + "33", width: "60%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.themeCardInfo}>
|
||||
<span className={s.themeCardLabel}>{theme.label}</span>
|
||||
<span className={s.themeCardDesc}>{theme.description}</span>
|
||||
</div>
|
||||
{active && <span className={s.themeCardCheck}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DevToolsTab() {
|
||||
const [splashTriggered, setSplashTriggered] = useState(false);
|
||||
|
||||
@@ -840,6 +923,7 @@ export default function SettingsModal() {
|
||||
</div>
|
||||
<div className={s.contentBody} ref={contentBodyRef}>
|
||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
||||
{tab === "appearance" && <AppearanceTab settings={settings} update={updateSettings} />}
|
||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||
|
||||
@@ -9,6 +9,13 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string; // str
|
||||
export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type Theme =
|
||||
| "dark" // default — near-black
|
||||
| "high-contrast" // darker + sharper text
|
||||
| "light" // warm off-white
|
||||
| "light-contrast" // light + max contrast
|
||||
| "midnight" // blue-black tint
|
||||
| "warm"; // amber/sepia tint
|
||||
|
||||
export interface HistoryEntry {
|
||||
mangaId: number;
|
||||
@@ -71,6 +78,8 @@ export interface Settings {
|
||||
folders: Folder[];
|
||||
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
|
||||
readerDebounceMs: number;
|
||||
/** UI colour theme. Applied as data-theme on <html>. */
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -102,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
storageLimitGb: null,
|
||||
folders: [],
|
||||
readerDebounceMs: 120,
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
interface Store {
|
||||
|
||||
@@ -106,4 +106,157 @@
|
||||
--t-fast: 0.08s ease;
|
||||
--t-base: 0.14s ease;
|
||||
--t-slow: 0.22s ease;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Themes
|
||||
Applied via data-theme on <html>.
|
||||
"dark" = default (no overrides needed, inherits :root).
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
/* ── High Contrast (dark base, sharper text) ── */
|
||||
[data-theme="high-contrast"] {
|
||||
--bg-void: #000000;
|
||||
--bg-base: #080808;
|
||||
--bg-surface: #0d0d0d;
|
||||
--bg-raised: #111111;
|
||||
--bg-overlay: #171717;
|
||||
--bg-subtle: #1e1e1e;
|
||||
|
||||
--border-dim: #252525;
|
||||
--border-base: #303030;
|
||||
--border-strong: #3e3e3e;
|
||||
--border-focus: #5a7a5a;
|
||||
|
||||
/* Text bumped up significantly for contrast */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #e8e6e0;
|
||||
--text-muted: #b0aea8;
|
||||
--text-faint: #6e6c68;
|
||||
--text-disabled: #303030;
|
||||
|
||||
--accent: #7aaa7a;
|
||||
--accent-dim: #2e4a2e;
|
||||
--accent-muted: #1e2e1e;
|
||||
--accent-fg: #bcd8bc;
|
||||
--accent-bright: #9fcf9f;
|
||||
}
|
||||
|
||||
/* ── Light mode ── */
|
||||
[data-theme="light"] {
|
||||
--bg-void: #e8e6e2;
|
||||
--bg-base: #eeece8;
|
||||
--bg-surface: #f4f2ee;
|
||||
--bg-raised: #faf8f4;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #f0ede8;
|
||||
|
||||
--border-dim: #dedad4;
|
||||
--border-base: #d0ccc6;
|
||||
--border-strong: #bbb6ae;
|
||||
--border-focus: #5a7a5a;
|
||||
|
||||
--text-primary: #1a1916;
|
||||
--text-secondary: #2e2c28;
|
||||
--text-muted: #5a5750;
|
||||
--text-faint: #9a9890;
|
||||
--text-disabled: #c8c4bc;
|
||||
|
||||
--accent: #4a724a;
|
||||
--accent-dim: #c8dcc8;
|
||||
--accent-muted: #deeade;
|
||||
--accent-fg: #2a5a2a;
|
||||
--accent-bright: #3a6a3a;
|
||||
|
||||
--color-error: #a03030;
|
||||
--color-error-bg: #fce8e8;
|
||||
--color-success: #2a6a2a;
|
||||
--color-info: #2a4a7a;
|
||||
--color-info-bg: #e8eef8;
|
||||
--color-read: #e8e4dc;
|
||||
}
|
||||
|
||||
/* ── Light High Contrast ── */
|
||||
[data-theme="light-contrast"] {
|
||||
--bg-void: #d8d4ce;
|
||||
--bg-base: #e2deda;
|
||||
--bg-surface: #ece8e2;
|
||||
--bg-raised: #f5f2ec;
|
||||
--bg-overlay: #ffffff;
|
||||
--bg-subtle: #e4e0d8;
|
||||
|
||||
--border-dim: #c4c0b8;
|
||||
--border-base: #b0aca4;
|
||||
--border-strong: #989490;
|
||||
--border-focus: #3a5a3a;
|
||||
|
||||
--text-primary: #080806;
|
||||
--text-secondary: #181612;
|
||||
--text-muted: #38342e;
|
||||
--text-faint: #706c64;
|
||||
--text-disabled: #b0ac a4;
|
||||
|
||||
--accent: #2a5a2a;
|
||||
--accent-dim: #b0ccb0;
|
||||
--accent-muted: #c8dcc8;
|
||||
--accent-fg: #183818;
|
||||
--accent-bright: #1e4e1e;
|
||||
|
||||
--color-error: #8a1a1a;
|
||||
--color-error-bg: #f8e0e0;
|
||||
--color-read: #e0dcd4;
|
||||
}
|
||||
|
||||
/* ── Midnight (deep blue-black tint) ── */
|
||||
[data-theme="midnight"] {
|
||||
--bg-void: #050810;
|
||||
--bg-base: #080c18;
|
||||
--bg-surface: #0c1020;
|
||||
--bg-raised: #101428;
|
||||
--bg-overlay: #151a30;
|
||||
--bg-subtle: #1a2038;
|
||||
|
||||
--border-dim: #1a2035;
|
||||
--border-base: #222840;
|
||||
--border-strong: #2c3450;
|
||||
--border-focus: #4a5c8a;
|
||||
|
||||
--text-primary: #eeeef8;
|
||||
--text-secondary: #c0c4d8;
|
||||
--text-muted: #808498;
|
||||
--text-faint: #404860;
|
||||
--text-disabled: #202840;
|
||||
|
||||
--accent: #6a7ab8;
|
||||
--accent-dim: #252d50;
|
||||
--accent-muted: #181e38;
|
||||
--accent-fg: #a8b4e8;
|
||||
--accent-bright: #8896d0;
|
||||
}
|
||||
|
||||
/* ── Warm (sepia / amber tinted) ── */
|
||||
[data-theme="warm"] {
|
||||
--bg-void: #0c0a06;
|
||||
--bg-base: #100e08;
|
||||
--bg-surface: #16130c;
|
||||
--bg-raised: #1c1810;
|
||||
--bg-overlay: #221e14;
|
||||
--bg-subtle: #28241a;
|
||||
|
||||
--border-dim: #201c10;
|
||||
--border-base: #2c2818;
|
||||
--border-strong: #3a3420;
|
||||
--border-focus: #6a5a30;
|
||||
|
||||
--text-primary: #f5f0e0;
|
||||
--text-secondary: #d8d0b0;
|
||||
--text-muted: #988c60;
|
||||
--text-faint: #584e30;
|
||||
--text-disabled: #302a18;
|
||||
|
||||
--accent: #c0902a;
|
||||
--accent-dim: #3a2c10;
|
||||
--accent-muted: #261e0c;
|
||||
--accent-fg: #e0b860;
|
||||
--accent-bright: #d0a040;
|
||||
}
|
||||
Reference in New Issue
Block a user