[V1] Major Bug Fixes & Loading Screen (WIP)

This commit is contained in:
Youwes09
2026-02-24 16:14:46 -06:00
parent ac1c0520c5
commit f866d4d0e9
10 changed files with 929 additions and 122 deletions
+79 -12
View File
@@ -1,22 +1,89 @@
Todo: Todo:
1. Check all Keybind Toggles 3. Explore Manga Upscaler & Other Image Processing
2. Update ReadME with Comprehensive Feature List 4. Font Weird on Flatpak, Investigate and Fix
3. Explore Manga Upscaler 5. Investigate "egl:failed to create dri2 screen"
4. Add Zoom-Slider for Zoom in Manga Reader
Bugs: Bugs:
2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic)
3. Patch Chapters to Grid View -
5. Fix Keybind Toggles - Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
- Add Back after Search & Clear on Search
- Add as Package in Nix Flake & Check Later
- GenreDrill & GenreFilter pages do not populate completely.
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
- Fix Mangafire Main Dispatcher Issue
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
- Clean up Migrate Model to be more initutive
Features: Features:
1. Frecency based Manga Suggestions - Add PDF Textbook Support
2. Proper Explore Tab - Major revision to disable entire manga-subsection and use as
solely as a reader/document launcher.
- Multiple Tag Filters + Mor Tags, Types, Etc
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Properly Kill Tachidesk-Server
- Migration Features
- Multi-Page Long Screenshot
-
Big Revisions: Big Revisions:
0. Expand into fully-fledged reader, with modular manga support
1. Anime & Novel Support 1. Anime & Novel Support
2. Tracker Support
3. Cloudflare Bypass Enable Support
4. macOS Support (feasible)
Test:
1. URL & Extension Additions
Testing:
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
20. Expand History (Total Time Read, etc)
12. Delete all Downloads should also cancel all download queues
13. Cancel Download along with Queue & Download Timeout Feature
Completed:
8. Fix Polling on Download Manager (Instantanous Response)
19. Debounce Time on Reader to improve lag (Toggle Setting)
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
17. Change Library Text change to "No manga saved to library, browse sources to add some."
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
7. Fix Scaling (100 = 125% and so forth)
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
11. Reader & UI needs download and other Notifications
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
- Add Refresh Details on Series Details.
- Patch GenreDrill & Integrate into Explore Folder
18. Disable NSFW Extensions option in settings
- Filtering by Genre (Accessed by Clicking tags on Manga)
- Remove Series Detail Mark Read & Unread
Important Commands:
cd ~/Projects/Manga/Moku
pnpm build
tar -czf packaging/frontend-dist.tar.gz -C dist .
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
3. flatpak build-bundle repo moku.flatpak dev.moku.app
+67 -25
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use nix::sys::statvfs::statvfs; use nix::sys::statvfs::statvfs;
use serde::Serialize; use serde::Serialize;
use tauri::Manager; use tauri::{Manager, WindowEvent};
use tauri_plugin_shell::{ShellExt, process::CommandChild}; use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -51,12 +51,9 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}; };
let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?; let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?;
// f_frsize is the fundamental block size used for block counts.
// f_bsize (block_size()) is just the preferred I/O size and must not be
// used with blocks()/blocks_free() — that gives wildly wrong numbers.
let frsize = vfs.fragment_size() as u64; let frsize = vfs.fragment_size() as u64;
let total_bytes = vfs.blocks() * frsize; let total_bytes = vfs.blocks() * frsize;
let free_bytes = vfs.blocks_available() * frsize; let free_bytes = vfs.blocks_available() * frsize;
Ok(StorageInfo { Ok(StorageInfo {
manga_bytes, manga_bytes,
@@ -66,31 +63,76 @@ fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
}) })
} }
fn kill_tachidesk(app: &tauri::AppHandle) {
// Kill the tracked child handle
let state = app.state::<ServerState>();
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::<ServerState>();
{
let guard = state.0.lock().unwrap();
if guard.is_some() {
println!("Server already running, skipping spawn.");
return Ok(());
}
}
let shell = app.shell();
match shell.command(&binary).spawn() {
Ok((_rx, child)) => {
println!("Spawned server: {}", binary);
let mut guard = state.0.lock().unwrap();
*guard = Some(child);
Ok(())
}
Err(e) => {
eprintln!("Failed to spawn {}: {}", binary, e);
Err(e.to_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);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.manage(ServerState(Mutex::new(None))) .manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![get_storage_info]) .invoke_handler(tauri::generate_handler![
.setup(|app| { get_storage_info,
let shell = app.shell(); spawn_server,
let app_handle = app.handle().clone(); kill_server,
])
let status = shell.command("tachidesk-server").spawn(); .setup(|_app| Ok(()))
.on_window_event(|window, event| {
match status { // Kill the server when the main window is actually destroyed.
Ok((_rx, child)) => { // This fires reliably on close regardless of whether the JS
println!("Tachidesk server process spawned successfully."); // cleanup callback ran.
let state = app_handle.state::<ServerState>(); if let WindowEvent::Destroyed = event {
let mut guard = state.0.lock().unwrap(); kill_tachidesk(window.app_handle());
*guard = Some(child);
}
Err(e) => {
eprintln!("Failed to spawn Tachidesk server: {}", e);
}
} }
Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running moku"); .expect("error while running moku");
+123 -46
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client"; import { gql } from "./lib/client";
@@ -11,9 +11,12 @@ import Settings from "./components/settings/Settings";
import MangaPreview from "./components/explore/MangaPreview"; import MangaPreview from "./components/explore/MangaPreview";
import TitleBar from "./components/layout/TitleBar"; import TitleBar from "./components/layout/TitleBar";
import Toaster from "./components/layout/Toaster"; import Toaster from "./components/layout/Toaster";
import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import s from "./App.module.css"; import s from "./App.module.css";
const MAX_ATTEMPTS = 30;
export default function App() { export default function App() {
const activeChapter = useStore((s) => s.activeChapter); const activeChapter = useStore((s) => s.activeChapter);
const settingsOpen = useStore((s) => s.settingsOpen); const settingsOpen = useStore((s) => s.settingsOpen);
@@ -21,24 +24,51 @@ export default function App() {
const setActiveDownloads = useStore((s) => s.setActiveDownloads); const setActiveDownloads = useStore((s) => s.setActiveDownloads);
const addToast = useStore((s) => s.addToast); const addToast = useStore((s) => s.addToast);
// Ref-based snapshot of the last known queue so we can diff across polls/events // serverProbeOk = server responded, but we wait for ring to finish before showing UI
const prevQueueRef = useRef<DownloadQueueItem[]>([]); const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer);
// appReady = ring filled + transition done, show main UI
const [appReady, setAppReady] = useState(!settings.autoStartServer);
const [failed, setFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const [idle, setIdle] = useState(false);
// dev tools: force show splash
const [devSplash, setDevSplash] = useState(false);
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// expose devSplash trigger via window for settings
useEffect(() => {
(window as any).__mokuShowSplash = () => setDevSplash(true);
return () => { delete (window as any).__mokuShowSplash; };
}, []);
useEffect(() => {
if (!appReady) return;
function resetIdle() {
setIdle(false);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (idleTimeoutMs === 0) return;
idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs);
}
const events = ["mousemove","mousedown","keydown","touchstart","wheel"];
events.forEach(e => window.addEventListener(e, resetIdle, { passive:true }));
resetIdle();
return () => {
events.forEach(e => window.removeEventListener(e, resetIdle));
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
};
}, [appReady, settings.idleTimeoutMin]);
/** Compare old queue → new queue and toast for anything that finished. */
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) { function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) { for (const item of prev) {
if (item.state !== "DOWNLOADING") continue; if (item.state !== "DOWNLOADING") continue;
const stillPresent = next.some((q) => q.chapter.id === item.chapter.id); if (!next.some(q => q.chapter.id === item.chapter.id)) {
if (!stillPresent) {
const manga = item.chapter.manga; const manga = item.chapter.manga;
addToast({ addToast({ kind:"success", title:"Chapter downloaded",
kind: "success", body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
title: "Chapter downloaded", duration: 4000 });
body: manga
? `${manga.title}${item.chapter.name}`
: item.chapter.name,
duration: 4000,
});
} }
} }
} }
@@ -46,13 +76,9 @@ export default function App() {
function applyQueue(next: DownloadQueueItem[]) { function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueueRef.current, next); detectCompletions(prevQueueRef.current, next);
prevQueueRef.current = next; prevQueueRef.current = next;
setActiveDownloads( setActiveDownloads(next.map(item => ({
next.map((item) => ({ chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
chapterId: item.chapter.id, })));
mangaId: item.chapter.mangaId,
progress: item.progress,
}))
);
} }
useEffect(() => { useEffect(() => {
@@ -60,52 +86,103 @@ export default function App() {
}, [settings.uiScale]); }, [settings.uiScale]);
useEffect(() => { useEffect(() => {
const prevent = (e: MouseEvent) => e.preventDefault(); const p = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", prevent); document.addEventListener("contextmenu", p);
return () => document.removeEventListener("contextmenu", prevent); return () => document.removeEventListener("contextmenu", p);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!settings.autoStartServer) return; if (!settings.autoStartServer) return;
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) => invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
console.warn("Could not start server:", err) console.warn("Could not start server:", err));
);
return () => { invoke("kill_server").catch(() => {}); }; return () => { invoke("kill_server").catch(() => {}); };
}, [settings.autoStartServer, settings.serverBinary]); }, [settings.autoStartServer, settings.serverBinary]);
// Global download status poller — always running, regardless of which page is open. // Poll until server responds
// This is the single source of truth for completion toasts.
useEffect(() => { useEffect(() => {
if (serverProbeOk) return;
let cancelled = false, tries = 0;
async function probe() {
if (cancelled) return;
tries++;
try {
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ query:"{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelled) { setServerProbeOk(true); return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; }
if (!cancelled) setTimeout(probe, 800);
}
const t = setTimeout(probe, 800);
return () => { cancelled = true; clearTimeout(t); };
}, [serverProbeOk, settings.serverUrl, retryKey]);
useEffect(() => {
if (!appReady) return;
function poll() { function poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyQueue(d.downloadStatus.queue)) .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
.catch(console.error);
} }
poll(); // immediate first fetch poll();
const id = setInterval(poll, 2000); const id = setInterval(poll, 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, [appReady]);
// Tauri real-time event — supplements the poller for instant UI badge updates.
// The payload is a lighter shape (no chapter name/manga), so we only use it
// for active download progress, not for completion detection.
useEffect(() => { useEffect(() => {
type DlPayload = { chapterId: number; mangaId: number; progress: number }[]; type P = { chapterId:number; mangaId:number; progress:number }[];
const unsub = listen<DlPayload>("download-progress", (e) => { const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
setActiveDownloads(e.payload); return () => { unsub.then(fn => fn()); };
});
return () => { unsub.then((fn) => fn()); };
}, [setActiveDownloads]); }, [setActiveDownloads]);
// Dev splash overlay — shows idle mode so you can dismiss with any interaction
if (devSplash) {
return (
<SplashScreen
mode="idle"
showFps
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
/>
);
}
// Loading splash — shown until ring fills + transition completes
if (!appReady) {
return (
<SplashScreen
mode="loading"
ringFull={serverProbeOk}
failed={failed}
showCards={settings.splashCards ?? true}
onReady={() => setAppReady(true)}
onRetry={() => {
setFailed(false);
setServerProbeOk(false);
setRetryKey(k => k+1);
}}
/>
);
}
return ( return (
<div className={s.root}> <div className={s.root}>
{!activeChapter && <TitleBar />} {idle && !activeChapter && (
<SplashScreen
mode="idle"
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
/>
)}
{!activeChapter && <TitleBar/>}
<div className={s.content}> <div className={s.content}>
{activeChapter ? <Reader /> : <Layout />} {activeChapter ? <Reader/> : <Layout/>}
</div> </div>
{settingsOpen && <Settings />} {settingsOpen && <Settings/>}
<MangaPreview /> <MangaPreview/>
<Toaster /> <Toaster/>
</div> </div>
); );
} }
+39 -7
View File
@@ -157,6 +157,8 @@ function ExploreFeed() {
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map()); const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
const [loadingGenres, setLoadingGenres] = useState(false); const [loadingGenres, setLoadingGenres] = useState(false);
const [sources, setSources] = useState<Source[]>([]); const [sources, setSources] = useState<Source[]>([]);
const [loadError, setLoadError] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const fetchedGenresRef = useRef<string>(""); const fetchedGenresRef = useRef<string>("");
@@ -208,10 +210,25 @@ function ExploreFeed() {
]; ];
} }
// ── Library + sources load (runs once) ──────────────────────────────────── // ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
useEffect(() => { useEffect(() => {
// If we already have data, no need to re-fetch (cache hit path)
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
if (alreadyLoaded) return;
setLoadingLib(true);
setLoadingPopular(true);
setLoadError(false);
const preferredLang = settings.preferredExtensionLang || "en"; const preferredLang = settings.preferredExtensionLang || "en";
// Clear stale failed cache entries so we actually retry
if (retryCount > 0) {
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.SOURCES);
fetchedGenresRef.current = "";
}
// Library — fire immediately, independent of sources // Library — fire immediately, independent of sources
cache.get(CACHE_KEYS.LIBRARY, () => cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([ Promise.all([
@@ -222,7 +239,7 @@ function ExploreFeed() {
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m); return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
}) })
).then(setAllManga) ).then(setAllManga)
.catch(console.error) .catch((e) => { console.error(e); setLoadError(true); })
.finally(() => setLoadingLib(false)); .finally(() => setLoadingLib(false));
// Sources — then kick off popular AND genres simultaneously // Sources — then kick off popular AND genres simultaneously
@@ -230,7 +247,7 @@ function ExploreFeed() {
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, preferredLang))
).then((allSources) => { ).then((allSources) => {
if (allSources.length === 0) { setLoadingPopular(false); return; } if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; }
// Cap to 2 sources for the explore feed — halves the network calls // Cap to 2 sources for the explore feed — halves the network calls
const topSources = getTopSources(allSources).slice(0, 2); const topSources = getTopSources(allSources).slice(0, 2);
@@ -292,9 +309,9 @@ function ExploreFeed() {
.catch((e) => { if (e?.name !== "AbortError") console.error(e); }) .catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
}) })
.catch(console.error); .catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [retryCount]);
// ── Frecency genres (derived from history + library) ────────────────────── // ── Frecency genres (derived from history + library) ──────────────────────
const frecencyGenres = useMemo(() => { const frecencyGenres = useMemo(() => {
@@ -459,8 +476,23 @@ function ExploreFeed() {
continueReading.length === 0 && recommended.length === 0 && continueReading.length === 0 && recommended.length === 0 &&
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && ( popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
<div className={s.empty}> <div className={s.empty}>
<span>Nothing to explore yet</span> {loadError ? (
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span> <>
<span>Could not reach Suwayomi</span>
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
<button
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
>
Retry
</button>
</>
) : (
<>
<span>Nothing to explore yet</span>
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
</>
)}
</div> </div>
)} )}
+18 -8
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useRef, memo } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } 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, getTopSources } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/sourceUtils"; import { dedupeSources, dedupeMangaByTitle, 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";
@@ -67,13 +67,18 @@ export default function GenreDrillPage() {
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, preferredLang))
).then((allSources) => { ).then((allSources) => {
const topSources = getTopSources(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), () => return cache.get(CACHE_KEYS.GENRE(genre), () =>
Promise.allSettled( Promise.allSettled(
topSources.map((src) => // Fetch page 1 and page 2 from each source for a fuller result set
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { sourcesToQuery.flatMap((src) =>
source: src.id, type: "SEARCH", page: 1, query: genre, [1, 2].map((page) =>
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas) gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page, query: genre,
}, ctrl.signal).then((d) => d.fetchSourceManga.mangas)
)
) )
).then((results) => { ).then((results) => {
const merged: Manga[] = []; const merged: Manga[] = [];
@@ -91,9 +96,14 @@ export default function GenreDrillPage() {
}, [genre]); }, [genre]);
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));
const srcMatches = sourceManga.filter((m) => !m.genre?.length || m.genre.includes(genre)); // Source manga: include ALL results — they came from a genre search,
return dedupeMangaById([...libMatches, ...srcMatches]); // 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]); }, [libraryManga, sourceManga, genre]);
function openCtx(e: React.MouseEvent, m: Manga) { function openCtx(e: React.MouseEvent, m: Manga) {
+458
View File
@@ -0,0 +1,458 @@
import { useEffect, useRef, useState } from "react";
import logoUrl from "../../assets/moku-icon.svg";
export type SplashMode = "loading" | "idle";
export const EXIT_MS = 320;
interface Props {
mode: SplashMode;
ringFull?: boolean;
failed?: boolean;
showCards?: boolean;
showFps?: boolean; // only passed from devSplash
onReady?: () => void;
onRetry?: () => void;
onDismiss?: () => void;
}
// ── Hash ──────────────────────────────────────────────────────────────────────
function hash(n: number): number {
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
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 ───
interface CardDef {
layer: 0 | 1 | 2;
cx: number;
w: number;
h: number;
lines: number; // 13, stored once, used by both stamp builder & (future) draw
alpha: number;
speed: number;
cycleSec: number;
phase: number;
travel: number;
yStart: number;
angleStart: number;
tilt: 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;
for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) {
const seed = col * 31 + layer * 97 + 7;
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
const h = w * 1.44;
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({
layer: layer as 0 | 1 | 2,
cx, w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3), // same seed+7 always
alpha: cfg.alpha,
speed,
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,
});
}
}
return out;
})();
// ── 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 ──────────────────────────────────────────────────
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
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.
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;
// Shadow
ctx.fillStyle = "rgba(0,0,0,0.5)";
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
// Body
ctx.fillStyle = "rgba(255,255,255,0.07)";
rrect(ctx, x0, y0, 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();
// Cover area
ctx.fillStyle = "rgba(255,255,255,0.15)";
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
// 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();
// 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);
}
return oc;
});
})();
// ── Pre-baked vignette canvas ─────────────────────────────────────────────────
const VIGNETTE: HTMLCanvasElement | null = (() => {
if (typeof document === "undefined") return null;
const oc = document.createElement("canvas");
oc.width = VW; oc.height = VH;
const ctx = oc.getContext("2d")!;
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);
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) {
ctx.clearRect(0, 0, cw, ch);
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
? (p / 0.07) * c.alpha
: p > 0.86
? ((1 - p) / 0.14) * c.alpha
: c.alpha;
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 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);
}
// 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);
}
// ── Ring ──────────────────────────────────────────────────────────────────────
function Ring({ progress }: { progress: number }) {
const r = 44, sw = 2, pad = 8;
const size = (r + pad) * 2, c = r + pad;
const circ = 2 * Math.PI * r;
const arc = circ * Math.min(Math.max(progress, 0.025), 0.999);
return (
<svg width={size} height={size} style={{
position: "absolute", pointerEvents: "none",
top: -((size - 80) / 2), left: -((size - 80) / 2),
}}>
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
transform={`rotate(-90 ${c} ${c})`}
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
</svg>
);
}
// ── FPS counter — only mounted when showFps=true (devSplash only) ─────────────
function FpsCounter() {
const divRef = useRef<HTMLDivElement>(null);
const times = useRef<number[]>([]);
useEffect(() => {
let raf = 0;
function tick(now: number) {
const arr = times.current;
arr.push(now);
if (arr.length > 60) arr.shift();
if (arr.length > 1 && divRef.current) {
const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000));
divRef.current.textContent = `${fps} fps`;
divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171";
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div ref={divRef} style={{
position: "fixed", top: 10, right: 14, zIndex: 10001,
fontFamily: "var(--font-mono, 'Courier New', monospace)",
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
color: "#4ade80",
background: "rgba(0,0,0,0.55)",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 4, padding: "2px 7px",
userSelect: "none", pointerEvents: "none",
}}>-- fps</div>
);
}
// ── CardCanvas — owns the single rAF loop ─────────────────────────────────────
function CardCanvas({ showFps }: { showFps: boolean }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false });
if (!ctx) return;
// 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 raf = 0, t0 = -1;
function frame(now: number) {
if (t0 < 0) t0 = now;
drawFrame(ctx!, (now - t0) / 1000, canvas!.width, canvas!.height);
raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
return (
<>
<canvas ref={ref} style={{
position: "absolute", inset: 0, pointerEvents: "none",
width: "100%", height: "100%",
}} />
{showFps && <FpsCounter />}
</>
);
}
// ── Static CSS ────────────────────────────────────────────────────────────────
const STATIC_CSS = `
@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} }
@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} }
@keyframes logoBreathe {
0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))}
50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))}
}
@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} }
`;
// ── Main ──────────────────────────────────────────────────────────────────────
export default function SplashScreen({
mode, ringFull = false, failed = false,
showCards = true, showFps = false,
onReady, onRetry, onDismiss,
}: Props) {
const [dots, setDots] = useState("");
const [ringProg, setRingProg] = useState(0.025);
const [exiting, setExiting] = useState(false);
const exitLock = useRef(false);
function triggerExit(cb?: () => void) {
if (exitLock.current) return;
exitLock.current = true;
setExiting(true);
setTimeout(() => cb?.(), EXIT_MS);
}
useEffect(() => {
if (!ringFull) return;
setRingProg(1);
const t = setTimeout(() => triggerExit(onReady), 650);
return () => clearTimeout(t);
}, [ringFull]);
useEffect(() => {
const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420);
return () => clearInterval(id);
}, []);
// Idle dismiss: keydown / mousedown / touchstart only — NO mousemove
useEffect(() => {
if (mode !== "idle" || !onDismiss) return;
function handler() { triggerExit(onDismiss); }
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
return () => {
window.removeEventListener("keydown", handler);
window.removeEventListener("mousedown", handler);
window.removeEventListener("touchstart", handler);
};
}, [mode, onDismiss]);
const isIdle = mode === "idle";
return (
<div style={{
position: "fixed", inset: 0, zIndex: 9999,
background: "var(--bg-base)", overflow: "hidden",
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
cursor: isIdle ? "pointer" : "default",
animation: exiting
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
}}>
<style>{STATIC_CSS}</style>
{showCards && <CardCanvas showFps={showFps} />}
{isIdle ? (
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
<div style={{
position: "absolute", inset: -20, borderRadius: "50%",
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
animation: "logoBreathe 4s ease-in-out infinite",
}} />
<img src={logoUrl} alt="Moku" style={{
width: 128, height: 128, borderRadius: 28,
display: "block", position: "relative",
animation: "logoBreathe 4s ease-in-out infinite",
}} />
</div>
<p style={{
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
letterSpacing: "0.22em", textTransform: "uppercase",
margin: 0, userSelect: "none",
animation: "hintFade 3.5s ease-in-out infinite",
}}>press any key to continue</p>
</div>
) : (
<>
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
{!failed && <Ring progress={ringProg} />}
<img src={logoUrl} alt="Moku"
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
</div>
<p style={{
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
letterSpacing: "0.26em", textTransform: "uppercase",
color: "var(--text-secondary)", margin: "0 0 8px",
zIndex: 1, userSelect: "none",
}}>moku</p>
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
{failed ? (
<>
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
Could not reach Suwayomi
</p>
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
Make sure tachidesk-server is on your PATH
</p>
<button onClick={onRetry} style={{
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
color: "var(--text-muted)", cursor: "pointer",
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
}}>Retry</button>
</>
) : (
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
{ringFull ? "Ready" : `Initializing server${dots}`}
</p>
)}
</div>
</>
)}
</div>
);
}
+30 -11
View File
@@ -58,13 +58,14 @@ function fetchLibrary() {
} }
export default function Library() { export default function Library() {
const [allManga, setAllManga] = useState<Manga[]>([]); const [allManga, setAllManga] = useState<Manga[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [retryCount, setRetryCount] = useState(0);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); const [search, setSearch] = useState("");
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null); const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const setActiveManga = useStore((state) => state.setActiveManga); const setActiveManga = useStore((state) => state.setActiveManga);
const libraryFilter = useStore((state) => state.libraryFilter); const libraryFilter = useStore((state) => state.libraryFilter);
@@ -80,18 +81,30 @@ export default function Library() {
const loadData = useCallback((showLoading = false) => { const loadData = useCallback((showLoading = false) => {
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
// Clear a previously failed cache entry so we actually retry the network call
if (!cache.has(CACHE_KEYS.LIBRARY)) {
// cache miss — fresh fetch, nothing to clear
}
fetchLibrary() fetchLibrary()
.then((nodes) => { setAllManga(nodes); setError(null); }) .then((nodes) => { setAllManga(nodes); setError(null); })
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
// Initial load — delayed on first mount so the server has time to start.
// retryCount bumps force a re-run; manual retries clear the cache first.
useEffect(() => { useEffect(() => {
loadData(true); setLoading(true);
// Re-fetch when library cache is invalidated (e.g. by Explore or GenreDrillPage) setError(null);
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
loadData(false);
// Re-fetch when library cache is invalidated by other pages
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false)); const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
return unsub; return unsub;
}, [loadData]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [retryCount]);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 }); scrollRef.current?.scrollTo({ top: 0 });
@@ -271,7 +284,13 @@ export default function Library() {
if (error) return ( if (error) return (
<div className={s.center}> <div className={s.center}>
<p className={s.errorMsg}>Could not reach Suwayomi</p> <p className={s.errorMsg}>Could not reach Suwayomi</p>
<p className={s.errorDetail}>{error}</p> <p className={s.errorDetail}>Make sure the server is running, then retry.</p>
<button
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
onClick={() => setRetryCount((c) => c + 1)}
>
Retry
</button>
</div> </div>
); );
+37 -11
View File
@@ -256,9 +256,14 @@ export default function Reader() {
* currently reading (for topbar display) without triggering a full reload. * currently reading (for topbar display) without triggering a full reload.
*/ */
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null); const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
// Ref mirror so the scroll handler always reads the latest value without
// closing over a stale state snapshot from a previous effect render.
const visibleChapterIdRef = useRef<number | null>(null);
// Keep the ref mirror in sync so the scroll handler always sees current strip state // Keep the ref mirror in sync so the scroll handler always sees current strip state
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]); useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
// Keep visibleChapterId ref in sync
useEffect(() => { visibleChapterIdRef.current = visibleChapterId; }, [visibleChapterId]);
// Restore scroll position synchronously after a head-trim, before the browser paints // Restore scroll position synchronously after a head-trim, before the browser paints
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -681,33 +686,54 @@ export default function Reader() {
// ── Infinite append ────────────────────────────────────────────────── // ── Infinite append ──────────────────────────────────────────────────
if (!autoNext) { if (!autoNext) {
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; // Only navigate when the strip genuinely overflows the viewport.
// If pages are short/zoomed-out, scrollHeight === clientHeight and
// atBottom would always be true, causing unwanted chapter switches.
const isScrollable = el.scrollHeight > el.clientHeight + 4;
const atBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList); if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
return; return;
} }
const strip = stripChaptersRef.current; const strip = stripChaptersRef.current;
// Silently update visibleChapterId as we scroll into each chunk // Silently update visibleChapterId as we scroll into each chunk.
// Use the ref so we always compare against the current value, not a
// stale closure snapshot from when the effect was last set up.
for (const chunk of strip) { for (const chunk of strip) {
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length; const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) { if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
if (chunk.chapterId !== visibleChapterId) { if (chunk.chapterId !== visibleChapterIdRef.current) {
setVisibleChapterId(chunk.chapterId); // Mark the chapter we just *left* as read before updating the ref.
if (settings.autoMarkRead) { if (settings.autoMarkRead) {
const prevChunk = strip[strip.indexOf(chunk) - 1]; const chunkIdx = strip.indexOf(chunk);
if (prevChunk) { const prevChunk = chunkIdx > 0 ? strip[chunkIdx - 1] : null;
if (!markedReadRef.current.has(prevChunk.chapterId)) { if (prevChunk && !markedReadRef.current.has(prevChunk.chapterId)) {
markedReadRef.current.add(prevChunk.chapterId); markedReadRef.current.add(prevChunk.chapterId);
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
}
} }
} }
visibleChapterIdRef.current = chunk.chapterId;
setVisibleChapterId(chunk.chapterId);
} }
break; break;
} }
} }
// When the user reaches the very bottom of the full strip, mark the
// last chapter as read (it never triggers the "crossed into next chunk" path).
if (settings.autoMarkRead) {
const isScrollable = el.scrollHeight > el.clientHeight + 4;
const atVeryBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
if (atVeryBottom) {
const lastChunk = strip[strip.length - 1];
if (lastChunk && !markedReadRef.current.has(lastChunk.chapterId)) {
markedReadRef.current.add(lastChunk.chapterId);
gql(MARK_CHAPTER_READ, { id: lastChunk.chapterId, isRead: true }).catch(console.error);
}
}
}
// Append next chapter when within 300px of the bottom // Append next chapter when within 300px of the bottom
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
if (!nearBottom) return; if (!nearBottom) return;
@@ -751,7 +777,7 @@ export default function Reader() {
el.removeEventListener("scroll", onScroll); el.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
}; };
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]); }, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
// Reset scroll position when switching chapters in non-longstrip modes // Reset scroll position when switching chapters in non-longstrip modes
useEffect(() => { useEffect(() => {
+74 -2
View File
@@ -1,5 +1,5 @@
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 } from "@phosphor-icons/react"; import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench } 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";
@@ -9,7 +9,7 @@ import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from
import type { Settings, FitMode } from "../../store"; import type { Settings, FitMode } from "../../store";
import s from "./Settings.module.css"; import s from "./Settings.module.css";
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about"; type Tab = "general" | "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" /> },
@@ -20,6 +20,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> }, { id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> }, { id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
{ id: "about", label: "About", icon: <Info 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 ────────────────────────────────────────────────────────────────
@@ -174,6 +175,24 @@ function GeneralTab({ settings, update }: { settings: Settings; update: (p: Part
checked={settings.autoStartServer} checked={settings.autoStartServer}
onChange={(v) => update({ autoStartServer: v })} /> onChange={(v) => update({ autoStartServer: v })} />
</div> </div>
<div className={s.section}>
<p className={s.sectionTitle}>Inactivity</p>
<SelectRow
label="Idle screen timeout"
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
value={String(settings.idleTimeoutMin ?? 5)}
options={[
{ value: "0", label: "Never" },
{ value: "1", label: "1 minute" },
{ value: "2", label: "2 minutes" },
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]}
onChange={(v) => update({ idleTimeoutMin: Number(v) })}
/>
</div>
</div> </div>
); );
} }
@@ -340,6 +359,13 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
checked={settings.gpuAcceleration} checked={settings.gpuAcceleration}
onChange={(v) => update({ gpuAcceleration: v })} /> onChange={(v) => update({ gpuAcceleration: v })} />
</div> </div>
<div className={s.section}>
<p className={s.sectionTitle}>Idle / Splash Screen</p>
<Toggle label="Animated card background"
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
checked={settings.splashCards ?? true}
onChange={(v) => update({ splashCards: v })} />
</div>
<div className={s.section}> <div className={s.section}>
<p className={s.sectionTitle}>Interface</p> <p className={s.sectionTitle}>Interface</p>
<Toggle label="Compact sidebar" <Toggle label="Compact sidebar"
@@ -702,6 +728,51 @@ function FoldersTab() {
); );
} }
function DevToolsTab() {
const [splashTriggered, setSplashTriggered] = useState(false);
function triggerSplash() {
setSplashTriggered(true);
setTimeout(() => setSplashTriggered(false), 200);
(window as any).__mokuShowSplash?.();
}
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Splash Screen</p>
<div className={s.stepRow}>
<div className={s.toggleInfo}>
<span className={s.toggleLabel}>Preview idle screen</span>
<span className={s.toggleDesc}>Show the idle splash dismiss with any click or key</span>
</div>
<button
className={s.dangerBtn}
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
color: splashTriggered ? "var(--bg-base)" : undefined,
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
transition: "all 0.15s ease" }}
onClick={triggerSplash}
>
Show idle
</button>
</div>
</div>
<div className={s.section}>
<p className={s.sectionTitle}>Build Info</p>
<div className={s.aboutBlock}>
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
Mode: {import.meta.env.MODE}
</p>
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
Dev: {String(import.meta.env.DEV)}
</p>
</div>
</div>
</div>
);
}
function AboutTab() { function AboutTab() {
return ( return (
<div className={s.panel}> <div className={s.panel}>
@@ -776,6 +847,7 @@ export default function SettingsModal() {
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />} {tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
{tab === "folders" && <FoldersTab />} {tab === "folders" && <FoldersTab />}
{tab === "about" && <AboutTab />} {tab === "about" && <AboutTab />}
{tab === "devtools" && <DevToolsTab />}
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -65,6 +65,8 @@ export interface Settings {
autoStartServer: boolean; autoStartServer: boolean;
preferredExtensionLang: string; preferredExtensionLang: string;
keybinds: Keybinds; keybinds: Keybinds;
idleTimeoutMin?: number;
splashCards?: boolean;
storageLimitGb: number | null; storageLimitGb: number | null;
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. */
@@ -95,6 +97,8 @@ export const DEFAULT_SETTINGS: Settings = {
autoStartServer: true, autoStartServer: true,
preferredExtensionLang: "en", preferredExtensionLang: "en",
keybinds: DEFAULT_KEYBINDS, keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5,
splashCards: true,
storageLimitGb: null, storageLimitGb: null,
folders: [], folders: [],
readerDebounceMs: 120, readerDebounceMs: 120,