Files
HealthLog/src/components/settings/settings-shell.tsx
T
Marc-André BombeckandClaude Opus 4.7 3221f86eea 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>
2026-05-08 14:15:15 +02:00

191 lines
6.4 KiB
TypeScript

"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>
);
}