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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user