Files
HealthLog/src/components/layout/top-bar.tsx
T
Marc-André Bombeck 04765ea8ac style: one spacing, title and skeleton language across the app
Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
2026-06-10 16:54:15 +02:00

187 lines
7.2 KiB
TypeScript

"use client";
import {
Bell,
ChevronDown,
LogIn,
LogOut,
Monitor,
Moon,
Settings,
Shield,
Sun,
} from "lucide-react";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function TopBar() {
const { user, isLoading } = useAuth();
const logout = useLogout();
const { theme, setTheme } = useTheme();
const { t } = useTranslations();
const avatarUrl = user?.avatarUrl ?? null;
const isAdmin = user?.role === "ADMIN";
const themeIcon =
theme === "system" ? (
<Monitor className="h-4 w-4" />
) : theme === "dark" ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
);
return (
<header
className="bg-card/80 border-border sticky top-0 z-40 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md md:px-6"
// iOS PWA on notched iPhones overlays the status bar onto the
// sticky header unless we reserve the safe-area inset. The
// inline style adds `safe-area-inset-top` as padding-top on
// devices that report one and is a no-op on every other
// platform; the inner h-16 content area stays nominally 64 px.
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
>
{/* Mobile logo */}
<Link href="/" className="flex items-center gap-2 md:hidden">
<Logo className="text-primary" size={20} />
<span className="font-bold tracking-tight">HealthLog</span>
</Link>
{/* Desktop: empty spacer (user controls are in sidebar) */}
<div className="hidden md:block" />
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">
{isLoading ? (
<Skeleton className="bg-muted h-4 w-20 rounded" />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("nav.userMenu")}
// v1.4.25 W8 — hit the WCAG 2.5.5 44 px touch-target floor on
// mobile. The text-only py-1.5 was previously ~30 px tall.
//
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible. The previous `focus:outline-none` killed
// the focus indicator without replacing it.
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Avatar className="size-7">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{user.username}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem asChild>
<Link href="/settings/account" className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
{t("nav.settings")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/notifications" className="cursor-pointer">
<Bell className="mr-2 h-4 w-4" />
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
{t("nav.admin")}
</Link>
</DropdownMenuItem>
)}
{/* v1.4.36 W4e — About moved into the Admin Console
(`/admin/about`). The dropdown entry was redundant
for the small audience that still reaches it (admins
only, on the order of once or twice a year). */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{themeIcon}
<span className="ml-2">{t("nav.theme")}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
{t("nav.themeSystem")}
{theme === "system" && (
<span className="text-primary ml-auto text-xs">
&#10003;
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
{t("nav.themeDark")}
{theme === "dark" && (
<span className="text-primary ml-auto text-xs">
&#10003;
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
{t("nav.themeLight")}
{theme === "light" && (
<span className="text-primary ml-auto text-xs">
&#10003;
</span>
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => logout.mutate()}
className="text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer"
>
<LogOut className="mr-2 h-4 w-4" />
{t("nav.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link
href="/auth/login"
// v1.4.25 W8 — match the WCAG 2.5.5 44 px touch-target floor.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-2 rounded-md px-2 text-sm transition-colors"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">{t("nav.login")}</span>
</Link>
)}
</div>
</header>
);
}