mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The "Skip to content" link is `position: fixed; top-2 left-2 z-[100]` on every authenticated page. Even with `opacity-0` it still occupied its bounding box, so a mouse click on the desktop logo (also at the top-left corner) sometimes hit the invisible link instead. Fix: keep the visual treatment (opacity, focus styles) but combine `pointer-events-none` + `-translate-y-full` in the unfocused state so the element neither receives clicks nor occupies any visible space. On focus, both modifiers reverse — the link slides into view and becomes interactive again. Existing keyboard flow is unchanged: Tab focuses the link, the focus ring + translucent slide-in is preserved. Tests: extend `e2e/a11y.spec.ts` with a Playwright check that the desktop logo is clickable from its centre on the dashboard and the URL stays at "/". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
149 lines
4.4 KiB
TypeScript
149 lines
4.4 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 { useAuth } from "@/hooks/use-auth";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { BottomNav } from "./bottom-nav";
|
|
import { SidebarNav } from "./sidebar-nav";
|
|
import { TopBar } from "./top-bar";
|
|
|
|
const PUBLIC_PATHS = ["/auth/login", "/auth/register"];
|
|
|
|
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));
|
|
const isAdminPage = pathname.startsWith("/admin");
|
|
const isOnboardingPage = pathname === "/onboarding";
|
|
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
|
|
|
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]);
|
|
|
|
// Redirect to onboarding if not completed
|
|
useEffect(() => {
|
|
if (
|
|
!isLoading &&
|
|
isAuthenticated &&
|
|
!isOnboardingPage &&
|
|
!isPublicPage &&
|
|
user &&
|
|
!user.onboardingCompletedAt
|
|
) {
|
|
router.replace("/onboarding");
|
|
}
|
|
}, [
|
|
isLoading,
|
|
isAuthenticated,
|
|
isOnboardingPage,
|
|
isPublicPage,
|
|
user,
|
|
router,
|
|
]);
|
|
|
|
// 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" />
|
|
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Public pages (login, register) — render without any nav
|
|
if (isPublicPage) {
|
|
return (
|
|
<main
|
|
id="main-content"
|
|
className="flex h-dvh items-center justify-center px-4"
|
|
>
|
|
{children}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// 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" />
|
|
<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 items-center justify-center px-4 py-8">
|
|
{children}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Authenticated — full app shell
|
|
return (
|
|
<>
|
|
{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 fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform pointer-events-none focus:translate-y-0 focus:opacity-100 focus:pointer-events-auto"
|
|
>
|
|
{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">
|
|
<TopBar />
|
|
<main
|
|
id="main-content"
|
|
className="flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
|
|
>
|
|
<div className="mx-auto max-w-[76.8rem] px-4 py-6 md:px-6">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<BottomNav />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|