web/src/routes/__root.tsx
4.2 KB · sha256:63af0462f3c365171f9cdc00572b7f706ff2001fe932b92671648074cafd7999
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import {
MutationCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ThemeProvider } from "../lib/theme";
import { AuthProvider, useAuth } from "../lib/auth";
import { Header, Sidebar } from "../components/layout";
import { LoginScreen } from "../components/login";
import {
ToastProvider,
_bindToastApi,
getToastApi,
useToast,
} from "../components/toast";
export const Route = createRootRoute({
component: RootComponent,
});
interface MutationMeta {
/** Toast shown while the mutation is in flight (auto-replaced on settle). */
pending?: string;
/** Toast on success. Can be a string or a fn of the mutation result. */
success?: string | ((data: unknown) => string);
/** Defaults to the thrown error's message. */
error?: string | ((err: Error) => string);
/** When false, suppress the global error toast even on failure. */
toastErrors?: boolean;
}
function RootComponent() {
const [client] = useState(() => {
const cache = new MutationCache({
onMutate: (_vars, mutation) => {
const meta = (mutation.meta as MutationMeta | undefined) ?? {};
const api = getToastApi();
if (api && meta.pending) {
const id = api.loading(meta.pending);
(mutation.state as { toastId?: string }).toastId = id;
}
},
onSuccess: (data, _vars, _ctx, mutation) => {
const meta = (mutation.meta as MutationMeta | undefined) ?? {};
const api = getToastApi();
if (!api) return;
const id = (mutation.state as { toastId?: string }).toastId;
const msg =
typeof meta.success === "function" ? meta.success(data) : meta.success;
if (msg) api.success(msg, id ? { id } : undefined);
else if (id) api.dismiss(id);
},
onError: (err, _vars, _ctx, mutation) => {
const meta = (mutation.meta as MutationMeta | undefined) ?? {};
if (meta.toastErrors === false) return;
const api = getToastApi();
if (!api) return;
const id = (mutation.state as { toastId?: string }).toastId;
const e = err as Error;
const msg =
typeof meta.error === "function"
? meta.error(e)
: meta.error || e.message || "Something went wrong";
api.error(msg, id ? { id } : undefined);
},
});
return new QueryClient({
mutationCache: cache,
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
retry: false,
},
},
});
});
return (
<ThemeProvider>
<ToastProvider>
<ToastBridge />
<AuthProvider>
<QueryClientProvider client={client}>
<AuthGate>
<div className="bg-surface-2 text-fg flex h-full min-h-screen">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Header />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
</AuthGate>
{import.meta.env.DEV && (
<TanStackRouterDevtools position="bottom-right" />
)}
</QueryClientProvider>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
);
}
/**
* Publishes the toast API to the module-level `getToastApi()` accessor so
* the QueryClient's MutationCache (constructed outside the React tree) can
* fire toasts without needing the hook.
*/
function ToastBridge() {
const api = useToast();
useEffect(() => {
_bindToastApi(api);
return () => _bindToastApi(null);
}, [api]);
return null;
}
function AuthGate({ children }: { children: React.ReactNode }) {
const { token, whoami, loading } = useAuth();
if (!token || (!whoami && !loading)) return <LoginScreen />;
if (loading || !whoami) {
return (
<div className="bg-surface-2 text-fg-muted flex min-h-screen items-center justify-center text-sm">
Verifying…
</div>
);
}
return <>{children}</>;
}