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 (
);
}
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 (