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 (
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
// Don't report expected HTTP errors (404, 403) to Sentry
if (!(isRouteErrorResponse(error) && error.status < 500)) {
Sentry.captureException(error);
}
const { t } = useTranslation();
if (isRouteErrorResponse(error)) {
return (