app/root.tsx
Rama main · réplica de lectura del repositorio original.
// Copyright (c) 2026 Doforu
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE-APACHE file or at:
// https://opensource.org/licenses/Apache-2.0
// Modifications Copyright (c) 2026 Doforu, distributed under the AGPL-3.0-only (see LICENSE).
import {
Button,
Empty,
LinkProvider,
Loader,
Toasty,
TooltipProvider,
} from "@cloudflare/kumo";
import { TriangleAlert } from "lucide-react";
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { forwardRef, useEffect, useState } from "react";
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Link as RouterLink,
Scripts,
ScrollRestoration,
} from "react-router";
import { useTranslation } from "react-i18next";
import { ApiError } from "~/services/api";
import api from "~/services/api";
import { Navigate, useLocation } from "react-router";
import LanguageSwitcher from "~/components/LanguageSwitcher";
import { i18nextMiddleware, getLocale } from "~/middleware/i18next";
import type { Route } from "./+types/root";
import "./index.css";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
// Don't retry 4xx errors (not found, unauthorized, etc.)
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false;
}
return failureCount < 2;
},
},
},
mutationCache: new MutationCache({
onError: (error) => {
// Global fallback for mutations that don't handle errors themselves.
// Consumers using mutateAsync + try/catch handle their own errors.
console.error("Mutation failed:", error);
},
}),
});
}
// Lazy singleton for the browser — avoids module-scope instantiation that
// leaks cache across SSR requests.
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === "undefined") {
// SSR: always create a fresh client per request to prevent cross-user cache leaks
return makeQueryClient();
}
// Browser: reuse the same client across navigations
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
// [i18n-foundation] Per-request i18next middleware. Lives on the root route so
// every page — including the `*` not-found route — runs through it and can
// resolve `context.get(i18nextContext)`. See app/middleware/i18next.ts.
export const middleware = [i18nextMiddleware];
/**
* Expose the request locale to the document shell. The `t` function itself is
* delivered via `<I18nextProvider>` in entry.{server,client}.tsx, so we only
* ship the small language tag as loader data here.
*/
export function loader({ context }: Route.LoaderArgs) {
return { locale: getLocale(context as never) };
}
/**
* [language-switcher-placement] Floating language switcher for every route
* that does NOT render the mailbox `<Header />` (which owns its own switcher,
* see app/components/Header.tsx).
*
* Rendered as a sibling of `AuthGate` *inside* `<Toasty>` so it
* - is part of the SSR document (visible to `curl`), rather than appearing
* only after the client-side auth probe resolves, and
* - stays visible on `/login` + `/setup`, and through the AuthGate loading
* splash, which are exactly the pre-auth pages that had no control before.
*
* `useLocation()` is safe here: `App` is the root route component and always
* renders inside the router context.
*/
function FloatingLanguageSwitcher() {
const location = useLocation();
// `/mailbox/*` already has the switcher inside `Header`.
if (location.pathname.startsWith("/mailbox/")) return null;
return (
<div className="fixed top-3 right-3 z-40">
<LanguageSwitcher />
</div>
);
}
const KumoLink = forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }
>(function KumoLink({ href, ...props }, ref) {
if (href && !href.startsWith("http")) {
return (
<RouterLink to={href} ref={ref} {...(props as Record<string, unknown>)} />
);
}
return <a href={href} ref={ref} {...props} />;
});
export function Layout({ children }: { children: React.ReactNode }) {
// [i18n-foundation] `<html lang>` follows the live i18next language so it
// stays correct on the server (detected locale) and after an in-app switch.
const { i18n } = useTranslation();
return (
<html lang={i18n.language} data-theme="porcelain" data-mode="light">
<head>
<script dangerouslySetInnerHTML={{
__html: [
"window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {};",
"window.$RefreshReg$ = () => {};",
"window.$RefreshSig$ = () => (type) => type;",
"window.__vite_plugin_react_preamble_installed__ = true;",
"window.RefreshRuntime = {",
" injectIntoGlobalHook: () => {},",
" createOverlay: () => ({ show() {}, hide() {} }),",
" performReactRefresh: () => Promise.resolve(),",
"};",
"console.log('[Polyfill] $RefreshSig$ set to:', typeof window.$RefreshSig$);",
].join("\n"),
}} />
<meta charSet="UTF-8" />
{/* Theme boot: allow ?theme=<kumo|midnight|porcelain|sakura>&mode=<light|dark>
to override the default theme before first paint. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var p=new URLSearchParams(location.search);var t=p.get("theme"),m=p.get("mode");if(t)document.documentElement.setAttribute("data-theme",t);if(m)document.documentElement.setAttribute("data-mode",m);}catch(e){}})();`,
}}
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link
rel="icon"
type="image/x-icon"
href="/favicon.ico"
sizes="48x48 32x32 16x16"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mailboxes</title>
<Meta />
<Links />
</head>
<body className="bg-kumo-recessed text-kumo-default antialiased">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export function HydrateFallback() {
return (
<div className="flex items-center justify-center h-screen">
<Loader size="lg" />
</div>
);
}
// ── Auth gate ──────────────────────────────────────────────────
// Checks the admin session once on mount. Unauthenticated visitors are
// redirected to /login; authenticated visitors on /login are sent home.
// Logout / login use a full page reload (window.location), so the gate
// always re-runs when auth state changes.
function AuthGate({ children }: { children: React.ReactNode }) {
const location = useLocation();
const [status, setStatus] = useState<
"loading" | "authed" | "unauthed" | "setup"
>("loading");
useEffect(() => {
let cancelled = false;
api.auth
.me()
.then(() => {
if (!cancelled) setStatus("authed");
})
.catch(async () => {
// Not signed in. A fresh deployment has no admin account yet, in
// which case the setup wizard is the only reachable page.
try {
const { initialized } = await api.adminStatus();
if (!cancelled) setStatus(initialized ? "unauthed" : "setup");
} catch {
if (!cancelled) setStatus("unauthed");
}
});
return () => {
cancelled = true;
};
}, []);
const isLoginPage = location.pathname === "/login";
const isSetupPage = location.pathname === "/setup";
if (status === "loading") {
return (
<div className="flex h-screen items-center justify-center bg-kumo-recessed">
<Loader size="lg" />
</div>
);
}
// Uninitialised deployment: force the first-run setup wizard.
if (status === "setup") {
return isSetupPage ? children : <Navigate to="/setup" replace />;
}
if (status === "authed") {
return isLoginPage || isSetupPage ? <Navigate to="/" replace /> : children;
}
return isLoginPage ? children : <Navigate to="/login" replace />;
}
export default function App() {
// Use useState to ensure each SSR request gets a fresh client while the
// browser reuses the same singleton across navigations.
const [queryClient] = useState(getQueryClient);
return (
<QueryClientProvider client={queryClient}>
<LinkProvider component={KumoLink}>
<TooltipProvider>
<Toasty>
<>
<FloatingLanguageSwitcher key="language-switcher" />
<AuthGate key="auth-gate">
<Outlet />
</AuthGate>
</>
</Toasty>
</TooltipProvider>
</LinkProvider>
</QueryClientProvider>
);
}
export function ErrorBoundary({ error }: { error: unknown }) {
const { t } = useTranslation("auth");
let title = t("errorGenericTitle");
let description = t("errorGenericDescription");
let status: number | null = null;
if (isRouteErrorResponse(error)) {
status = error.status;
if (error.status === 404) {
title = t("errorNotFoundTitle");
description = t("errorNotFoundDescription");
} else {
title = t("errorStatusTitle", { status: error.status });
description = error.statusText || description;
}
} else if (error instanceof Error && import.meta.env.DEV) {
description = error.message;
}
return (
<div className="flex items-center justify-center min-h-screen p-8">
<Empty
icon={<TriangleAlert size={48} className="text-kumo-inactive" />}
title={status === 404 ? t("error404Title") : title}
description={description}
contents={
<Button
variant="primary"
onClick={() => {
window.location.href = "/";
}}
>
{t("common:goHome")}
</Button>
}
/>
</div>
);
}