mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
- Range pills: make the radiogroup a single tab stop with roving tabindex and Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern. Previously every pill was its own tab stop and the arrow keys were dead. - Range delta caption: render a neutral dash + "no change" for a perfectly stable metric instead of a down-arrow with "−0%". Adds the `noChange` string across all six locales. - Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the body-scrolled shells (login / register / onboarding / legal) via a `data-scroll` attribute the shell sets, so the height-locked authenticated branch no longer reserves a second, never-painted gutter on classic scrollbar platforms. - Mood tiles: a lone in-target / stability tile now spans full width instead of leaving a half-width orphan beside it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
226 lines
9.1 KiB
TypeScript
226 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { Loader2 } from "lucide-react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useEffect } from "react";
|
|
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
|
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
|
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
|
import { BottomNav } from "./bottom-nav";
|
|
import { OfflineBanner } from "./offline-banner";
|
|
import { SidebarNav } from "./sidebar-nav";
|
|
import { TopBar } from "./top-bar";
|
|
|
|
// v1.4.27 MB6 — `/about` joins the public-path list so the GeoLite2
|
|
// CC BY-SA 4.0 attribution stays reachable for unauthenticated
|
|
// visitors. The `/about` route is already registered in `proxy.ts`
|
|
// PUBLIC_PATHS; this constant gates the client-side auth shell, and
|
|
// both lists must agree or the route round-trips through the
|
|
// sign-in redirect.
|
|
const PUBLIC_PATHS = [
|
|
"/auth/login",
|
|
"/auth/register",
|
|
"/privacy",
|
|
"/about",
|
|
];
|
|
|
|
export function AuthShell({ children }: { children: React.ReactNode }) {
|
|
const { user, isAuthenticated, isLoading } = useAuth();
|
|
const { t } = useTranslations();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
|
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
|
|
// header + footer. Centering it inside the public-page wrapper would
|
|
// squash a 3000-word policy into a "login card" layout, so we let it
|
|
// render edge-to-edge while still resolving as a public page for the
|
|
// auth gate above.
|
|
// v1.4.27 MB6 — `/about` follows the same shape (own header, own
|
|
// footer, full-width body), so it joins the standalone list.
|
|
const isStandalonePublicPage =
|
|
pathname.startsWith("/privacy") || pathname.startsWith("/about");
|
|
const isAdminPage = pathname.startsWith("/admin");
|
|
const isOnboardingPage = pathname === "/onboarding";
|
|
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
|
|
|
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
|
|
// only for the body-scrolled shells (login / register / onboarding /
|
|
// standalone legal pages). The authenticated branch is height-locked and
|
|
// scrolls inside its own `<main>`, which reserves its own gutter; applying
|
|
// the document gutter there too produced a second, never-painted gutter on
|
|
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
|
|
// so the CSS rule scopes to them.
|
|
const isBodyScrolled = isPublicPage || isOnboardingPage;
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
if (isBodyScrolled) {
|
|
root.setAttribute("data-scroll", "body");
|
|
} else {
|
|
root.removeAttribute("data-scroll");
|
|
}
|
|
return () => {
|
|
root.removeAttribute("data-scroll");
|
|
};
|
|
}, [isBodyScrolled]);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !isAuthenticated && !isPublicPage) {
|
|
router.replace("/auth/login");
|
|
}
|
|
}, [isLoading, isAuthenticated, isPublicPage, router]);
|
|
|
|
// Redirect non-admins away from /admin
|
|
useEffect(() => {
|
|
if (
|
|
!isLoading &&
|
|
isAuthenticated &&
|
|
isAdminPage &&
|
|
user?.role !== "ADMIN"
|
|
) {
|
|
router.replace("/");
|
|
}
|
|
}, [isLoading, isAuthenticated, isAdminPage, user?.role, router]);
|
|
|
|
// v1.4.22 C4 — onboarding redirect moved to proxy.ts so it lands on
|
|
// the first server response instead of post-hydration. The proxy
|
|
// reads the `hl_onboarding` cookie (mirrored from the DB by the auth
|
|
// routes) and 307s to /onboarding before this shell ever paints. The
|
|
// previous `useEffect`-based redirect caused a brief dashboard flash
|
|
// for users with `onboardingCompletedAt === null`.
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-dvh items-center justify-center" role="status">
|
|
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
|
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Public pages (login, register) — render without any nav
|
|
if (isPublicPage) {
|
|
// Long-form legal pages render edge-to-edge with their own chrome.
|
|
if (isStandalonePublicPage) {
|
|
return <>{children}</>;
|
|
}
|
|
return (
|
|
<div className="flex h-dvh flex-col">
|
|
<MaintainershipBanner />
|
|
<main
|
|
id="main-content"
|
|
className="flex flex-1 items-center justify-center px-4"
|
|
>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Not authenticated, waiting for redirect
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className="flex h-dvh items-center justify-center" role="status">
|
|
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
|
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Onboarding page — minimal shell, no sidebar/nav
|
|
if (isOnboardingPage) {
|
|
return (
|
|
<>
|
|
{showUnlockNotifier && user?.id ? (
|
|
<AchievementUnlockNotifier userId={user.id} />
|
|
) : null}
|
|
<div className="flex min-h-dvh flex-col">
|
|
<MaintainershipBanner />
|
|
<div className="flex flex-1 items-center justify-center px-4 py-8">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Authenticated — full app shell.
|
|
//
|
|
// v1.4.34 IW-B — the Coach launch provider lives here (not inside
|
|
// `app/insights/layout.tsx` anymore) so every authenticated route can
|
|
// call `askCoach()` from the same context. Pre-hoist the drawer was
|
|
// only reachable from `/insights/**`; the dashboard hero CTA now opens
|
|
// it without a route hop. `<LayoutCoachMount>` consumes the same
|
|
// context to render the drawer once at the shell level — the
|
|
// insights-only mobile FAB stays where it is, inside the routed
|
|
// insights layout, so the bottom-right floating action sits beside
|
|
// the chart tooltips only on the surfaces that need it.
|
|
return (
|
|
<CoachLaunchProvider>
|
|
{showUnlockNotifier && user?.id ? (
|
|
<AchievementUnlockNotifier userId={user.id} />
|
|
) : null}
|
|
{/*
|
|
Keyboard-only skip link. The non-focused state has both
|
|
`pointer-events-none` (so a mouse click in the top-left corner
|
|
passes through to the logo behind it) AND `-translate-y-full`
|
|
(so even an accidental tap with stylus / touch can't activate
|
|
it). Focus restores both — the element jumps back into the
|
|
viewport and becomes clickable.
|
|
*/}
|
|
<a
|
|
href="#main-content"
|
|
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
|
|
>
|
|
{t("nav.skipToContent")}
|
|
</a>
|
|
<div className="flex h-dvh flex-col md:flex-row">
|
|
<SidebarNav />
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{/*
|
|
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
|
|
`navigator.onLine === false`. Sits above the maintainership
|
|
banner + top bar so the connection-status hint is always
|
|
the first chrome line a user sees in the offline branch.
|
|
*/}
|
|
<OfflineBanner />
|
|
<MaintainershipBanner />
|
|
<TopBar />
|
|
<main
|
|
id="main-content"
|
|
// `scrollbar-gutter: stable` keeps the scroll viewport's content
|
|
// box a fixed width whether or not the vertical scrollbar is
|
|
// painted. Without it a route / sub-tab whose body overflows
|
|
// (e.g. the admin "Insights Quality" section) shows the bar and
|
|
// narrows the content box, while a shorter one (e.g.
|
|
// "Integrations") hides it and widens the box — the `mx-auto`
|
|
// wrapper then recentres and the whole column, including the
|
|
// admin/settings sidebar, shifts a few px sideways on every
|
|
// toggle. Reserving the gutter holds the layout still.
|
|
className="flex-1 overflow-y-auto [scrollbar-gutter:stable] pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
|
|
>
|
|
{/*
|
|
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
|
|
(1280 px) so dashboard / settings / admin / bugreport all
|
|
hit the same content frame. Pre-v1.4.33 the dashboard
|
|
shell capped at `max-w-[76.8rem]` (1228 px) while the
|
|
settings/admin shells used `max-w-screen-xl`, producing a
|
|
52 px lateral wobble on every route switch. Same audit
|
|
note in `.planning/round-v1433-audit-polish.md` §4.2.
|
|
*/}
|
|
<div className="mx-auto max-w-screen-xl px-4 py-6 md:px-6">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<BottomNav />
|
|
</div>
|
|
<LayoutCoachMount />
|
|
</CoachLaunchProvider>
|
|
);
|
|
}
|