"use client"; /** * `` — sidebar + main column shell for the v1.4 settings split. * * Each section lives at its own route (`/settings/[section]/page.tsx`) and * mounts inside this shell. The shell owns nothing but navigation: the active * section is read from the route, the section heading + content are rendered * by the page itself. * * Mobile-first per `docs/ui-guidelines.md`: * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. */ import * as React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { AlarmClock, Bell, Blocks, Download, Info, KeyRound, LayoutDashboard, Link2, Settings2, SlidersHorizontal, Sparkles, User, type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, type SettingsSectionSlug, } from "./section-slugs"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import // from `./section-slugs` directly, since this file is `"use client"` and // values exported from a client module become unusable proxies on the // server. (Discovered the hard way: `SETTINGS_SECTION_SLUGS.map(...)` in // `generateStaticParams()` blows up at build time when imported through a // client boundary.) export { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, type SettingsSectionSlug, }; interface SettingsSection { slug: SettingsSectionSlug; /** i18n key under `settings.sections..title`. */ titleKey: string; icon: LucideIcon; } /** * Source of truth for the section list that the settings shell renders * in its sidebar + mobile chip-strip. Order matches the in-app * navigation order. Don't reorder without updating tests. * * "About" sits at the end of the list. v1.4.33 IW7 had folded it into * the sidebar user-card dropdown only, which left `/settings/about` an * orphaned route — reachable by URL but discoverable nowhere in the * settings navigation. It is now both a regular (last) shell entry and * a user-card dropdown item ("Über HealthLog" / "About HealthLog"). * * v1.18.0 (S3) — `sources` is no longer a sidebar entry: source priority * folded into Settings → Integrations as the "Sources" sub-tab. * `thresholds` (Targets) keeps its own entry, served by the dynamic * `[section]` route. */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { slug: "account", titleKey: "settings.sections.account.title", icon: User }, // v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one // place to enable/disable the secondary tracking domains. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, }, { slug: "integrations", titleKey: "settings.sections.integrations.title", icon: Link2, }, { slug: "notifications", titleKey: "settings.sections.notifications.title", icon: Bell, }, // v1.17.1 — the one "Reminders & Notifications" home. Gathers the // scattered reminder categories (medication / mood / Vorsorge / low-stock // / coach nudge) and links to the notification channels. The canonical // editors keep their own routes; this nav entry is the single front door. { slug: "reminders", titleKey: "settings.sections.reminders.title", icon: AlarmClock, }, // v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the // four scattered Dashboard / Insights / Medications / Mood "arrange" // entries. Those routes still resolve (deep links, page-header cogs, and // the hub's own links all work); they are simply reached through the // Layout hub now, so "how my app is laid out" reads as one home. { slug: "layout", titleKey: "settings.sections.layout.title", icon: LayoutDashboard, }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", icon: SlidersHorizontal, }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, { slug: "export", titleKey: "settings.sections.export.title", icon: Download, }, { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, }, { slug: "about", titleKey: "settings.sections.about.title", icon: Info, }, ] as const; export interface SettingsShellProps { /** * Optional override for the active section. When omitted the shell reads * the active slug from `usePathname()` (the production behaviour). Tests * pass an explicit value so they don't need to mock the router. */ active?: SettingsSectionSlug; children: React.ReactNode; } // v1.17.1 (F-2) — the four personalization editors live under the Layout // hub. They keep their own routes but are NOT standalone nav entries, so // when the user is on one of them the Layout nav entry is the one that // reads active. const LAYOUT_CHILD_SLUGS: ReadonlySet = new Set([ "dashboard", "insights", "medications", "mood", ]); /** Map a Layout child editor onto the Layout hub for nav highlighting. */ function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug { return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug; } function deriveActiveSlug( pathname: string | null, override?: SettingsSectionSlug, ): SettingsSectionSlug { if (override) return navHighlightSlug(override); if (!pathname) return "account"; // Match `/settings/` and ignore any trailing segments (none today, // but cheap insurance for future nested routes). const match = pathname.match(/^\/settings\/([^/]+)/); const candidate = match?.[1] ?? ""; if (LAYOUT_CHILD_SLUGS.has(candidate)) return "layout"; return isSettingsSectionSlug(candidate) ? candidate : "account"; } export function SettingsShell({ active, children }: SettingsShellProps) { const pathname = usePathname(); const { t } = useTranslations(); const activeSlug = deriveActiveSlug(pathname, active); // v1.4.33 IW4 — keep the active chip in view inside the horizontal // mobile strip. On a 393 CSS px viewport the strip is wider than the // viewport and the rightmost chips sit off-screen; a user who tapped // one of those chips lands on the new route with the strip still // scrolled to the leftmost chip, which reads as if the navigation is // broken. We pin the active chip to the left edge of the strip. // // v1.7.0 — adjust only the strip's own horizontal scroll offset // (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`. // `scrollIntoView` walks every scrollable ancestor and adjusts both // axes, so on mobile it also nudged the whole document vertically on // each route change — a dizzy auto-scroll that fired on every settings // sub-page tap. Scrolling the strip's own `scrollLeft` confines the // motion to its horizontal axis and never touches the page scroll // position. const mobileStripRef = React.useRef(null); React.useEffect(() => { if (typeof window === "undefined") return; const strip = mobileStripRef.current; if (!strip) return; const active = strip.querySelector('[aria-current="page"]'); if (!active) return; // Land the active chip at the strip's left edge, clamped to the // scrollable range. `offsetLeft` is relative to the strip's padding // box, which is the origin for `scrollLeft`. const maxScroll = strip.scrollWidth - strip.clientWidth; const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); strip.scrollTo({ left: target, // v1.4.43 W5-H5 — respect `prefers-reduced-motion`. behavior: scrollBehaviorForUser(), }); }, [activeSlug]); // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was // producing visibly more top/bottom whitespace on Settings/Admin // pages than on Dashboard/Insights/Measurements. return (
{/* Mobile section strip — horizontal scroll, hidden on md+. `no-scrollbar` (defined in `globals.css`) suppresses the painted scrollbar; the horizontal swipe + keyboard arrow scrolling still work. Without it the 10-section strip renders an always-on scrollbar at the top of every settings page, which makes the page feel like every section card has an overflow problem. v1.4.33 IW7 — `snap-x snap-mandatory` lets a swipe-flick land on the next chip's leading edge instead of the in-between dead zone. The auto `scrollIntoView({inline: "center"})` effect above stays the canonical positioner for the active chip; snap is the polish layer when the user manually flicks through the strip without tapping. */}
{/* Desktop sticky sidebar */} {/* Main column — page renders its own h1 + subtitle. The `min-h-[calc(100dvh-12rem)]` reserve keeps the column tall enough that swapping a short loading state for a long section list (Thresholds, Sources) does not jump the page height under the sticky sidebar. v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter on ``, not `
`: the surrounding AuthShell already provides the page's single `
` landmark and a nested second one is an a11y violation. */}
{children}
); }