import { useEffect } from "react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Link, redirect } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; import { detectLocale } from "@trails-cool/i18n"; import { getSessionUser } from "~/lib/auth.server"; import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; import { useUnreadNotifications } from "~/hooks/useUnreadNotifications"; import { Footer } from "~/components/Footer"; import { AccountDropdown } from "~/components/AccountDropdown"; import { MobileNavMenu } from "~/components/MobileNavMenu"; import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; import { TERMS_VERSION } from "~/lib/legal"; import stylesheet from "@trails-cool/ui/styles.css?url"; // Paths that must stay reachable even when the user has a stale // terms_version, so they can read the Terms, accept them, or log out. const TERMS_GATE_ALLOWLIST = [ "/auth/accept-terms", "/auth/logout", "/legal/", ]; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { const { i18n } = useTranslation(); return ( {children} ); } export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); const locale = detectLocale(request); // Gate logged-in users with stale / missing terms_version: send them to the // re-accept page on any request that isn't already on an allow-listed path. if (user && user.termsVersion !== TERMS_VERSION) { const pathname = new URL(request.url).pathname; const onAllowlistedPath = TERMS_GATE_ALLOWLIST.some((p) => p.endsWith("/") ? pathname.startsWith(p) : pathname === p, ); if (!onAllowlistedPath) { const returnTo = encodeURIComponent(pathname + new URL(request.url).search); throw redirect(`/auth/accept-terms?returnTo=${returnTo}`); } } // Unread-notification count for the navbar bell badge. Pending follow // requests are not separately counted here — each pending request // creates an unread `follow_request_received` notification, so the // unread count already covers them. Hidden behind a dynamic import so // the root layout doesn't pull in the notifications module on // anonymous renders. let unreadNotifications = 0; if (user) { const { countUnread } = await import("./lib/notifications.server.ts"); unreadNotifications = await countUnread(user.id); } return { user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null, locale, unreadNotifications, }; } function BellLink({ unread, label }: { unread: number; label: string }) { return ( {unread > 0 && ( {unread} )} ); } function NavBar({ user, unreadNotifications, }: { user: { id: string; username: string; displayName: string | null } | null; unreadNotifications: number; }) { const { t } = useTranslation("journal"); const location = useLocation(); // Live-updating unread count for the navbar badge. Loader value is // the SSR baseline; SSE pushes overrides after first event. const liveUnread = useUnreadNotifications(unreadNotifications, user !== null); const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + "/"); const linkClass = (path: string) => `text-sm font-medium ${ isActive(path) ? "text-blue-600" : "text-gray-600 hover:text-gray-900" }`; return ( ); } export default function App({ loaderData }: Route.ComponentProps) { const user = loaderData?.user; const locale = loaderData?.locale ?? "en"; const unreadNotifications = loaderData?.unreadNotifications ?? 0; useEffect(() => { if (user) { initSentryClient(); Sentry.setUser({ id: user.id }); } else { Sentry.setUser(null); stopSentryClient(); } }, [user]); return (