// 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 `` 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 `
` (which owns its own switcher, * see app/components/Header.tsx). * * Rendered as a sibling of `AuthGate` *inside* `` 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 (
); } const KumoLink = forwardRef< HTMLAnchorElement, React.AnchorHTMLAttributes & { href?: string } >(function KumoLink({ href, ...props }, ref) { if (href && !href.startsWith("http")) { return ( )} /> ); } return ; }); export function Layout({ children }: { children: React.ReactNode }) { // [i18n-foundation] `` follows the live i18next language so it // stays correct on the server (detected locale) and after an in-app switch. const { i18n } = useTranslation(); return (