[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
+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 { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client";
@@ -11,9 +11,12 @@ import Settings from "./components/settings/Settings";
import MangaPreview from "./components/explore/MangaPreview";
import TitleBar from "./components/layout/TitleBar";
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 s from "./App.module.css";
const MAX_ATTEMPTS = 30;
export default function App() {
const activeChapter = useStore((s) => s.activeChapter);
const settingsOpen = useStore((s) => s.settingsOpen);
@@ -21,24 +24,51 @@ export default function App() {
const setActiveDownloads = useStore((s) => s.setActiveDownloads);
const addToast = useStore((s) => s.addToast);
// Ref-based snapshot of the last known queue so we can diff across polls/events
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
// serverProbeOk = server responded, but we wait for ring to finish before showing UI
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[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
const stillPresent = next.some((q) => q.chapter.id === item.chapter.id);
if (!stillPresent) {
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({
kind: "success",
title: "Chapter downloaded",
body: manga
? `${manga.title}${item.chapter.name}`
: item.chapter.name,
duration: 4000,
});
addToast({ kind:"success", title:"Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000 });
}
}
}
@@ -46,13 +76,9 @@ export default function App() {
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueueRef.current, next);
prevQueueRef.current = next;
setActiveDownloads(
next.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}))
);
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
})));
}
useEffect(() => {
@@ -60,52 +86,103 @@ export default function App() {
}, [settings.uiScale]);
useEffect(() => {
const prevent = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", prevent);
return () => document.removeEventListener("contextmenu", prevent);
const p = (e: MouseEvent) => e.preventDefault();
document.addEventListener("contextmenu", p);
return () => document.removeEventListener("contextmenu", p);
}, []);
useEffect(() => {
if (!settings.autoStartServer) return;
invoke("spawn_server", { binary: settings.serverBinary }).catch((err) =>
console.warn("Could not start server:", err)
);
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
console.warn("Could not start server:", err));
return () => { invoke("kill_server").catch(() => {}); };
}, [settings.autoStartServer, settings.serverBinary]);
// Global download status poller — always running, regardless of which page is open.
// This is the single source of truth for completion toasts.
// Poll until server responds
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() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => applyQueue(d.downloadStatus.queue))
.catch(console.error);
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
}
poll(); // immediate first fetch
poll();
const id = setInterval(poll, 2000);
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(() => {
type DlPayload = { chapterId: number; mangaId: number; progress: number }[];
const unsub = listen<DlPayload>("download-progress", (e) => {
setActiveDownloads(e.payload);
});
return () => { unsub.then((fn) => fn()); };
type P = { chapterId:number; mangaId:number; progress:number }[];
const unsub = listen<P>("download-progress", e => setActiveDownloads(e.payload));
return () => { unsub.then(fn => fn()); };
}, [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 (
<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}>
{activeChapter ? <Reader /> : <Layout />}
{activeChapter ? <Reader/> : <Layout/>}
</div>
{settingsOpen && <Settings />}
<MangaPreview />
<Toaster />
{settingsOpen && <Settings/>}
<MangaPreview/>
<Toaster/>
</div>
);
}