99 lines
2.7 KiB
TypeScript
99 lines
2.7 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useLocation } from "react-router-dom";
|
|
import { SplashScreen } from "../components/pwa/SplashScreen";
|
|
|
|
const MIN_SPLASH_MS = 400;
|
|
const MAX_SPLASH_MS = 8000;
|
|
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
|
|
|
type BootstrapRoute = "chat" | "settings";
|
|
|
|
interface BootstrapContextValue {
|
|
markRouteReady: (route: BootstrapRoute) => void;
|
|
}
|
|
|
|
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
|
|
|
|
export function BootstrapProvider({ children }: { children: ReactNode }) {
|
|
const location = useLocation();
|
|
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
|
|
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
|
|
chat: false,
|
|
settings: false,
|
|
});
|
|
const [splashVisible, setSplashVisible] = useState(true);
|
|
const [splashExiting, setSplashExiting] = useState(false);
|
|
const mountTime = useRef(Date.now());
|
|
|
|
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
|
|
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
|
|
setSplashVisible(true);
|
|
setSplashExiting(false);
|
|
mountTime.current = Date.now();
|
|
}, [route]);
|
|
|
|
useEffect(() => {
|
|
if (readyRoutes[route]) return;
|
|
const timer = window.setTimeout(() => {
|
|
setReadyRoutes((prev) => (prev[route] ? prev : { ...prev, [route]: true }));
|
|
}, MAX_SPLASH_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [route, readyRoutes]);
|
|
|
|
useEffect(() => {
|
|
if (!readyRoutes[route]) return;
|
|
|
|
let warmBoot = false;
|
|
try {
|
|
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
|
|
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
if (warmBoot) {
|
|
setSplashVisible(false);
|
|
setSplashExiting(false);
|
|
return;
|
|
}
|
|
|
|
const elapsed = Date.now() - mountTime.current;
|
|
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
|
|
|
|
const timer = window.setTimeout(() => {
|
|
setSplashExiting(true);
|
|
window.setTimeout(() => setSplashVisible(false), 420);
|
|
}, remaining);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}, [readyRoutes, route]);
|
|
|
|
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
|
|
|
|
return (
|
|
<BootstrapContext.Provider value={value}>
|
|
{children}
|
|
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
|
|
</BootstrapContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useBootstrap(): BootstrapContextValue {
|
|
const ctx = useContext(BootstrapContext);
|
|
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
|
|
return ctx;
|
|
}
|