From fec0e5d3f6458bfd599e642b4087180e3ca987da Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 24 Feb 2026 18:44:19 -0600 Subject: [PATCH] [V1] Patched MangaPreview & Added Themes (Contrast) --- flake.nix | 6 +- src-tauri/src/lib.rs | 21 +- src/App.tsx | 5 + src/components/explore/Explore.module.css | 54 +++ src/components/explore/Explore.tsx | 47 ++- .../explore/GenreDrillPage.module.css | 40 +++ src/components/explore/GenreDrillPage.tsx | 222 +++++++++--- .../explore/MangaPreview.module.css | 10 + src/components/explore/MangaPreview.tsx | 16 +- src/components/layout/SplashScreen.tsx | 322 ++++++++++-------- src/components/pages/MigrateModal.module.css | 150 ++++++++ src/components/pages/MigrateModal.tsx | 228 ++++++++----- src/components/pages/Search.module.css | 20 +- src/components/pages/Search.tsx | 145 ++++++-- src/components/pages/SeriesDetail.tsx | 7 +- src/components/settings/Settings.module.css | 100 ++++++ src/components/settings/Settings.tsx | 108 +++++- src/store/index.ts | 10 + src/styles/tokens.css | 153 +++++++++ 19 files changed, 1335 insertions(+), 329 deletions(-) diff --git a/flake.nix b/flake.nix index b06b694..225ed9d 100644 --- a/flake.nix +++ b/flake.nix @@ -118,7 +118,6 @@ preBuild = '' cp -r ${frontend} ../dist ''; - WEBKIT_DISABLE_COMPOSITING_MODE = "1"; }; cargoArtifacts = craneLib.buildDepsOnly commonArgs; @@ -133,7 +132,9 @@ pkgs.gtk3 ]}" \ --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 ]; shellHook = '' - export WEBKIT_DISABLE_COMPOSITING_MODE=1 export APPIMAGE_EXTRACT_AND_RUN=1 export NO_STRIP=true export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dbe752..88cef93 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,24 +63,29 @@ fn get_storage_info(downloads_path: String) -> Result { }) } +/// 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) { - // Kill the tracked child handle let state = app.state::(); let mut guard = state.0.lock().unwrap(); if let Some(child) = guard.take() { let _ = child.kill(); 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") .arg("-f") .arg("tachidesk") .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] fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); @@ -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] fn kill_server(app: tauri::AppHandle) -> Result<(), String> { kill_tachidesk(&app); @@ -124,12 +127,10 @@ pub fn run() { get_storage_info, spawn_server, kill_server, + get_scale_factor, ]) .setup(|_app| Ok(())) .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 { kill_tachidesk(window.app_handle()); } diff --git a/src/App.tsx b/src/App.tsx index e489e39..1481276 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -85,6 +85,11 @@ export default function App() { document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`; }, [settings.uiScale]); + useEffect(() => { + const theme = settings.theme ?? "dark"; + document.documentElement.setAttribute("data-theme", theme); + }, [settings.theme]); + useEffect(() => { const p = (e: MouseEvent) => e.preventDefault(); document.addEventListener("contextmenu", p); diff --git a/src/components/explore/Explore.module.css b/src/components/explore/Explore.module.css index 24c7ce6..08d64fc 100644 --- a/src/components/explore/Explore.module.css +++ b/src/components/explore/Explore.module.css @@ -384,4 +384,58 @@ font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); +} +/* ── Explore More end-cap card ───────────────────────────────────────────── */ +.exploreMoreCard { + flex-shrink: 0; + width: 110px; + aspect-ratio: 2 / 3; + border-radius: var(--radius-md); + border: 1px dashed var(--border-strong); + background: var(--bg-raised); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color var(--t-base), background var(--t-base); + padding: 0; +} +.exploreMoreCard:hover { + border-color: var(--accent); + background: var(--accent-muted); +} +.exploreMoreCard:hover .exploreMoreIcon { color: var(--accent-fg); } +.exploreMoreCard:hover .exploreMoreLabel { color: var(--accent-fg); } + +.exploreMoreInner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3); + pointer-events: none; +} + +.exploreMoreIcon { + color: var(--text-faint); + transition: color var(--t-base); +} + +.exploreMoreLabel { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--text-faint); + transition: color var(--t-base); + text-align: center; +} + +.exploreMoreGenre { + font-size: var(--text-2xs); + color: var(--text-faint); + opacity: 0.6; + text-align: center; + font-family: var(--font-ui); + letter-spacing: var(--tracking-wide); } \ No newline at end of file diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx index 551b43a..acd7ac6 100644 --- a/src/components/explore/Explore.tsx +++ b/src/components/explore/Explore.tsx @@ -24,6 +24,18 @@ function frecencyScore(readAt: number, count: number): number { function GhostCard() { return
; } const GHOST_COUNT = 3; +const ROW_CAP = 25; + +// Hijack vertical wheel delta → horizontal scroll on .row divs +function handleRowWheel(e: React.WheelEvent) { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const el = e.currentTarget; + const canScrollLeft = el.scrollLeft > 0; + const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1; + if (!canScrollLeft && !canScrollRight) return; + e.stopPropagation(); + el.scrollLeft += e.deltaY; +} function SkeletonRow({ count = 8 }: { count?: number }) { return ( @@ -80,6 +92,22 @@ const MiniCard = memo(function MiniCard({ ); }); +// ── Explore More end-cap ────────────────────────────────────────────────────── + +const ExploreMoreCard = memo(function ExploreMoreCard({ + genre, onClick, +}: { genre: string; onClick: () => void }) { + return ( + + ); +}); + // ── Section ─────────────────────────────────────────────────────────────────── function Section({ @@ -416,8 +444,8 @@ function ExploreFeed() { {(continueReading.length > 0 || loadingLib) && (
} loading={loadingLib}> -
- {continueReading.map(({ manga, chapterName, progress }) => ( +
+ {continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => ( openManga(manga)} onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> ))} @@ -428,8 +456,8 @@ function ExploreFeed() { {(recommended.length > 0 || loadingLib) && (
} loading={loadingLib}> -
- {recommended.map((m) => ( +
+ {recommended.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => )} @@ -446,8 +474,8 @@ function ExploreFeed() { {sources.length === 0 ? (
No sources installed. Add extensions first.
) : ( -
- {popularManga.map((m) => ( +
+ {popularManga.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => )} @@ -462,10 +490,13 @@ function ExploreFeed() { if (!isLoading && items.length === 0) return null; return (
setGenreFilter(genre)} loading={isLoading}> -
- {items.map((m) => ( +
+ {items.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} + {items.length >= ROW_CAP && ( + setGenreFilter(genre)} /> + )} {Array.from({ length: GHOST_COUNT }).map((_, i) => )}
diff --git a/src/components/explore/GenreDrillPage.module.css b/src/components/explore/GenreDrillPage.module.css index c2f3ccd..f3a79bc 100644 --- a/src/components/explore/GenreDrillPage.module.css +++ b/src/components/explore/GenreDrillPage.module.css @@ -133,4 +133,44 @@ font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); +} + +.resultCount { + margin-left: auto; + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +/* Show more — spans full grid width */ +.showMoreCell { + grid-column: 1 / -1; + display: flex; + justify-content: center; + padding: var(--sp-2) 0 var(--sp-4); +} + +.showMoreBtn { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 7px 20px; + border-radius: var(--radius-md); + background: var(--bg-raised); + color: var(--text-muted); + border: 1px solid var(--border-dim); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base); +} +.showMoreBtn:hover:not(:disabled) { + color: var(--text-secondary); + border-color: var(--border-strong); +} +.showMoreBtn:disabled { + opacity: 0.5; + cursor: default; } \ No newline at end of file diff --git a/src/components/explore/GenreDrillPage.tsx b/src/components/explore/GenreDrillPage.tsx index 7f3a41c..36328dc 100644 --- a/src/components/explore/GenreDrillPage.tsx +++ b/src/components/explore/GenreDrillPage.tsx @@ -1,14 +1,37 @@ -import { useEffect, useState, useMemo, useRef, memo } from "react"; -import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; +import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react"; +import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { cache, CACHE_KEYS } from "../../lib/cache"; -import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/sourceUtils"; +import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; import { useStore } from "../../store"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import type { Manga, Source } from "../../lib/types"; import s from "./GenreDrillPage.module.css"; +// ── Constants ────────────────────────────────────────────────────────────────── +const PAGE_SIZE = 50; // how many items to show at once +const INITIAL_PAGES = 3; // source API pages to fetch upfront per source +const MAX_SOURCES = 12; // max sources to query concurrently +const CONCURRENCY = 4; // parallel source fetches + +async function runConcurrent( + items: T[], + fn: (item: T) => Promise, + signal: AbortSignal, +): Promise { + let i = 0; + async function worker() { + while (i < items.length) { + if (signal.aborted) return; + const item = items[i++]; + await fn(item).catch(() => {}); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); +} + +// ── CoverImg ────────────────────────────────────────────────────────────────── const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) { const [loaded, setLoaded] = useState(false); return ( @@ -21,6 +44,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; ); }); +// ── GenreDrillPage ──────────────────────────────────────────────────────────── export default function GenreDrillPage() { const genre = useStore((st) => st.genreFilter); const setGenreFilter = useStore((st) => st.setGenreFilter); @@ -30,12 +54,17 @@ export default function GenreDrillPage() { const addFolder = useStore((st) => st.addFolder); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); - const [libraryManga, setLibraryManga] = useState([]); - const [sourceManga, setSourceManga] = useState([]); - const [loadingLibrary, setLoadingLibrary] = useState(true); - const [loadingSources, setLoadingSources] = useState(true); + const [libraryManga, setLibraryManga] = useState([]); + const [sourceManga, setSourceManga] = useState([]); + const [loadingInitial, setLoadingInitial] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - const abortRef = useRef(null); + + // Per-source next-page tracker; -1 means exhausted + const nextPageRef = useRef>(new Map()); + const sourcesRef = useRef([]); + const abortRef = useRef(null); useEffect(() => { if (!genre) return; @@ -44,11 +73,15 @@ export default function GenreDrillPage() { const ctrl = new AbortController(); abortRef.current = ctrl; - setLoadingLibrary(true); - setLoadingSources(true); + setLoadingInitial(true); setSourceManga([]); + setLibraryManga([]); + setVisibleCount(PAGE_SIZE); + nextPageRef.current = new Map(); - // ── Library ──────────────────────────────────────────────────────────── + const preferredLang = settings.preferredExtensionLang || "en"; + + // ── Library (fire-and-forget, doesn't block skeleton removal) ───────── cache.get(CACHE_KEYS.LIBRARY, () => Promise.all([ gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), @@ -57,55 +90,122 @@ export default function GenreDrillPage() { const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m])); return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m); }) - ).then(setLibraryManga) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) - .finally(() => setLoadingLibrary(false)); + ) + .then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); - // ── Sources ──────────────────────────────────────────────────────────── - const preferredLang = settings.preferredExtensionLang || "en"; + // ── Sources: stream results in as each source responds ──────────────── cache.get(CACHE_KEYS.SOURCES, () => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => dedupeSources(d.sources.nodes, preferredLang)) - ).then((allSources) => { - // Use ALL deduped sources for drill pages (not just frecency top 4) - // Cap at 8 to avoid hammering the server too hard - const sourcesToQuery = allSources.slice(0, 8); - return cache.get(CACHE_KEYS.GENRE(genre), () => - Promise.allSettled( - // Fetch page 1 and page 2 from each source for a fuller result set - sourcesToQuery.flatMap((src) => - [1, 2].map((page) => - gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page, query: genre, - }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) - ) - ) - ).then((results) => { - const merged: Manga[] = []; - for (const r of results) - if (r.status === "fulfilled") merged.push(...r.value); - return dedupeMangaByTitle(merged); - }) - ); - }) - .then((manga) => { if (!ctrl.signal.aborted) setSourceManga(manga); }) - .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) - .finally(() => { if (!ctrl.signal.aborted) setLoadingSources(false); }); + .then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)) + ).then(async (allSources) => { + const sources = allSources.slice(0, MAX_SOURCES); + sourcesRef.current = sources; + // Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page + for (const src of sources) nextPageRef.current.set(src.id, -1); + + await runConcurrent(sources, async (src) => { + if (ctrl.signal.aborted) return; + const pageItems: Manga[] = []; + for (let page = 1; page <= INITIAL_PAGES; page++) { + if (ctrl.signal.aborted) return; + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: genre }, + ctrl.signal, + ); + pageItems.push(...d.fetchSourceManga.mangas); + if (!d.fetchSourceManga.hasNextPage) { + nextPageRef.current.set(src.id, -1); + break; + } else if (page === INITIAL_PAGES) { + // Has more pages beyond what we fetched upfront — mark for "load more" + nextPageRef.current.set(src.id, INITIAL_PAGES + 1); + } + } catch (e: any) { + if (e?.name === "AbortError") return; + nextPageRef.current.set(src.id, -1); + break; + } + } + if (!ctrl.signal.aborted && pageItems.length > 0) { + // Dedupe by ID only — title dedup across sources is too aggressive and collapses + // legitimate different-source results that share a common title (e.g. "Action" genre) + setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems])); + // Drop the skeleton as soon as we have anything + setLoadingInitial(false); + } + }, ctrl.signal); + + if (!ctrl.signal.aborted) setLoadingInitial(false); + }).catch((e) => { + if (e?.name !== "AbortError") console.error(e); + if (!ctrl.signal.aborted) setLoadingInitial(false); + }); return () => { ctrl.abort(); }; - }, [genre]); + }, [genre]); // eslint-disable-line react-hooks/exhaustive-deps + // ── Derived merged list ──────────────────────────────────────────────────── const filtered = useMemo(() => { - // Library manga: only include if genre matches (we have full metadata) const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); - // Source manga: include ALL results — they came from a genre search, - // but the API often returns no genre tags in the brief response payload. - // De-duplicate against library matches by id. const libIds = new Set(libMatches.map((m) => m.id)); const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); return dedupeMangaById([...libMatches, ...srcAll]); }, [libraryManga, sourceManga, genre]); + // ── Load more ────────────────────────────────────────────────────────────── + const hasMoreVisible = visibleCount < filtered.length; + const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); + const hasMore = hasMoreVisible || hasMoreNetwork; + + const loadMore = useCallback(async () => { + if (loadingMore) return; + + // If there are buffered results, just reveal the next page + if (hasMoreVisible) { + setVisibleCount((v) => v + PAGE_SIZE); + return; + } + + // Fetch next pages from network + const sources = sourcesRef.current.filter( + (src) => (nextPageRef.current.get(src.id) ?? -1) > 0 + ); + if (!sources.length) return; + + setLoadingMore(true); + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + await runConcurrent(sources, async (src) => { + const page = nextPageRef.current.get(src.id)!; + if (ctrl.signal.aborted) return; + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: genre }, + ctrl.signal, + ); + nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); + if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) + setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); + } catch (e: any) { + if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); + } + }, ctrl.signal); + } finally { + if (!ctrl.signal.aborted) { + setVisibleCount((v) => v + PAGE_SIZE); + setLoadingMore(false); + } + } + }, [loadingMore, hasMoreVisible, genre]); + + // ── Context menu ────────────────────────────────────────────────────────── function openCtx(e: React.MouseEvent, m: Manga) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, manga: m }); @@ -144,7 +244,7 @@ export default function GenreDrillPage() { ]; } - const showSkeleton = loadingLibrary && filtered.length === 0; + const visibleItems = filtered.slice(0, visibleCount); return (
@@ -154,25 +254,30 @@ export default function GenreDrillPage() { Back {genre} - {loadingSources && !loadingLibrary && filtered.length > 0 && ( - Loading more… + {loadingInitial && filtered.length === 0 ? null : ( + + {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} + + )} + {!loadingInitial && hasMoreNetwork && ( + More loading… )}
- {showSkeleton ? ( + {loadingInitial && filtered.length === 0 ? (
- {Array.from({ length: 24 }).map((_, i) => ( + {Array.from({ length: 50 }).map((_, i) => (
))}
- ) : filtered.length === 0 && !loadingSources ? ( + ) : filtered.length === 0 ? (
No manga found for "{genre}".
) : (
- {filtered.map((m) => ( + {visibleItems.map((m) => ( ))} + {hasMore && ( +
+ +
+ )}
)} diff --git a/src/components/explore/MangaPreview.module.css b/src/components/explore/MangaPreview.module.css index 7305c29..3b1f1f8 100644 --- a/src/components/explore/MangaPreview.module.css +++ b/src/components/explore/MangaPreview.module.css @@ -359,6 +359,16 @@ background: var(--bg-raised); color: var(--text-faint); } +.genreTagClickable { + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.genreTagClickable:hover { + color: var(--accent-fg); + border-color: var(--accent-dim); + background: var(--accent-muted); +} + /* ── Metadata table ──────────────────────────────────────────────────────── */ .metaTable { display: flex; flex-direction: column; gap: 1px; diff --git a/src/components/explore/MangaPreview.tsx b/src/components/explore/MangaPreview.tsx index e6292e3..33f7215 100644 --- a/src/components/explore/MangaPreview.tsx +++ b/src/components/explore/MangaPreview.tsx @@ -17,6 +17,7 @@ export default function MangaPreview() { const setPreviewManga = useStore((st) => st.setPreviewManga); const setActiveManga = useStore((st) => st.setActiveManga); const setNavPage = useStore((st) => st.setNavPage); + const setGenreFilter = useStore((st) => st.setGenreFilter); const openReader = useStore((st) => st.openReader); const addToast = useStore((st) => st.addToast); const folders = useStore((st) => st.settings.folders); @@ -476,7 +477,20 @@ export default function MangaPreview() { {/* ── Genre tags ── */} {!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
- {displayManga.genre.map((g) => {g})} + {displayManga.genre.map((g) => ( + + ))}
)} diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx index 1c11901..49b9c2a 100644 --- a/src/components/layout/SplashScreen.tsx +++ b/src/components/layout/SplashScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import logoUrl from "../../assets/moku-icon.svg"; export type SplashMode = "loading" | "idle"; @@ -9,7 +10,7 @@ interface Props { ringFull?: boolean; failed?: boolean; showCards?: boolean; - showFps?: boolean; // only passed from devSplash + showFps?: boolean; onReady?: () => void; onRetry?: () => void; onDismiss?: () => void; @@ -22,21 +23,13 @@ function hash(n: number): number { return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff; } -// ── Dimensions ──────────────────────────────────────────────────────────────── -// Use window dimensions for card/stamp generation (reasonable at load time), -// but the canvas itself will resize dynamically — see CardCanvas below. -const VW = typeof window !== "undefined" ? window.innerWidth : 1280; -const VH = typeof window !== "undefined" ? window.innerHeight : 800; -const BUF = 80; -const COLS = 14; - -// ── Card definition — lines stored here so stamps use the exact same value ─── +// ── Card definition ─────────────────────────────────────────────────────────── interface CardDef { layer: 0 | 1 | 2; cx: number; w: number; h: number; - lines: number; // 1‒3, stored once, used by both stamp builder & (future) draw + lines: number; alpha: number; speed: number; cycleSec: number; @@ -47,15 +40,20 @@ interface CardDef { tilt: number; } +interface CardTrig { cosA: number; sinA: number; tiltRad: number; } + const LAYER_CFG = [ { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, { wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 }, { wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 }, ] as const; -const CARDS: CardDef[] = (() => { - const out: CardDef[] = []; - const laneW = VW / COLS; +const BUF = 80; +const COLS = 14; + +function buildCards(vw: number, vh: number): { cards: CardDef[]; trigs: CardTrig[] } { + const cards: CardDef[] = []; + const laneW = vw / COLS; for (let layer = 0; layer < 3; layer++) { const cfg = LAYER_CFG[layer]; for (let col = 0; col < COLS; col++) { @@ -65,49 +63,31 @@ const CARDS: CardDef[] = (() => { const maxNudge = (laneW - w) / 2 - 2; const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge); const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin); - const travel = VH + h + BUF; - out.push({ + const travel = vh + h + BUF; + cards.push({ layer: layer as 0 | 1 | 2, cx, w, h, - lines: 1 + Math.floor(hash(seed + 7) * 3), // same seed+7 always - alpha: cfg.alpha, + lines: 1 + Math.floor(hash(seed + 7) * 3), + alpha: cfg.alpha, speed, - cycleSec: travel / speed, - phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, + cycleSec: travel / speed, + phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, travel, - yStart: VH + h / 2 + BUF / 2, - angleStart:hash(seed + 3) * 50 - 25, - tilt: (hash(seed + 4) * 2 - 1) * 18, + yStart: vh + h / 2 + BUF / 2, + angleStart: hash(seed + 3) * 50 - 25, + tilt: (hash(seed + 4) * 2 - 1) * 18, }); } } - return out; -})(); + const trigs: CardTrig[] = cards.map(c => ({ + cosA: Math.cos(c.angleStart * (Math.PI / 180)), + sinA: Math.sin(c.angleStart * (Math.PI / 180)), + tiltRad: c.tilt * (Math.PI / 180), + })); + return { cards, trigs }; +} -// ── Pre-computed per-card trig deltas ──────────────────────────────────────── -// angleStart and tilt are fixed; only p (0→1) scales the tilt. -// We can't fully precompute because p changes per frame, but we CAN precompute -// the per-radian cos/sin values and use small-angle linearisation... actually -// the simplest win is to note angles are small (±43° max) and just avoid -// recomputing Math.cos/sin of angleStart every frame — cache them, then -// use rotation composition for the tilt delta which is tiny per frame. -// -// Simpler and sufficient: cache base angle cos/sin for each card at module init, -// then compose with the tilt delta using the rotation formula: -// cos(a+d) = cos(a)*cos(d) - sin(a)*sin(d) -// sin(a+d) = sin(a)*cos(d) + cos(a)*sin(d) -// Since the tilt delta is at most 18° total over the whole travel, per-frame -// delta is tiny — Math.cos of a tiny number ≈ 1, Math.sin ≈ angle. -// But the cleanest approach: just cache angleStart's cos/sin, and per frame -// only compute cos/sin of the TILT FRACTION (small value). -interface CardTrig { cosA: number; sinA: number; tiltRad: number; } -const CARD_TRIG: CardTrig[] = CARDS.map(c => ({ - cosA: Math.cos(c.angleStart * (Math.PI / 180)), - sinA: Math.sin(c.angleStart * (Math.PI / 180)), - tiltRad: c.tilt * (Math.PI / 180), -})); - -// ── Rounded rect path helper ────────────────────────────────────────────────── +// ── Rounded rect ────────────────────────────────────────────────────────────── function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); ctx.moveTo(x + r, y); @@ -118,78 +98,78 @@ function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h ctx.closePath(); } -// ── Stamp builder — runs ONCE at module init ────────────────────────────────── -// Each card is pre-rendered at full opacity to a tiny offscreen canvas. -// Hot path does zero path ops — just globalAlpha + drawImage per card. +// ── Stamp builder ───────────────────────────────────────────────────────────── const STAMP_PAD = 6; -const STAMPS: HTMLCanvasElement[] = (() => { - if (typeof document === "undefined") return []; - return CARDS.map(c => { - const oc = document.createElement("canvas"); - oc.width = Math.ceil(c.w + STAMP_PAD * 2); - oc.height = Math.ceil(c.h + STAMP_PAD * 2); - const ctx = oc.getContext("2d")!; - const x0 = STAMP_PAD; - const y0 = STAMP_PAD; - const coverH = (c.w * 0.72) * 1.05; - // Text lines start just below the cover rect - const lineY0 = y0 + 3 + coverH + 5; +function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement { + const logW = Math.ceil(c.w + STAMP_PAD * 2); + const logH = Math.ceil(c.h + STAMP_PAD * 2); + const oc = document.createElement("canvas"); + oc.width = Math.round(logW * dpr); + oc.height = Math.round(logH * dpr); + const ctx = oc.getContext("2d")!; + ctx.scale(dpr, dpr); - // Shadow - ctx.fillStyle = "rgba(0,0,0,0.5)"; - rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); + const x0 = STAMP_PAD; + const y0 = STAMP_PAD; + const coverH = (c.w * 0.72) * 1.05; + const lineY0 = y0 + 3 + coverH + 5; - // Body - ctx.fillStyle = "rgba(255,255,255,0.07)"; - rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); + ctx.fillStyle = "rgba(0,0,0,0.5)"; + rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); - // Border - ctx.strokeStyle = "rgba(255,255,255,0.75)"; - ctx.lineWidth = 1.2; - rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.07)"; + rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); - // Cover area - ctx.fillStyle = "rgba(255,255,255,0.15)"; - rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.75)"; + ctx.lineWidth = 1.2; + rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); - // Cover tint band - ctx.fillStyle = "rgba(255,255,255,0.08)"; - rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); + ctx.fillStyle = "rgba(255,255,255,0.15)"; + rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); - // Text lines — use c.lines (same value as buildCards computed) - for (let li = 0; li < c.lines; li++) { - ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)"; - ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2); - } + ctx.fillStyle = "rgba(255,255,255,0.08)"; + rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); - return oc; - }); -})(); + for (let li = 0; li < c.lines; li++) { + ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)"; + ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2); + } -// ── Pre-baked vignette canvas ───────────────────────────────────────────────── -const VIGNETTE: HTMLCanvasElement | null = (() => { - if (typeof document === "undefined") return null; + return oc; +} + +// ── Vignette builder ────────────────────────────────────────────────────────── +function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement { const oc = document.createElement("canvas"); - oc.width = VW; oc.height = VH; + oc.width = Math.round(vw * dpr); + oc.height = Math.round(vh * dpr); const ctx = oc.getContext("2d")!; - const g = ctx.createRadialGradient(VW / 2, VH / 2, 0, VW / 2, VH / 2, Math.max(VW, VH) * 0.65); + ctx.scale(dpr, dpr); + const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65); g.addColorStop(0.15, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.82)"); ctx.fillStyle = g; - ctx.fillRect(0, 0, VW, VH); + ctx.fillRect(0, 0, vw, vh); return oc; -})(); +} -// ── Draw frame — hot path ───────────────────────────────────────────────────── -// Uses setTransform() instead of manual translate/rotate undo. -// setTransform sets the full matrix in one call — no floating-point drift, -// no stack push/pop, one fewer operation than save+restore. -function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number) { +// ── Draw frame ──────────────────────────────────────────────────────────────── +function drawFrame( + ctx: CanvasRenderingContext2D, + t: number, + cw: number, + ch: number, + dpr: number, + cards: CardDef[], + trigs: CardTrig[], + stamps: HTMLCanvasElement[], + vignette: HTMLCanvasElement, +) { ctx.clearRect(0, 0, cw, ch); - for (let i = 0; i < CARDS.length; i++) { - const c = CARDS[i]; + for (let i = 0; i < cards.length; i++) { + const c = cards[i]; const p = ((t / c.cycleSec) + c.phase) % 1; const alpha = p < 0.07 @@ -200,27 +180,28 @@ function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: num if (alpha < 0.005) continue; - const cy = c.yStart - p * c.travel; - - // Compose base rotation with tilt delta using trig identity — - // avoids two Math.cos/sin calls; only one pair for the small delta. - const tg = CARD_TRIG[i]; - const delta = tg.tiltRad * p; // small value (≤ 18° * 1) + const cy = c.yStart - p * c.travel; + const tg = trigs[i]; + const delta = tg.tiltRad * p; const cosDelta = Math.cos(delta); const sinDelta = Math.sin(delta); const cos = tg.cosA * cosDelta - tg.sinA * sinDelta; const sin = tg.sinA * cosDelta + tg.cosA * sinDelta; ctx.globalAlpha = alpha; - // setTransform(a,b,c,d,e,f) = [cos,sin,-sin,cos,tx,ty] - ctx.setTransform(cos, sin, -sin, cos, c.cx, cy); - ctx.drawImage(STAMPS[i], -c.w / 2 - STAMP_PAD, -c.h / 2 - STAMP_PAD); + ctx.setTransform( + cos * dpr, sin * dpr, + -sin * dpr, cos * dpr, + c.cx * dpr, cy * dpr, + ); + const sw = c.w + STAMP_PAD * 2; + const sh = c.h + STAMP_PAD * 2; + ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh); } - // Reset to identity + full opacity in one call ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; - if (VIGNETTE) ctx.drawImage(VIGNETTE, 0, 0, cw, ch); + ctx.drawImage(vignette, 0, 0, cw, ch); } // ── Ring ────────────────────────────────────────────────────────────────────── @@ -243,10 +224,10 @@ function Ring({ progress }: { progress: number }) { ); } -// ── FPS counter — only mounted when showFps=true (devSplash only) ───────────── +// ── FPS counter ─────────────────────────────────────────────────────────────── function FpsCounter() { - const divRef = useRef(null); - const times = useRef([]); + const divRef = useRef(null); + const times = useRef([]); useEffect(() => { let raf = 0; @@ -279,36 +260,110 @@ function FpsCounter() { ); } -// ── CardCanvas — owns the single rAF loop ───────────────────────────────────── +// ── CardCanvas ──────────────────────────────────────────────────────────────── +// Uses invoke("get_scale_factor") to get the real OS DPR from winit/Tauri +// before building any bitmaps. window.devicePixelRatio is unreliable in +// nix run and flatpak because WebKitGTK may not have received the HiDPI +// hint from the compositor by the time the JS context initialises. +// Tauri reads it from the native window handle, which is always correct. function CardCanvas({ showFps }: { showFps: boolean }) { const ref = useRef(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; + const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false }); if (!ctx) return; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; - // Keep canvas resolution in sync with its CSS size - function syncSize() { - if (!canvas) return; - canvas.width = canvas.offsetWidth || window.innerWidth; - canvas.height = canvas.offsetHeight || window.innerHeight; - } - syncSize(); - const ro = new ResizeObserver(syncSize); - ro.observe(canvas); + let cancelled = false; - let raf = 0, t0 = -1; - function frame(now: number) { - if (t0 < 0) t0 = now; - drawFrame(ctx!, (now - t0) / 1000, canvas!.width, canvas!.height); + async function init() { + // Prefer the Tauri-sourced scale factor; fall back to the JS value + // when running outside Tauri (e.g. vite dev server in a browser). + let dpr = window.devicePixelRatio || 1; + try { + const tauriDpr = await invoke("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("get_scale_factor") + .then(d => { if (d > 0) curDpr = d; }) + .catch(() => { curDpr = window.devicePixelRatio || 1; }); + } + + syncSize(); + const ro = new ResizeObserver(syncSize); + if (canvas) ro.observe(canvas); + + let raf = 0, t0 = -1; + function frame(now: number) { + if (t0 < 0) t0 = now; + drawFrame( + ctx!, (now - t0) / 1000, + canvas!.width, canvas!.height, + curDpr, cards, trigs, stamps, vignette, + ); + raf = requestAnimationFrame(frame); + } raf = requestAnimationFrame(frame); + + // Stash cleanup so the synchronous useEffect return can reach it. + (canvas as any).__splashCleanup = () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; } - raf = requestAnimationFrame(frame); + + init(); + return () => { - cancelAnimationFrame(raf); - ro.disconnect(); + cancelled = true; + (canvas as any).__splashCleanup?.(); }; }, []); @@ -364,7 +419,6 @@ export default function SplashScreen({ return () => clearInterval(id); }, []); - // Idle dismiss: keydown / mousedown / touchstart only — NO mousemove useEffect(() => { if (mode !== "idle" || !onDismiss) return; function handler() { triggerExit(onDismiss); } diff --git a/src/components/pages/MigrateModal.module.css b/src/components/pages/MigrateModal.module.css index a745f86..f43050a 100644 --- a/src/components/pages/MigrateModal.module.css +++ b/src/components/pages/MigrateModal.module.css @@ -475,4 +475,154 @@ background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08); border-radius: var(--radius-md); border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2); +} +/* ── Source context pill (step 2 header) ── */ +.searchContext { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.searchContextIcon { + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; +} + +.searchContextName { + flex: 1; + font-size: var(--text-sm); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.searchContextChange { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--accent-fg); + background: none; + border: none; + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: opacity var(--t-base); +} +.searchContextChange:hover { opacity: 0.75; } + +/* ── Result row: updated layout with similarity ── */ +.resultInfo { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; + min-width: 0; +} + +.resultMeta { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.bestMatchBadge { + display: inline-flex; + align-items: center; + gap: 3px; + font-family: var(--font-ui); + font-size: 9px; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--accent-fg); + background: var(--accent-muted); + border: 1px solid var(--accent-dim); + padding: 1px 5px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.simBar { + width: 48px; + height: 3px; + background: var(--bg-overlay); + border-radius: var(--radius-full); + overflow: hidden; + flex-shrink: 0; +} + +.simFill { + display: block; + height: 100%; + background: var(--accent); + border-radius: var(--radius-full); + transition: width 0.2s ease; +} + +.simLabel { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + white-space: nowrap; +} + +/* ── Confirm step additions ── */ +.confirmDivider { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.confirmTag { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + padding: 2px 7px; + border-radius: var(--radius-full); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + color: var(--text-faint); +} + +.confirmTagNew { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} + +.statGood { color: var(--color-success) !important; } +.statWarn { color: #d97706 !important; } +.statBad { color: var(--color-error) !important; } + +.chapterDiff { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: #d97706; + letter-spacing: var(--tracking-wide); + margin-left: var(--sp-2); +} + +.warnBox { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + background: rgba(217, 119, 6, 0.08); + border: 1px solid rgba(217, 119, 6, 0.25); + border-radius: var(--radius-md); + font-size: var(--text-xs); + color: #d97706; + line-height: var(--leading-snug); } \ No newline at end of file diff --git a/src/components/pages/MigrateModal.tsx b/src/components/pages/MigrateModal.tsx index f6c10d0..7382cd8 100644 --- a/src/components/pages/MigrateModal.tsx +++ b/src/components/pages/MigrateModal.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react"; +import { useState, useEffect, useCallback } from "react"; +import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import type { Manga, Source, Chapter } from "../../lib/types"; @@ -18,20 +18,33 @@ interface Match { manga: Manga; chapters: Chapter[]; readCount: number; + similarity: number; +} + +// Simple title similarity: normalise → word overlap / Jaccard +function titleSimilarity(a: string, b: string): number { + const norm = (s: string) => + s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); + const wordsA = new Set(norm(a)); + const wordsB = new Set(norm(b)); + if (wordsA.size === 0 || wordsB.size === 0) return 0; + const intersection = [...wordsA].filter((w) => wordsB.has(w)).length; + const union = new Set([...wordsA, ...wordsB]).size; + return intersection / union; } export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) { - const [step, setStep] = useState("source"); - const [sources, setSources] = useState([]); + const [step, setStep] = useState("source"); + const [sources, setSources] = useState([]); const [loadingSources, setLoadingSources] = useState(true); const [selectedSource, setSelectedSource] = useState(null); - const [query, setQuery] = useState(manga.title); - const [results, setResults] = useState([]); - const [searching, setSearching] = useState(false); - const [selectedMatch, setSelectedMatch] = useState(null); - const [loadingMatch, setLoadingMatch] = useState(false); - const [migrating, setMigrating] = useState(false); - const [error, setError] = useState(null); + const [query, setQuery] = useState(manga.title); + const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]); + const [searching, setSearching] = useState(false); + const [selectedMatch, setSelectedMatch] = useState(null); + const [loadingMatchId, setLoadingMatchId] = useState(null); + const [migrating, setMigrating] = useState(false); + const [error, setError] = useState(null); useEffect(() => { gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) @@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat .finally(() => setLoadingSources(false)); }, []); - async function searchSource() { - if (!selectedSource || !query.trim()) return; + const searchSource = useCallback(async (src: Source, q: string) => { + if (!src || !q.trim()) return; setSearching(true); setResults([]); setError(null); try { const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(), + source: src.id, type: "SEARCH", page: 1, query: q.trim(), }); - setResults(d.fetchSourceManga.mangas); + const scored = d.fetchSourceManga.mangas.map((m) => ({ + manga: m, + similarity: titleSimilarity(manga.title, m.title), + })); + // Sort by similarity desc so best matches float to top + scored.sort((a, b) => b.similarity - a.similarity); + setResults(scored); } catch (e: any) { setError(e.message); } finally { setSearching(false); } + }, [manga.title]); + + function pickSource(src: Source) { + setSelectedSource(src); + setStep("search"); + // Auto-search immediately with original title + searchSource(src, query); } - async function selectMatch(m: Manga) { - setLoadingMatch(true); + async function selectMatch(m: Manga, similarity: number) { + setLoadingMatchId(m.id); setError(null); try { const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id }); @@ -67,12 +93,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01); return old?.isRead; }).length; - setSelectedMatch({ manga: m, chapters, readCount }); + setSelectedMatch({ manga: m, chapters, readCount, similarity }); setStep("confirm"); } catch (e: any) { setError(e.message); } finally { - setLoadingMatch(false); + setLoadingMatchId(null); } } @@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat setError(null); try { const { manga: newManga, chapters: newChapters } = selectedMatch; - - // Build read/bookmark/progress maps from old chapters keyed by chapterNumber const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c])); const toMarkRead: number[] = []; @@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat if (!old) continue; if (old.isRead) toMarkRead.push(nc.id); if (old.isBookmarked) toMarkBookmarked.push(nc.id); - if ((old.lastPageRead ?? 0) > 0 && !old.isRead) { + if ((old.lastPageRead ?? 0) > 0 && !old.isRead) progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! }); - } } - // Migrate read state - if (toMarkRead.length) { + if (toMarkRead.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true }); - } - // Migrate bookmarks - if (toMarkBookmarked.length) { + if (toMarkBookmarked.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true }); - } - // Migrate in-progress pages one by one (different lastPageRead per chapter) - for (const { id, lastPageRead } of progressUpdates) { + for (const { id, lastPageRead } of progressUpdates) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead }); - } - // Add new to library, remove old await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true }); await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }); @@ -125,33 +141,48 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat } } - const readCount = currentChapters.filter((c) => c.isRead).length; - const totalCount = currentChapters.length; + const readCount = currentChapters.filter((c) => c.isRead).length; + const totalCount = currentChapters.length; + + const chapterDiff = selectedMatch + ? selectedMatch.chapters.length - totalCount + : 0; + + const STEPS: Step[] = ["source", "search", "confirm"]; + const stepIdx = STEPS.indexOf(step); return (
e.target === e.currentTarget && onClose()}>
+ + {/* ── Header ── */}
Migrate source {manga.title}
- +
{/* ── Step indicators ── */}
- {(["source", "search", "confirm"] as Step[]).map((st, i) => ( -
- {i < ["source","search","confirm"].indexOf(step) ? : i + 1} - {st.charAt(0).toUpperCase() + st.slice(1)} + {STEPS.map((st, i) => ( +
+ + {i < stepIdx ? : i + 1} + + + {st === "source" ? "Pick source" + : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") + : "Confirm"} +
))}
+ {/* ── Step 1: Pick source ── */} {step === "source" && (
@@ -163,11 +194,9 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
No other sources installed.
) : ( sources.map((src) => ( - +
+ )} +
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchSource()} - autoFocus - /> + onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)} + placeholder="Search title…" + autoFocus />
- -
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
+
))} - {!searching && results.map((m) => ( - ))} - {!searching && results.length === 0 && query && ( -
No results.
+ {!searching && results.length === 0 && !error && ( +
+ {query ? "No results — try a different title." : "Enter a title to search."} +
)}
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat

{manga.title}

{manga.source?.displayName ?? "Unknown"}

+ Current
- +
+ +
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat

{selectedMatch.manga.title}

{selectedSource?.displayName ?? "Unknown"}

+ New
+
+ Title match + 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}> + {Math.round(selectedMatch.similarity * 100)}% + +
Chapters on new source - {selectedMatch.chapters.length} + + {selectedMatch.chapters.length} + {chapterDiff !== 0 && ( + {chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current + )} +
- Read progress to migrate - {readCount} / {totalCount} chapters -
-
- Matched chapters - {selectedMatch.readCount} will carry over + Read progress to carry over + {selectedMatch.readCount} / {readCount} chapters
+ {chapterDiff < -5 && ( +
+ + New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing. +
+ )} +

The current entry will be removed from your library. Downloads are not transferred.

@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css index 8b71c6a..fbb4c41 100644 --- a/src/components/pages/Search.module.css +++ b/src/components/pages/Search.module.css @@ -315,7 +315,25 @@ .tagGrid .skCard { width: auto; } .tagGrid .skCover { width: 100%; } -/* ── NSFW badge ──────────────────────────────────────────────────────────── */ +/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */ +.showMoreCell { + grid-column: 1 / -1; + display: flex; + justify-content: center; + padding: var(--sp-2) 0 var(--sp-4); +} + +.showMoreBtn { + display: flex; align-items: center; gap: var(--sp-2); + font-family: var(--font-ui); font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 7px 20px; border-radius: var(--radius-md); + background: var(--bg-raised); color: var(--text-muted); + border: 1px solid var(--border-dim); cursor: pointer; + transition: color var(--t-base), border-color var(--t-base); +} +.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); } +.showMoreBtn:disabled { opacity: 0.5; cursor: default; } .nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx index ce0e458..4113993 100644 --- a/src/components/pages/Search.tsx +++ b/src/components/pages/Search.tsx @@ -4,8 +4,8 @@ import { } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; -import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; +import { cache, CACHE_KEYS } from "../../lib/cache"; +import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils"; import { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; import s from "./Search.module.css"; @@ -428,6 +428,10 @@ function KeywordTab({ // ── Tag tab ─────────────────────────────────────────────────────────────────── +const TAG_PAGE_SIZE = 50; // items shown per "page" +const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load +const TAG_MAX_SOURCES = 12; // max sources to query + function TagTab({ preferredLang, onMangaClick, }: { @@ -436,11 +440,16 @@ function TagTab({ preferredLang: string; onMangaClick: (m: Manga) => void; }) { - const [activeTag, setActiveTag] = useState(null); - const [tagResults, setTagResults] = useState([]); - const [loadingTag, setLoadingTag] = useState(false); - const [tagFilter, setTagFilter] = useState(""); - const abortRef = useRef(null); + const [activeTag, setActiveTag] = useState(null); + const [tagResults, setTagResults] = useState([]); + const [loadingTag, setLoadingTag] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE); + const [tagFilter, setTagFilter] = useState(""); + // Track next page to fetch per source for "load more from network" + const nextPageRef = useRef>(new Map()); + const sourcesRef = useRef([]); + const abortRef = useRef(null); useEffect(() => () => { abortRef.current?.abort(); }, []); @@ -449,6 +458,8 @@ function TagTab({ setActiveTag(tag); setTagResults([]); setLoadingTag(true); + setVisibleCount(TAG_PAGE_SIZE); + nextPageRef.current = new Map(); abortRef.current?.abort(); const ctrl = new AbortController(); @@ -459,27 +470,44 @@ function TagTab({ gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => d.sources.nodes.filter((s) => s.id !== "0")) ); - const deduped = dedupeSources(sources, preferredLang); - const top = getTopSources(deduped); + const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES); + sourcesRef.current = deduped; - const results = await cache.get(CACHE_KEYS.GENRE(tag), () => - Promise.allSettled( - top.map((src) => - gql<{ fetchSourceManga: { mangas: Manga[] } }>( + // Start all at -1; the fetch loop sets the real next page if hasNextPage is true + for (const src of deduped) { + nextPageRef.current.set(src.id, -1); + } + + // Stream results in: fetch each source's pages concurrently, update state as each settles + await runConcurrent(deduped, async (src) => { + if (ctrl.signal.aborted) return; + const pageResults: Manga[] = []; + // Fetch TAG_FETCH_PAGES pages in series per source + for (let page = 1; page <= TAG_FETCH_PAGES; page++) { + if (ctrl.signal.aborted) return; + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( FETCH_SOURCE_MANGA, - { source: src.id, type: "SEARCH", page: 1, query: tag }, + { source: src.id, type: "SEARCH", page, query: tag }, ctrl.signal, - ).then((d) => d.fetchSourceManga.mangas) - ) - ).then((settled) => { - const merged: Manga[] = []; - for (const r of settled) - if (r.status === "fulfilled") merged.push(...r.value); - return dedupeMangaByTitle(merged); - }) - ); - - if (!ctrl.signal.aborted) setTagResults(results); + ); + pageResults.push(...d.fetchSourceManga.mangas); + if (!d.fetchSourceManga.hasNextPage) { + nextPageRef.current.set(src.id, -1); // no more pages + break; + } else if (page === TAG_FETCH_PAGES) { + // Still has more pages beyond what we fetched upfront + nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1); + } + } catch (e: any) { + if (e?.name === "AbortError") return; + break; // source error — move on + } + } + if (!ctrl.signal.aborted && pageResults.length > 0) { + setTagResults((prev) => dedupeMangaById([...prev, ...pageResults])); + } + }, ctrl.signal); } catch (e: any) { if (e?.name !== "AbortError") console.error(e); } finally { @@ -487,11 +515,61 @@ function TagTab({ } } + async function loadMore() { + if (!activeTag || loadingMore) return; + + // First check if we have more buffered results to show + if (visibleCount < tagResults.length) { + setVisibleCount((v) => v + TAG_PAGE_SIZE); + return; + } + + // Otherwise fetch next pages from sources + const sourcesToFetch = sourcesRef.current.filter( + (src) => (nextPageRef.current.get(src.id) ?? -1) > 0 + ); + if (sourcesToFetch.length === 0) return; + + setLoadingMore(true); + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + await runConcurrent(sourcesToFetch, async (src) => { + const page = nextPageRef.current.get(src.id)!; + if (ctrl.signal.aborted) return; + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: activeTag }, + ctrl.signal, + ); + nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1); + if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) { + setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas])); + } + } catch (e: any) { + if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1); + } + }, ctrl.signal); + } finally { + if (!ctrl.signal.aborted) { + setVisibleCount((v) => v + TAG_PAGE_SIZE); + setLoadingMore(false); + } + } + } + const filteredGenres = useMemo(() => { const q = tagFilter.trim().toLowerCase(); return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; }, [tagFilter]); + const visibleResults = tagResults.slice(0, visibleCount); + const hasMore = visibleCount < tagResults.length || + sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0); + return (
@@ -531,15 +609,26 @@ function TagTab({ {activeTag} {loadingTag ? - : {tagResults.length} results} + : + {visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results + }
{loadingTag ? ( - + ) : tagResults.length > 0 ? (
- {tagResults.map((m) => ( + {visibleResults.map((m) => ( onMangaClick(m)} /> ))} + {hasMore && ( +
+ +
+ )}
) : (
diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index 2dcb7f5..9cbf2dc 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -302,6 +302,7 @@ export default function SeriesDetail() { const updateSettings = useStore((state) => state.updateSettings); const addToast = useStore((state) => state.addToast); const setGenreFilter = useStore((state) => state.setGenreFilter); + const setNavPage = useStore((state) => state.setNavPage); const [manga, setManga] = useState(null); const [chapters, setChapters] = useState([]); @@ -733,7 +734,11 @@ export default function SeriesDetail() { key={g} className={[s.genre, s.genreClickable].join(" ")} title={`Filter library by "${g}"`} - onClick={() => setGenreFilter(g)} + onClick={() => { + setGenreFilter(g); + setNavPage("explore"); + setActiveManga(null); + }} > {g} diff --git a/src/components/settings/Settings.module.css b/src/components/settings/Settings.module.css index 6af6523..7e1a601 100644 --- a/src/components/settings/Settings.module.css +++ b/src/components/settings/Settings.module.css @@ -458,4 +458,104 @@ .folderTabToggleOn:hover { background: var(--accent-muted); color: var(--accent-fg); +} + +/* ─── Theme picker ── */ +.themeGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); +} + +.themeCard { + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-2); + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: var(--bg-raised); + cursor: pointer; + text-align: left; + transition: border-color var(--t-base), background var(--t-base); +} +.themeCard:hover { border-color: var(--border-strong); background: var(--bg-overlay); } +.themeCardActive { + border-color: var(--accent); + background: var(--accent-muted); +} +.themeCardActive:hover { border-color: var(--accent); } + +.themePreview { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid rgba(0,0,0,0.15); + flex-shrink: 0; +} + +.themePreviewBg { + width: 100%; height: 100%; + display: flex; +} + +.themePreviewSidebar { + width: 22%; + height: 100%; + flex-shrink: 0; + opacity: 0.9; +} + +.themePreviewContent { + flex: 1; + padding: 10% 12%; + display: flex; + flex-direction: column; + gap: 8%; + justify-content: center; +} + +.themePreviewAccent { + height: 14%; + border-radius: 2px; + width: 55%; +} + +.themePreviewText { + height: 9%; + border-radius: 2px; + width: 100%; +} + +.themeCardInfo { + display: flex; + flex-direction: column; + gap: 1px; +} + +.themeCardLabel { + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--text-secondary); + line-height: var(--leading-tight); +} + +.themeCardDesc { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + line-height: var(--leading-snug); +} + +.themeCardCheck { + position: absolute; + top: var(--sp-1); + right: var(--sp-2); + font-size: var(--text-xs); + color: var(--accent-fg); + font-family: var(--font-ui); } \ No newline at end of file diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index d6d5cc1..576aeef 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -1,26 +1,27 @@ import { useEffect, useRef, useState, useCallback } from "react"; -import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench } from "@phosphor-icons/react"; +import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "@phosphor-icons/react"; import { invoke } from "@tauri-apps/api/core"; import { gql } from "../../lib/client"; import { GET_DOWNLOADS_PATH } from "../../lib/queries"; import { useStore } from "../../store"; import type { Folder } from "../../store"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds"; -import type { Settings, FitMode } from "../../store"; +import type { Settings, FitMode, Theme } from "../../store"; import s from "./Settings.module.css"; -type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools"; +type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools"; const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ - { id: "general", label: "General", icon: }, - { id: "reader", label: "Reader", icon: }, - { id: "library", label: "Library", icon: }, - { id: "performance", label: "Performance", icon: }, - { id: "keybinds", label: "Keybinds", icon: }, - { id: "storage", label: "Storage", icon: }, - { id: "folders", label: "Folders", icon: }, - { id: "about", label: "About", icon: }, - { id: "devtools", label: "Dev Tools", icon: }, + { id: "general", label: "General", icon: }, + { id: "appearance", label: "Appearance", icon: }, + { id: "reader", label: "Reader", icon: }, + { id: "library", label: "Library", icon: }, + { id: "performance",label: "Performance",icon: }, + { id: "keybinds", label: "Keybinds", icon: }, + { id: "storage", label: "Storage", icon: }, + { id: "folders", label: "Folders", icon: }, + { id: "about", label: "About", icon: }, + { id: "devtools", label: "Dev Tools", icon: }, ]; // ── 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) => void }) { + const current = settings.theme ?? "dark"; + return ( +
+
+

Theme

+
+ {THEMES.map((theme) => { + const active = current === theme.id; + return ( + + ); + })} +
+
+
+ ); +} + function DevToolsTab() { const [splashTriggered, setSplashTriggered] = useState(false); @@ -840,6 +923,7 @@ export default function SettingsModal() {
{tab === "general" && } + {tab === "appearance" && } {tab === "reader" && } {tab === "library" && } {tab === "performance" && } diff --git a/src/store/index.ts b/src/store/index.ts index 817b433..b308680 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -9,6 +9,13 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string; // str export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search"; export type ReadingDirection = "ltr" | "rtl"; export type ChapterSortDir = "desc" | "asc"; +export type Theme = + | "dark" // default — near-black + | "high-contrast" // darker + sharper text + | "light" // warm off-white + | "light-contrast" // light + max contrast + | "midnight" // blue-black tint + | "warm"; // amber/sepia tint export interface HistoryEntry { mangaId: number; @@ -71,6 +78,8 @@ export interface Settings { folders: Folder[]; /** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */ readerDebounceMs: number; + /** UI colour theme. Applied as data-theme on . */ + theme: Theme; } export const DEFAULT_SETTINGS: Settings = { @@ -102,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = { storageLimitGb: null, folders: [], readerDebounceMs: 120, + theme: "dark", }; interface Store { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index c61afa9..5ad3dc6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -106,4 +106,157 @@ --t-fast: 0.08s ease; --t-base: 0.14s ease; --t-slow: 0.22s ease; +} + +/* ───────────────────────────────────────────── + Themes + Applied via data-theme on . + "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; } \ No newline at end of file