refactor(settings): grouped left-rail nav replaces overflowing tab bar

The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.

Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:

- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
  General, Display, Privacy & Security, Integrations, System — and a
  lucide icon next to every item. Active state uses the existing primary
  token. Adds a section header on the right pane that echoes the active
  item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
  tap to switch, no horizontal scroll, screen-reader friendly.

Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
This commit is contained in:
Paul Nothaft
2026-04-28 23:59:54 +02:00
parent 625711af96
commit f171f6b974
+158 -33
View File
@@ -1,5 +1,21 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Sliders,
CalendarPlus,
Activity,
Lock,
Shield,
Image as ImageIcon,
Search,
Tags,
BarChart3,
Flag,
Code,
KeyRound,
Webhook,
type LucideIcon,
} from 'lucide-react';
import { Loading } from '../../components/common';
import {
useSettingsState,
@@ -20,6 +36,17 @@ import {
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens' | 'webhooks';
interface NavItem {
key: TabType;
label: string;
icon: LucideIcon;
}
interface NavGroup {
label: string;
items: NavItem[];
}
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general');
const { t } = useTranslation();
@@ -71,22 +98,54 @@ export const SettingsPage: React.FC = () => {
);
}
const tabs: { key: TabType; label: string }[] = [
{ key: 'general', label: t('settings.general.title') },
{ key: 'events', label: t('settings.events.title', 'Event Creation') },
{ key: 'status', label: t('settings.systemStatus.title') },
{ key: 'security', label: t('settings.security.title') },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails') },
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots') },
{ key: 'categories', label: t('settings.categories.title') },
{ key: 'analytics', label: t('settings.analytics.title') },
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS') },
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') },
{ key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks') },
// Grouped nav: replaces the previous flat 13-tab horizontal bar that
// overflowed even on 1440px viewports. Categories follow the macOS
// System Settings / Stripe / GitHub pattern — scales to N tabs without
// horizontal scroll, gives visual taxonomy, and surfaces every option.
const navGroups: NavGroup[] = [
{
label: t('settings.groups.general', 'General'),
items: [
{ key: 'general', label: t('settings.general.title'), icon: Sliders },
{ key: 'events', label: t('settings.events.title', 'Event Creation'), icon: CalendarPlus },
],
},
{
label: t('settings.groups.display', 'Display'),
items: [
{ key: 'categories', label: t('settings.categories.title'), icon: Tags },
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code },
],
},
{
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
items: [
{ key: 'security', label: t('settings.security.title'), icon: Lock },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield },
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation'), icon: Flag },
],
},
{
label: t('settings.groups.integrations', 'Integrations'),
items: [
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens'), icon: KeyRound },
{ key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks'), icon: Webhook },
],
},
{
label: t('settings.groups.system', 'System'),
items: [
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
],
},
];
const allItems = navGroups.flatMap((g) => g.items);
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
return (
<div>
<div className="mb-6">
@@ -94,26 +153,90 @@ export const SettingsPage: React.FC = () => {
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('settings.subtitle')}</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 dark:border-neutral-700 mb-6">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.key
? 'border-primary-600 text-primary-600 dark:text-primary-400'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6 lg:gap-8">
{/* Mobile: native select dropdown — keeps every option reachable
in one tap on touch devices, no horizontal scroll. */}
<div className="lg:hidden">
<label htmlFor="settings-section" className="sr-only">
{t('settings.sectionLabel', 'Settings section')}
</label>
<select
id="settings-section"
value={activeTab}
onChange={(e) => setActiveTab(e.target.value as TabType)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{navGroups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</optgroup>
))}
</select>
</div>
{/* Tab Content */}
{/* Desktop: grouped left rail. Sticky so the nav stays visible
while the right pane scrolls through long forms. */}
<aside className="hidden lg:block">
<nav
aria-label={t('settings.navAriaLabel', 'Settings navigation')}
className="sticky top-6 space-y-6"
>
{navGroups.map((group) => (
<div key={group.label}>
<h3 className="px-3 mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{group.label}
</h3>
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.key;
return (
<li key={item.key}>
<button
type="button"
onClick={() => setActiveTab(item.key)}
aria-current={isActive ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
isActive
? 'text-primary-600 dark:text-primary-400'
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
}`}
/>
<span className="truncate">{item.label}</span>
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
<div className="min-w-0">
{/* Section heading echoes the active nav item — anchors the user
after they switch, especially after a mobile select change. */}
<div className="mb-4 lg:mb-6 pb-3 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2">
<activeItem.icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{activeItem.label}
</h2>
</div>
</div>
{/* Tab Content */}
{activeTab === 'general' && (
<GeneralTab
generalSettings={generalSettings}
@@ -192,6 +315,8 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'apiTokens' && <ApiTokensTab />}
{activeTab === 'webhooks' && <WebhooksTab />}
</div>
</div>
</div>
);
};