[V1] Patched MangaPreview & Added Themes (Contrast)

This commit is contained in:
Youwes09
2026-02-24 18:44:19 -06:00
parent f866d4d0e9
commit fec0e5d3f6
19 changed files with 1335 additions and 329 deletions
+3 -3
View File
@@ -118,7 +118,6 @@
preBuild = '' preBuild = ''
cp -r ${frontend} ../dist cp -r ${frontend} ../dist
''; '';
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
}; };
cargoArtifacts = craneLib.buildDepsOnly commonArgs; cargoArtifacts = craneLib.buildDepsOnly commonArgs;
@@ -133,7 +132,9 @@
pkgs.gtk3 pkgs.gtk3
]}" \ ]}" \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \ --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" \
--prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" --prefix PATH : "${lib.makeBinPath [ pkgs.suwayomi-server ]}" \
--set GDK_BACKEND wayland \
--set WEBKIT_FORCE_SANDBOX 0
''; '';
}); });
@@ -157,7 +158,6 @@
xdg-utils xdg-utils
]; ];
shellHook = '' shellHook = ''
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export APPIMAGE_EXTRACT_AND_RUN=1 export APPIMAGE_EXTRACT_AND_RUN=1
export NO_STRIP=true export NO_STRIP=true
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
+11 -10
View File
@@ -63,24 +63,29 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
/// Returns the true OS-level scale factor for the main window.
/// This reads directly from the underlying winit window handle, bypassing
/// whatever value WebKitGTK happens to report to JS via window.devicePixelRatio.
/// This is the only reliable way to get the correct DPR in all launch
/// environments — tauri dev, nix run, flatpak, etc.
#[tauri::command]
fn get_scale_factor(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
fn kill_tachidesk(app: &tauri::AppHandle) { fn kill_tachidesk(app: &tauri::AppHandle) {
// Kill the tracked child handle
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
let mut guard = state.0.lock().unwrap(); let mut guard = state.0.lock().unwrap();
if let Some(child) = guard.take() { if let Some(child) = guard.take() {
let _ = child.kill(); let _ = child.kill();
println!("Killed tracked server child."); println!("Killed tracked server child.");
} }
// Belt-and-suspenders: the JVM registers its process name as "tachidesk",
// not "tachidesk-server", so pkill must target the short name.
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill")
.arg("-f") .arg("-f")
.arg("tachidesk") .arg("tachidesk")
.status(); .status();
} }
/// Spawn the server. Guards against double-spawn — if a child is already
/// tracked, this is a no-op (handles React StrictMode double-invoke in dev).
#[tauri::command] #[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> { fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
@@ -107,8 +112,6 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> {
} }
} }
/// Explicit kill — called from App.tsx cleanup. The window-close handler
/// below is the authoritative kill path; this is a secondary safety net.
#[tauri::command] #[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> { fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app); kill_tachidesk(&app);
@@ -124,12 +127,10 @@ pub fn run() {
get_storage_info, get_storage_info,
spawn_server, spawn_server,
kill_server, kill_server,
get_scale_factor,
]) ])
.setup(|_app| Ok(())) .setup(|_app| Ok(()))
.on_window_event(|window, event| { .on_window_event(|window, event| {
// Kill the server when the main window is actually destroyed.
// This fires reliably on close regardless of whether the JS
// cleanup callback ran.
if let WindowEvent::Destroyed = event { if let WindowEvent::Destroyed = event {
kill_tachidesk(window.app_handle()); kill_tachidesk(window.app_handle());
} }
+5
View File
@@ -85,6 +85,11 @@ export default function App() {
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
}, [settings.uiScale]); }, [settings.uiScale]);
useEffect(() => {
const theme = settings.theme ?? "dark";
document.documentElement.setAttribute("data-theme", theme);
}, [settings.theme]);
useEffect(() => { useEffect(() => {
const p = (e: MouseEvent) => e.preventDefault(); const p = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", p); document.addEventListener("contextmenu", p);
+54
View File
@@ -384,4 +384,58 @@
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); 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);
} }
+39 -8
View File
@@ -24,6 +24,18 @@ function frecencyScore(readAt: number, count: number): number {
function GhostCard() { return <div className={s.ghostCard} aria-hidden />; } function GhostCard() { return <div className={s.ghostCard} aria-hidden />; }
const GHOST_COUNT = 3; 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 }) { function SkeletonRow({ count = 8 }: { count?: number }) {
return ( 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 ─────────────────────────────────────────────────────────────────── // ── Section ───────────────────────────────────────────────────────────────────
function Section({ function Section({
@@ -416,8 +444,8 @@ function ExploreFeed() {
{(continueReading.length > 0 || loadingLib) && ( {(continueReading.length > 0 || loadingLib) && (
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}> <Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row}> <div className={s.row} onWheel={handleRowWheel}>
{continueReading.map(({ manga, chapterName, progress }) => ( {continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => (
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)} <MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
))} ))}
@@ -428,8 +456,8 @@ function ExploreFeed() {
{(recommended.length > 0 || loadingLib) && ( {(recommended.length > 0 || loadingLib) && (
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}> <Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
<div className={s.row}> <div className={s.row} onWheel={handleRowWheel}>
{recommended.map((m) => ( {recommended.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, 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}`} />)} {Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
@@ -446,8 +474,8 @@ function ExploreFeed() {
{sources.length === 0 ? ( {sources.length === 0 ? (
<div className={s.noSource}>No sources installed. Add extensions first.</div> <div className={s.noSource}>No sources installed. Add extensions first.</div>
) : ( ) : (
<div className={s.row}> <div className={s.row} onWheel={handleRowWheel}>
{popularManga.map((m) => ( {popularManga.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, 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}`} />)} {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; if (!isLoading && items.length === 0) return null;
return ( return (
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}> <Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
<div className={s.row}> <div className={s.row} onWheel={handleRowWheel}>
{items.map((m) => ( {items.slice(0, ROW_CAP).map((m) => (
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, 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}`} />)} {Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
</div> </div>
</Section> </Section>
@@ -133,4 +133,44 @@
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--text-xs); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); 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;
} }
+168 -54
View File
@@ -1,14 +1,37 @@
import { useEffect, useState, useMemo, useRef, memo } from "react"; import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; 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 { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
import s from "./GenreDrillPage.module.css"; 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 CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
return ( return (
@@ -21,6 +44,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string;
); );
}); });
// ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() { export default function GenreDrillPage() {
const genre = useStore((st) => st.genreFilter); const genre = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter); const setGenreFilter = useStore((st) => st.setGenreFilter);
@@ -30,12 +54,17 @@ export default function GenreDrillPage() {
const addFolder = useStore((st) => st.addFolder); const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const [libraryManga, setLibraryManga] = useState<Manga[]>([]); const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]); const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingLibrary, setLoadingLibrary] = useState(true); const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingSources, setLoadingSources] = 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 [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(() => { useEffect(() => {
if (!genre) return; if (!genre) return;
@@ -44,11 +73,15 @@ export default function GenreDrillPage() {
const ctrl = new AbortController(); const ctrl = new AbortController();
abortRef.current = ctrl; abortRef.current = ctrl;
setLoadingLibrary(true); setLoadingInitial(true);
setLoadingSources(true);
setSourceManga([]); 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, () => cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([ Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), 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])); const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
return all.mangas.nodes.map((m) => libMap.get(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); }) .then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.finally(() => setLoadingLibrary(false)); .catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources ──────────────────────────────────────────────────────────── // ── Sources: stream results in as each source responds ────────────────
const preferredLang = settings.preferredExtensionLang || "en";
cache.get(CACHE_KEYS.SOURCES, () => cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes, preferredLang)) .then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
).then((allSources) => { ).then(async (allSources) => {
// Use ALL deduped sources for drill pages (not just frecency top 4) const sources = allSources.slice(0, MAX_SOURCES);
// Cap at 8 to avoid hammering the server too hard sourcesRef.current = sources;
const sourcesToQuery = allSources.slice(0, 8); // Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
return cache.get(CACHE_KEYS.GENRE(genre), () => for (const src of sources) nextPageRef.current.set(src.id, -1);
Promise.allSettled(
// Fetch page 1 and page 2 from each source for a fuller result set await runConcurrent(sources, async (src) => {
sourcesToQuery.flatMap((src) => if (ctrl.signal.aborted) return;
[1, 2].map((page) => const pageItems: Manga[] = [];
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { for (let page = 1; page <= INITIAL_PAGES; page++) {
source: src.id, type: "SEARCH", page, query: genre, if (ctrl.signal.aborted) return;
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas) try {
) const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
) FETCH_SOURCE_MANGA,
).then((results) => { { source: src.id, type: "SEARCH", page, query: genre },
const merged: Manga[] = []; ctrl.signal,
for (const r of results) );
if (r.status === "fulfilled") merged.push(...r.value); pageItems.push(...d.fetchSourceManga.mangas);
return dedupeMangaByTitle(merged); if (!d.fetchSourceManga.hasNextPage) {
}) nextPageRef.current.set(src.id, -1);
); break;
}) } else if (page === INITIAL_PAGES) {
.then((manga) => { if (!ctrl.signal.aborted) setSourceManga(manga); }) // Has more pages beyond what we fetched upfront — mark for "load more"
.catch((e) => { if (e?.name !== "AbortError") console.error(e); }) nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
.finally(() => { if (!ctrl.signal.aborted) setLoadingSources(false); }); }
} 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(); }; return () => { ctrl.abort(); };
}, [genre]); }, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ────────────────────────────────────────────────────
const filtered = useMemo(() => { const filtered = useMemo(() => {
// Library manga: only include if genre matches (we have full metadata)
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); 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 libIds = new Set(libMatches.map((m) => m.id));
const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcAll]); return dedupeMangaById([...libMatches, ...srcAll]);
}, [libraryManga, sourceManga, genre]); }, [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) { function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m }); 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 ( return (
<div className={s.root}> <div className={s.root}>
@@ -154,25 +254,30 @@ export default function GenreDrillPage() {
<span>Back</span> <span>Back</span>
</button> </button>
<span className={s.title}>{genre}</span> <span className={s.title}>{genre}</span>
{loadingSources && !loadingLibrary && filtered.length > 0 && ( {loadingInitial && filtered.length === 0 ? null : (
<span className={s.loadingHint}>Loading more</span> <span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
</span>
)}
{!loadingInitial && hasMoreNetwork && (
<span className={s.loadingHint}>More loading</span>
)} )}
</div> </div>
{showSkeleton ? ( {loadingInitial && filtered.length === 0 ? (
<div className={s.grid}> <div className={s.grid}>
{Array.from({ length: 24 }).map((_, i) => ( {Array.from({ length: 50 }).map((_, i) => (
<div key={i} className={s.cardSkeleton}> <div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} /> <div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} /> <div className={["skeleton", s.titleSkeleton].join(" ")} />
</div> </div>
))} ))}
</div> </div>
) : filtered.length === 0 && !loadingSources ? ( ) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{genre}".</div> <div className={s.empty}>No manga found for "{genre}".</div>
) : ( ) : (
<div className={s.grid}> <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)}> <button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
<div className={s.coverWrap}> <div className={s.coverWrap}>
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} /> <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> <p className={s.cardTitle}>{m.title}</p>
</button> </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> </div>
)} )}
@@ -359,6 +359,16 @@
background: var(--bg-raised); color: var(--text-faint); 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 ──────────────────────────────────────────────────────── */ /* ── Metadata table ──────────────────────────────────────────────────────── */
.metaTable { .metaTable {
display: flex; flex-direction: column; gap: 1px; display: flex; flex-direction: column; gap: 1px;
+15 -1
View File
@@ -17,6 +17,7 @@ export default function MangaPreview() {
const setPreviewManga = useStore((st) => st.setPreviewManga); const setPreviewManga = useStore((st) => st.setPreviewManga);
const setActiveManga = useStore((st) => st.setActiveManga); const setActiveManga = useStore((st) => st.setActiveManga);
const setNavPage = useStore((st) => st.setNavPage); const setNavPage = useStore((st) => st.setNavPage);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const openReader = useStore((st) => st.openReader); const openReader = useStore((st) => st.openReader);
const addToast = useStore((st) => st.addToast); const addToast = useStore((st) => st.addToast);
const folders = useStore((st) => st.settings.folders); const folders = useStore((st) => st.settings.folders);
@@ -476,7 +477,20 @@ export default function MangaPreview() {
{/* ── Genre tags ── */} {/* ── Genre tags ── */}
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && ( {!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
<div className={s.genres}> <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> </div>
)} )}
+188 -134
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import logoUrl from "../../assets/moku-icon.svg"; import logoUrl from "../../assets/moku-icon.svg";
export type SplashMode = "loading" | "idle"; export type SplashMode = "loading" | "idle";
@@ -9,7 +10,7 @@ interface Props {
ringFull?: boolean; ringFull?: boolean;
failed?: boolean; failed?: boolean;
showCards?: boolean; showCards?: boolean;
showFps?: boolean; // only passed from devSplash showFps?: boolean;
onReady?: () => void; onReady?: () => void;
onRetry?: () => void; onRetry?: () => void;
onDismiss?: () => void; onDismiss?: () => void;
@@ -22,21 +23,13 @@ function hash(n: number): number {
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff; return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
} }
// ── Dimensions ──────────────────────────────────────────────────────────────── // ── Card definition ───────────────────────────────────────────────────────────
// 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 ───
interface CardDef { interface CardDef {
layer: 0 | 1 | 2; layer: 0 | 1 | 2;
cx: number; cx: number;
w: number; w: number;
h: number; h: number;
lines: number; // 13, stored once, used by both stamp builder & (future) draw lines: number;
alpha: number; alpha: number;
speed: number; speed: number;
cycleSec: number; cycleSec: number;
@@ -47,15 +40,20 @@ interface CardDef {
tilt: number; tilt: number;
} }
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
const LAYER_CFG = [ const LAYER_CFG = [
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 }, { wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 }, { wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
] as const; ] as const;
const CARDS: CardDef[] = (() => { const BUF = 80;
const out: CardDef[] = []; const COLS = 14;
const laneW = VW / COLS;
function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } {
const cards: CardDef[] = [];
const laneW = vw / COLS;
for (let layer = 0; layer < 3; layer++) { for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer]; const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) { for (let col = 0; col < COLS; col++) {
@@ -65,49 +63,31 @@ const CARDS: CardDef[] = (() => {
const maxNudge = (laneW - w) / 2 - 2; const maxNudge = (laneW - w) / 2 - 2;
const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge); 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 speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
const travel = VH + h + BUF; const travel = vh + h + BUF;
out.push({ cards.push({
layer: layer as 0 | 1 | 2, layer: layer as 0 | 1 | 2,
cx, w, h, cx, w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3), // same seed+7 always lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha, alpha: cfg.alpha,
speed, speed,
cycleSec: travel / speed, cycleSec: travel / speed,
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
travel, travel,
yStart: VH + h / 2 + BUF / 2, yStart: vh + h / 2 + BUF / 2,
angleStart:hash(seed + 3) * 50 - 25, angleStart: hash(seed + 3) * 50 - 25,
tilt: (hash(seed + 4) * 2 - 1) * 18, 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 ──────────────────────────────────────── // ── Rounded rect ──────────────────────────────────────────────────────────────
// 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 ──────────────────────────────────────────────────
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + r, y); ctx.moveTo(x + r, y);
@@ -118,78 +98,78 @@ function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h
ctx.closePath(); ctx.closePath();
} }
// ── Stamp builder — runs ONCE at module init ────────────────────────────────── // ── Stamp builder ─────────────────────────────────────────────────────────────
// Each card is pre-rendered at full opacity to a tiny offscreen canvas.
// Hot path does zero path ops — just globalAlpha + drawImage per card.
const STAMP_PAD = 6; const STAMP_PAD = 6;
const STAMPS: HTMLCanvasElement[] = (() => { function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
if (typeof document === "undefined") return []; const logW = Math.ceil(c.w + STAMP_PAD * 2);
return CARDS.map(c => { const logH = Math.ceil(c.h + STAMP_PAD * 2);
const oc = document.createElement("canvas"); const oc = document.createElement("canvas");
oc.width = Math.ceil(c.w + STAMP_PAD * 2); oc.width = Math.round(logW * dpr);
oc.height = Math.ceil(c.h + STAMP_PAD * 2); oc.height = Math.round(logH * dpr);
const ctx = oc.getContext("2d")!; const ctx = oc.getContext("2d")!;
const x0 = STAMP_PAD; ctx.scale(dpr, dpr);
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;
// Shadow const x0 = STAMP_PAD;
ctx.fillStyle = "rgba(0,0,0,0.5)"; const y0 = STAMP_PAD;
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); const coverH = (c.w * 0.72) * 1.05;
const lineY0 = y0 + 3 + coverH + 5;
// Body ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
// Border ctx.fillStyle = "rgba(255,255,255,0.07)";
ctx.strokeStyle = "rgba(255,255,255,0.75)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
ctx.lineWidth = 1.2;
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
// Cover area ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.fillStyle = "rgba(255,255,255,0.15)"; ctx.lineWidth = 1.2;
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
// Cover tint band ctx.fillStyle = "rgba(255,255,255,0.15)";
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
// Text lines — use c.lines (same value as buildCards computed) ctx.fillStyle = "rgba(255,255,255,0.08)";
for (let li = 0; li < c.lines; li++) { rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
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);
}
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 ───────────────────────────────────────────────── return oc;
const VIGNETTE: HTMLCanvasElement | null = (() => { }
if (typeof document === "undefined") return null;
// ── Vignette builder ──────────────────────────────────────────────────────────
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas"); 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 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(0.15, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,0,0,0.82)"); g.addColorStop(1, "rgba(0,0,0,0.82)");
ctx.fillStyle = g; ctx.fillStyle = g;
ctx.fillRect(0, 0, VW, VH); ctx.fillRect(0, 0, vw, vh);
return oc; return oc;
})(); }
// ── Draw frame — hot path ───────────────────────────────────────────────────── // ── Draw frame ────────────────────────────────────────────────────────────────
// Uses setTransform() instead of manual translate/rotate undo. function drawFrame(
// setTransform sets the full matrix in one call — no floating-point drift, ctx: CanvasRenderingContext2D,
// no stack push/pop, one fewer operation than save+restore. t: number,
function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number) { cw: number,
ch: number,
dpr: number,
cards: CardDef[],
trigs: CardTrig[],
stamps: HTMLCanvasElement[],
vignette: HTMLCanvasElement,
) {
ctx.clearRect(0, 0, cw, ch); ctx.clearRect(0, 0, cw, ch);
for (let i = 0; i < CARDS.length; i++) { for (let i = 0; i < cards.length; i++) {
const c = CARDS[i]; const c = cards[i];
const p = ((t / c.cycleSec) + c.phase) % 1; const p = ((t / c.cycleSec) + c.phase) % 1;
const alpha = p < 0.07 const alpha = p < 0.07
@@ -200,27 +180,28 @@ function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: num
if (alpha < 0.005) continue; if (alpha < 0.005) continue;
const cy = c.yStart - p * c.travel; const cy = c.yStart - p * c.travel;
const tg = trigs[i];
// Compose base rotation with tilt delta using trig identity — const delta = tg.tiltRad * p;
// 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 cosDelta = Math.cos(delta); const cosDelta = Math.cos(delta);
const sinDelta = Math.sin(delta); const sinDelta = Math.sin(delta);
const cos = tg.cosA * cosDelta - tg.sinA * sinDelta; const cos = tg.cosA * cosDelta - tg.sinA * sinDelta;
const sin = tg.sinA * cosDelta + tg.cosA * sinDelta; const sin = tg.sinA * cosDelta + tg.cosA * sinDelta;
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
// setTransform(a,b,c,d,e,f) = [cos,sin,-sin,cos,tx,ty] ctx.setTransform(
ctx.setTransform(cos, sin, -sin, cos, c.cx, cy); cos * dpr, sin * dpr,
ctx.drawImage(STAMPS[i], -c.w / 2 - STAMP_PAD, -c.h / 2 - STAMP_PAD); -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.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
if (VIGNETTE) ctx.drawImage(VIGNETTE, 0, 0, cw, ch); ctx.drawImage(vignette, 0, 0, cw, ch);
} }
// ── Ring ────────────────────────────────────────────────────────────────────── // ── Ring ──────────────────────────────────────────────────────────────────────
@@ -243,10 +224,10 @@ function Ring({ progress }: { progress: number }) {
); );
} }
// ── FPS counter — only mounted when showFps=true (devSplash only) ───────────── // ── FPS counter ───────────────────────────────────────────────────────────────
function FpsCounter() { function FpsCounter() {
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const times = useRef<number[]>([]); const times = useRef<number[]>([]);
useEffect(() => { useEffect(() => {
let raf = 0; 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 }) { function CardCanvas({ showFps }: { showFps: boolean }) {
const ref = useRef<HTMLCanvasElement>(null); const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
const canvas = ref.current; const canvas = ref.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false }); const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
if (!ctx) return; if (!ctx) return;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// Keep canvas resolution in sync with its CSS size let cancelled = false;
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 raf = 0, t0 = -1; async function init() {
function frame(now: number) { // Prefer the Tauri-sourced scale factor; fall back to the JS value
if (t0 < 0) t0 = now; // when running outside Tauri (e.g. vite dev server in a browser).
drawFrame(ctx!, (now - t0) / 1000, canvas!.width, canvas!.height); 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); 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 () => { return () => {
cancelAnimationFrame(raf); cancelled = true;
ro.disconnect(); (canvas as any).__splashCleanup?.();
}; };
}, []); }, []);
@@ -364,7 +419,6 @@ export default function SplashScreen({
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// Idle dismiss: keydown / mousedown / touchstart only — NO mousemove
useEffect(() => { useEffect(() => {
if (mode !== "idle" || !onDismiss) return; if (mode !== "idle" || !onDismiss) return;
function handler() { triggerExit(onDismiss); } function handler() { triggerExit(onDismiss); }
@@ -475,4 +475,154 @@
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08); background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2); 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);
} }
+151 -77
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react"; import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import type { Manga, Source, Chapter } from "../../lib/types"; import type { Manga, Source, Chapter } from "../../lib/types";
@@ -18,20 +18,33 @@ interface Match {
manga: Manga; manga: Manga;
chapters: Chapter[]; chapters: Chapter[];
readCount: number; 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) { export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
const [step, setStep] = useState<Step>("source"); const [step, setStep] = useState<Step>("source");
const [sources, setSources] = useState<Source[]>([]); const [sources, setSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(true); const [loadingSources, setLoadingSources] = useState(true);
const [selectedSource, setSelectedSource] = useState<Source | null>(null); const [selectedSource, setSelectedSource] = useState<Source | null>(null);
const [query, setQuery] = useState(manga.title); const [query, setQuery] = useState(manga.title);
const [results, setResults] = useState<Manga[]>([]); const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null); const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
const [loadingMatch, setLoadingMatch] = useState(false); const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
const [migrating, setMigrating] = useState(false); const [migrating, setMigrating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
@@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
.finally(() => setLoadingSources(false)); .finally(() => setLoadingSources(false));
}, []); }, []);
async function searchSource() { const searchSource = useCallback(async (src: Source, q: string) => {
if (!selectedSource || !query.trim()) return; if (!src || !q.trim()) return;
setSearching(true); setSearching(true);
setResults([]); setResults([]);
setError(null); setError(null);
try { try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { 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) { } catch (e: any) {
setError(e.message); setError(e.message);
} finally { } finally {
setSearching(false); 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) { async function selectMatch(m: Manga, similarity: number) {
setLoadingMatch(true); setLoadingMatchId(m.id);
setError(null); setError(null);
try { try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id }); 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); const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
return old?.isRead; return old?.isRead;
}).length; }).length;
setSelectedMatch({ manga: m, chapters, readCount }); setSelectedMatch({ manga: m, chapters, readCount, similarity });
setStep("confirm"); setStep("confirm");
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
} finally { } finally {
setLoadingMatch(false); setLoadingMatchId(null);
} }
} }
@@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
setError(null); setError(null);
try { try {
const { manga: newManga, chapters: newChapters } = selectedMatch; 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 oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
const toMarkRead: number[] = []; const toMarkRead: number[] = [];
@@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
if (!old) continue; if (!old) continue;
if (old.isRead) toMarkRead.push(nc.id); if (old.isRead) toMarkRead.push(nc.id);
if (old.isBookmarked) toMarkBookmarked.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! }); 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 }); await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
} if (toMarkBookmarked.length)
// Migrate bookmarks
if (toMarkBookmarked.length) {
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true }); await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
} for (const { id, lastPageRead } of progressUpdates)
// Migrate in-progress pages one by one (different lastPageRead per chapter)
for (const { id, lastPageRead } of progressUpdates) {
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead }); 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: newManga.id, inLibrary: true });
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }); 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 readCount = currentChapters.filter((c) => c.isRead).length;
const totalCount = currentChapters.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 ( return (
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}> <div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={s.modal}> <div className={s.modal}>
{/* ── Header ── */}
<div className={s.modalHeader}> <div className={s.modalHeader}>
<div className={s.modalTitle}> <div className={s.modalTitle}>
<span className={s.modalTitleLabel}>Migrate source</span> <span className={s.modalTitleLabel}>Migrate source</span>
<span className={s.modalTitleManga}>{manga.title}</span> <span className={s.modalTitleManga}>{manga.title}</span>
</div> </div>
<button className={s.closeBtn} onClick={onClose}> <button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
<X size={14} weight="light" />
</button>
</div> </div>
{/* ── Step indicators ── */} {/* ── Step indicators ── */}
<div className={s.steps}> <div className={s.steps}>
{(["source", "search", "confirm"] as Step[]).map((st, i) => ( {STEPS.map((st, i) => (
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}> <div key={st}
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span> className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span> <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> </div>
<div className={s.body}> <div className={s.body}>
{/* ── Step 1: Pick source ── */} {/* ── Step 1: Pick source ── */}
{step === "source" && ( {step === "source" && (
<div className={s.sourceList}> <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> <div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
) : ( ) : (
sources.map((src) => ( sources.map((src) => (
<button <button key={src.id}
key={src.id}
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()} 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} <img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div className={s.sourceInfo}> <div className={s.sourceInfo}>
@@ -184,22 +213,34 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
{/* ── Step 2: Search & pick match ── */} {/* ── Step 2: Search & pick match ── */}
{step === "search" && ( {step === "search" && (
<div className={s.searchStep}> <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.searchRow}>
<div className={s.searchBar}> <div className={s.searchBar}>
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} /> <MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
<input <input className={s.searchInput} value={query}
className={s.searchInput}
value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSource()} onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
autoFocus placeholder="Search title…"
/> autoFocus />
</div> </div>
<button className={s.searchBtn} onClick={searchSource} disabled={searching}> <button className={s.searchBtn}
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"} onClick={() => selectedSource && searchSource(selectedSource, query)}
</button> disabled={searching || !selectedSource}>
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}> {searching
Back ? <CircleNotch size={13} weight="light" className="anim-spin" />
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
</button> </button>
</div> </div>
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
<div className={["skeleton", s.skCover].join(" ")} /> <div className={["skeleton", s.skCover].join(" ")} />
<div className={s.skMeta}> <div className={s.skMeta}>
<div className={["skeleton", s.skTitle].join(" ")} /> <div className={["skeleton", s.skTitle].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
</div> </div>
</div> </div>
))} ))}
{!searching && results.map((m) => ( {!searching && results.map(({ manga: m, similarity }, idx) => (
<button <button key={m.id} className={s.resultRow}
key={m.id} onClick={() => selectMatch(m, similarity)}
className={s.resultRow} disabled={loadingMatchId !== null}>
onClick={() => selectMatch(m)}
disabled={loadingMatch}
>
<div className={s.resultCoverWrap}> <div className={s.resultCoverWrap}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} /> <img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
</div> </div>
<span className={s.resultTitle}>{m.title}</span> <div className={s.resultInfo}>
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />} <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> </button>
))} ))}
{!searching && results.length === 0 && query && ( {!searching && results.length === 0 && !error && (
<div className={s.centered}><span className={s.hint}>No results.</span></div> <div className={s.centered}>
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
</div>
)} )}
</div> </div>
</div> </div>
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
</div> </div>
<p className={s.confirmTitle}>{manga.title}</p> <p className={s.confirmTitle}>{manga.title}</p>
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p> <p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
<span className={s.confirmTag}>Current</span>
</div> </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.confirmManga}>
<div className={s.confirmCoverWrap}> <div className={s.confirmCoverWrap}>
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
</div> </div>
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p> <p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p> <p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
</div> </div>
</div> </div>
<div className={s.confirmStats}> <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}> <div className={s.statRow}>
<span className={s.statLabel}>Chapters on new source</span> <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>
<div className={s.statRow}> <div className={s.statRow}>
<span className={s.statLabel}>Read progress to migrate</span> <span className={s.statLabel}>Read progress to carry over</span>
<span className={s.statVal}>{readCount} / {totalCount} chapters</span> <span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
</div>
<div className={s.statRow}>
<span className={s.statLabel}>Matched chapters</span>
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
</div> </div>
</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}> <p className={s.confirmNote}>
The current entry will be removed from your library. Downloads are not transferred. The current entry will be removed from your library. Downloads are not transferred.
</p> </p>
@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}> <button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
{migrating {migrating
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating</> ? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating</>
: "Migrate"} : <><Check size={13} weight="bold" /> Migrate</>}
</button> </button>
</div> </div>
</div> </div>
+19 -1
View File
@@ -315,7 +315,25 @@
.tagGrid .skCard { width: auto; } .tagGrid .skCard { width: auto; }
.tagGrid .skCover { width: 100%; } .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 { .nsfwBadge {
font-family: var(--font-ui); font-size: var(--text-2xs); font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); padding: 1px 5px; letter-spacing: var(--tracking-wide); padding: 1px 5px;
+117 -28
View File
@@ -4,8 +4,8 @@ import {
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store"; import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
import s from "./Search.module.css"; import s from "./Search.module.css";
@@ -428,6 +428,10 @@ function KeywordTab({
// ── Tag tab ─────────────────────────────────────────────────────────────────── // ── 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({ function TagTab({
preferredLang, onMangaClick, preferredLang, onMangaClick,
}: { }: {
@@ -436,11 +440,16 @@ function TagTab({
preferredLang: string; preferredLang: string;
onMangaClick: (m: Manga) => void; onMangaClick: (m: Manga) => void;
}) { }) {
const [activeTag, setActiveTag] = useState<string | null>(null); const [activeTag, setActiveTag] = useState<string | null>(null);
const [tagResults, setTagResults] = useState<Manga[]>([]); const [tagResults, setTagResults] = useState<Manga[]>([]);
const [loadingTag, setLoadingTag] = useState(false); const [loadingTag, setLoadingTag] = useState(false);
const [tagFilter, setTagFilter] = useState(""); const [loadingMore, setLoadingMore] = useState(false);
const abortRef = useRef<AbortController | null>(null); 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(); }, []); useEffect(() => () => { abortRef.current?.abort(); }, []);
@@ -449,6 +458,8 @@ function TagTab({
setActiveTag(tag); setActiveTag(tag);
setTagResults([]); setTagResults([]);
setLoadingTag(true); setLoadingTag(true);
setVisibleCount(TAG_PAGE_SIZE);
nextPageRef.current = new Map();
abortRef.current?.abort(); abortRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -459,27 +470,44 @@ function TagTab({
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((s) => s.id !== "0")) .then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
); );
const deduped = dedupeSources(sources, preferredLang); const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
const top = getTopSources(deduped); sourcesRef.current = deduped;
const results = await cache.get(CACHE_KEYS.GENRE(tag), () => // Start all at -1; the fetch loop sets the real next page if hasNextPage is true
Promise.allSettled( for (const src of deduped) {
top.map((src) => nextPageRef.current.set(src.id, -1);
gql<{ fetchSourceManga: { mangas: Manga[] } }>( }
// 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, FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: tag }, { source: src.id, type: "SEARCH", page, query: tag },
ctrl.signal, ctrl.signal,
).then((d) => d.fetchSourceManga.mangas) );
) pageResults.push(...d.fetchSourceManga.mangas);
).then((settled) => { if (!d.fetchSourceManga.hasNextPage) {
const merged: Manga[] = []; nextPageRef.current.set(src.id, -1); // no more pages
for (const r of settled) break;
if (r.status === "fulfilled") merged.push(...r.value); } else if (page === TAG_FETCH_PAGES) {
return dedupeMangaByTitle(merged); // Still has more pages beyond what we fetched upfront
}) nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
); }
} catch (e: any) {
if (!ctrl.signal.aborted) setTagResults(results); 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) { } catch (e: any) {
if (e?.name !== "AbortError") console.error(e); if (e?.name !== "AbortError") console.error(e);
} finally { } 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 filteredGenres = useMemo(() => {
const q = tagFilter.trim().toLowerCase(); const q = tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
}, [tagFilter]); }, [tagFilter]);
const visibleResults = tagResults.slice(0, visibleCount);
const hasMore = visibleCount < tagResults.length ||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
return ( return (
<div className={s.splitRoot}> <div className={s.splitRoot}>
<div className={s.splitSidebar}> <div className={s.splitSidebar}>
@@ -531,15 +609,26 @@ function TagTab({
<span className={s.splitContentTitle}>{activeTag}</span> <span className={s.splitContentTitle}>{activeTag}</span>
{loadingTag {loadingTag
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} /> ? <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> </div>
{loadingTag ? ( {loadingTag ? (
<GridSkeleton /> <GridSkeleton count={50} />
) : tagResults.length > 0 ? ( ) : tagResults.length > 0 ? (
<div className={s.tagGrid}> <div className={s.tagGrid}>
{tagResults.map((m) => ( {visibleResults.map((m) => (
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(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>
) : ( ) : (
<div className={s.empty}> <div className={s.empty}>
+6 -1
View File
@@ -302,6 +302,7 @@ export default function SeriesDetail() {
const updateSettings = useStore((state) => state.updateSettings); const updateSettings = useStore((state) => state.updateSettings);
const addToast = useStore((state) => state.addToast); const addToast = useStore((state) => state.addToast);
const setGenreFilter = useStore((state) => state.setGenreFilter); const setGenreFilter = useStore((state) => state.setGenreFilter);
const setNavPage = useStore((state) => state.setNavPage);
const [manga, setManga] = useState<Manga | null>(null); const [manga, setManga] = useState<Manga | null>(null);
const [chapters, setChapters] = useState<Chapter[]>([]); const [chapters, setChapters] = useState<Chapter[]>([]);
@@ -733,7 +734,11 @@ export default function SeriesDetail() {
key={g} key={g}
className={[s.genre, s.genreClickable].join(" ")} className={[s.genre, s.genreClickable].join(" ")}
title={`Filter library by "${g}"`} title={`Filter library by "${g}"`}
onClick={() => setGenreFilter(g)} onClick={() => {
setGenreFilter(g);
setNavPage("explore");
setActiveManga(null);
}}
> >
{g} {g}
</button> </button>
+100
View File
@@ -458,4 +458,104 @@
.folderTabToggleOn:hover { .folderTabToggleOn:hover {
background: var(--accent-muted); background: var(--accent-muted);
color: var(--accent-fg); 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);
} }
+96 -12
View File
@@ -1,26 +1,27 @@
import { useEffect, useRef, useState, useCallback } from "react"; 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 { invoke } from "@tauri-apps/api/core";
import { gql } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_DOWNLOADS_PATH } from "../../lib/queries"; import { GET_DOWNLOADS_PATH } from "../../lib/queries";
import { useStore } from "../../store"; import { useStore } from "../../store";
import type { Folder } from "../../store"; import type { Folder } from "../../store";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds"; 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"; 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 }[] = [ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> }, { id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
{ id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> }, { id: "appearance", label: "Appearance", icon: <PaintBrush size={14} weight="light" /> },
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> }, { id: "reader", label: "Reader", icon: <Book size={14} weight="light" /> },
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> }, { id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> }, { id: "performance",label: "Performance",icon: <Sliders size={14} weight="light" /> },
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> }, { id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> }, { id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> }, { id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
{ id: "devtools", label: "Dev Tools", icon: <Wrench 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 ──────────────────────────────────────────────────────────────── // ── 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() { function DevToolsTab() {
const [splashTriggered, setSplashTriggered] = useState(false); const [splashTriggered, setSplashTriggered] = useState(false);
@@ -840,6 +923,7 @@ export default function SettingsModal() {
</div> </div>
<div className={s.contentBody} ref={contentBodyRef}> <div className={s.contentBody} ref={contentBodyRef}>
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />} {tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
{tab === "appearance" && <AppearanceTab settings={settings} update={updateSettings} />}
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />} {tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />} {tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />} {tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
+10
View File
@@ -9,6 +9,13 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string; // str
export type NavPage = "library" | "sources" | "explore" | "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";
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 { export interface HistoryEntry {
mangaId: number; mangaId: number;
@@ -71,6 +78,8 @@ export interface Settings {
folders: Folder[]; folders: Folder[];
/** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */ /** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */
readerDebounceMs: number; readerDebounceMs: number;
/** UI colour theme. Applied as data-theme on <html>. */
theme: Theme;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -102,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
storageLimitGb: null, storageLimitGb: null,
folders: [], folders: [],
readerDebounceMs: 120, readerDebounceMs: 120,
theme: "dark",
}; };
interface Store { interface Store {
+153
View File
@@ -106,4 +106,157 @@
--t-fast: 0.08s ease; --t-fast: 0.08s ease;
--t-base: 0.14s ease; --t-base: 0.14s ease;
--t-slow: 0.22s 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;
} }