import { useEffect } from "react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, 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 { 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}`); } } // Pending follow-request count for the navbar badge. Cheap (a single // `count(*) WHERE accepted_at IS NULL`); only computed for signed-in // users. Hidden behind a dynamic import so the root layout doesn't // pull in the follow module on anonymous renders. let pendingFollowRequests = 0; let unreadNotifications = 0; if (user) { const { countPendingFollowRequests } = await import("./lib/follow.server.ts"); const { countUnread } = await import("./lib/notifications.server.ts"); [pendingFollowRequests, unreadNotifications] = await Promise.all([ countPendingFollowRequests(user.id), countUnread(user.id), ]); } return { user: user ? { id: user.id, username: user.username } : null, locale, pendingFollowRequests, unreadNotifications, }; } function NavBar({ user, pendingFollowRequests, unreadNotifications, }: { user: { id: string; username: string } | null; pendingFollowRequests: number; 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 pendingFollowRequests = loaderData?.pendingFollowRequests ?? 0; const unreadNotifications = loaderData?.unreadNotifications ?? 0; useEffect(() => { if (user) { initSentryClient(); Sentry.setUser({ id: user.id }); } else { Sentry.setUser(null); stopSentryClient(); } }, [user]); return (