mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account
Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).
Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): route every Codex OAuth callback redirect to /settings/integrations
Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.
After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.
All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ddab92e42b
commit
3221f86eea
18 files changed
+3879
-3253
No files matched your search
@@ -158,7 +158,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
{gravatarUrl && (
|
||||
<AvatarImage src={gravatarUrl} alt={user.username} />
|
||||
@@ -193,7 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="text-muted-foreground hover:text-foreground hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{dropdownContent}
|
||||
@@ -364,7 +370,9 @@ export function SidebarNav() {
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/bugreport"
|
||||
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
||||
aria-current={
|
||||
pathname === "/bugreport" ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/bugreport"
|
||||
@@ -382,11 +390,13 @@ export function SidebarNav() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
@@ -415,11 +425,13 @@ export function SidebarNav() {
|
||||
{t("nav.bugreport")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TopBar() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="cursor-pointer">
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<SettingsShell>` reads the active route from `usePathname()`. We stub
|
||||
// next/navigation so the SSR test render works without an App-Router
|
||||
// runtime. The mock reads `mockPathnameRef.value` at call-time (not at
|
||||
// module-load) so each test can mutate the ref before rendering and the
|
||||
// shell will see the updated path.
|
||||
const mockPathnameRef = { value: "/settings/account" };
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathnameRef.value,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
isSettingsSectionSlug,
|
||||
} from "../settings-shell";
|
||||
|
||||
function renderShell(props: {
|
||||
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
|
||||
pathname?: string;
|
||||
locale?: "en" | "de";
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/settings/account";
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={props.locale ?? "en"}>
|
||||
<SettingsShell active={props.active}>
|
||||
<div>section body</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
it("declares exactly the eight sections agreed for v1.4", () => {
|
||||
// Order matters — `generateStaticParams()` and the sidebar derive their
|
||||
// ordering from this constant, so a reorder is a behaviour change.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
|
||||
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
}
|
||||
expect(isSettingsSectionSlug("nope")).toBe(false);
|
||||
expect(isSettingsSectionSlug("")).toBe(false);
|
||||
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
|
||||
});
|
||||
|
||||
it("each section has a non-empty title key under settings.sections.<slug>", () => {
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(section.titleKey).toBe(`settings.sections.${section.slug}.title`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
||||
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
||||
// but the markup is always present so it's keyboard-discoverable
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
|
||||
// `/settings#withings` → `/settings/integrations`) — they must NOT
|
||||
// sneak back in.
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
expect(html).not.toContain('href="/settings#');
|
||||
expect(html).not.toContain('href="/settings"');
|
||||
});
|
||||
|
||||
it('marks the active section with aria-current="page" (and only that one)', () => {
|
||||
const html = renderShell({ active: "notifications" });
|
||||
// Both layouts emit the active link, so two `aria-current="page"`.
|
||||
const occurrences = html.match(/aria-current="page"/g);
|
||||
expect(occurrences?.length ?? 0).toBe(2);
|
||||
|
||||
// And the active link is on the notifications href. Attribute order
|
||||
// in React SSR is alphabetic, so `aria-current` precedes `href` —
|
||||
// the regex deliberately doesn't pin order to stay robust against
|
||||
// future React renderer changes.
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("derives the active slug from the current pathname when `active` is omitted", () => {
|
||||
const html = renderShell({ pathname: "/settings/api" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
||||
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — English", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).toContain("Account");
|
||||
expect(html).toContain("Integrations");
|
||||
expect(html).toContain("Notifications");
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("AI Insights");
|
||||
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Advanced");
|
||||
expect(html).toContain("About");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
const html = renderShell({ active: "account", locale: "de" });
|
||||
expect(html).toContain("Konto");
|
||||
expect(html).toContain("Integrationen");
|
||||
expect(html).toContain("Benachrichtigungen");
|
||||
expect(html).toContain("Übersicht");
|
||||
expect(html).toContain("KI-Auswertungen");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
expect(html).toContain("Über");
|
||||
});
|
||||
|
||||
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SectionPlaceholder>` — the temporary body of every `/settings/[section]`
|
||||
* route until the matching extraction PR lands.
|
||||
*
|
||||
* The shell + routing surface ship in PR A2-shell so reviewers can validate
|
||||
* the URL structure in isolation. The follow-up PRs (A2-account, A2-about,
|
||||
* A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder
|
||||
* with the real content for each slug, copying from
|
||||
* `src/app/settings/page.legacy.tsx`.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "./section-slugs";
|
||||
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
integrations: Link2,
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
advanced: Settings2,
|
||||
about: Info,
|
||||
};
|
||||
|
||||
export interface SectionPlaceholderProps {
|
||||
slug: SettingsSectionSlug;
|
||||
}
|
||||
|
||||
export function SectionPlaceholder({ slug }: SectionPlaceholderProps) {
|
||||
const { t } = useTranslations();
|
||||
const Icon = SLUG_ICON[slug];
|
||||
const sectionTitle = t(`settings.sections.${slug}.title`);
|
||||
const sectionDescription = t(`settings.sections.${slug}.description`);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={`settings-section-${slug}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id={`settings-section-${slug}-title`}
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{sectionTitle}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{sectionDescription}</p>
|
||||
</header>
|
||||
|
||||
<EmptyState
|
||||
icon={<Icon className="size-6" />}
|
||||
title={sectionTitle}
|
||||
description={t("settings.sections.placeholder.coming_soon")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Server-safe constants for the v1.4 settings split. Kept in a separate
|
||||
* module from `<SettingsShell>` (a client component) so server-rendered code
|
||||
* paths — `generateStaticParams()`, the page guard, future tests — can import
|
||||
* the slug list without dragging the whole client bundle into the server
|
||||
* graph.
|
||||
*/
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"ai",
|
||||
"api",
|
||||
"advanced",
|
||||
"about",
|
||||
] as const;
|
||||
|
||||
export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number];
|
||||
|
||||
export function isSettingsSectionSlug(
|
||||
value: string,
|
||||
): value is SettingsSectionSlug {
|
||||
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<SettingsShell>` — 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: section selector renders as a horizontal scroll strip at the top so
|
||||
* a thumb-tap can swap sections without leaving the viewport.
|
||||
* - >=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 {
|
||||
Bell,
|
||||
Info,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
User,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
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.<slug>.title`. */
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the section list. Order matches the in-app navigation
|
||||
* order so the sidebar, mobile strip, and `generateStaticParams()` all line
|
||||
* up. Don't reorder without updating tests.
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
|
||||
{
|
||||
slug: "integrations",
|
||||
titleKey: "settings.sections.integrations.title",
|
||||
icon: Link2,
|
||||
},
|
||||
{
|
||||
slug: "notifications",
|
||||
titleKey: "settings.sections.notifications.title",
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
slug: "dashboard",
|
||||
titleKey: "settings.sections.dashboard.title",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
function deriveActiveSlug(
|
||||
pathname: string | null,
|
||||
override?: SettingsSectionSlug,
|
||||
): SettingsSectionSlug {
|
||||
if (override) return override;
|
||||
if (!pathname) return "account";
|
||||
// Match `/settings/<slug>` and ignore any trailing segments (none today,
|
||||
// but cheap insurance for future nested routes).
|
||||
const match = pathname.match(/^\/settings\/([^/]+)/);
|
||||
const candidate = match?.[1] ?? "";
|
||||
return isSettingsSectionSlug(candidate) ? candidate : "account";
|
||||
}
|
||||
|
||||
export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+. */}
|
||||
<nav
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="-mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
|
||||
{/* Desktop sticky sidebar */}
|
||||
<aside
|
||||
aria-label={t("settings.shell.sectionsNav")}
|
||||
className="hidden md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
<ul className="space-y-1">
|
||||
{SETTINGS_SECTIONS.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main column — page renders its own h1 + subtitle */}
|
||||
<main className="min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user