From 4f3368502adc92824b1f67c4877310582a3fe42e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 19 Apr 2026 21:35:31 +0200 Subject: [PATCH] feat(ui): introduce shared PageSidebar for Settings and Admin Replaces the inline tab bar on SettingsPage and AdminPage with a responsive sidebar layout (left nav on desktop, hamburger drawer on mobile). Each tab gets a lucide-react icon for quick scanning. Both pages drop max-w-6xl so the panel fills the viewport. --- client/src/components/Layout/PageSidebar.tsx | 210 +++++++++++++++++++ client/src/pages/AdminPage.tsx | 57 +++-- client/src/pages/SettingsPage.tsx | 68 +++--- 3 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 client/src/components/Layout/PageSidebar.tsx diff --git a/client/src/components/Layout/PageSidebar.tsx b/client/src/components/Layout/PageSidebar.tsx new file mode 100644 index 00000000..9c293b9f --- /dev/null +++ b/client/src/components/Layout/PageSidebar.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Menu, X, type LucideIcon } from 'lucide-react' + +export interface PageSidebarTab { + id: string + label: string + icon: LucideIcon +} + +interface PageSidebarProps { + /** Uppercase label shown above the tab list, e.g. "SETTINGS". */ + sidebarLabel: string + tabs: PageSidebarTab[] + activeTab: string + onTabChange: (id: string) => void + children: React.ReactNode + /** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */ + footer?: React.ReactNode +} + +/** + * Left-sidebar + right-panel layout used by the Settings and Admin pages. + * + * Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest. + * Mobile: sidebar collapses behind a hamburger at the top of the panel; tap + * the hamburger to slide the sidebar in as an overlay, tap a tab to close. + */ +export default function PageSidebar({ + sidebarLabel, + tabs, + activeTab, + onTabChange, + children, + footer, +}: PageSidebarProps): React.ReactElement { + const [mobileOpen, setMobileOpen] = useState(false) + const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? '' + + // Close the mobile drawer on Escape or on outside click. + const drawerRef = useRef(null) + useEffect(() => { + if (!mobileOpen) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [mobileOpen]) + + return ( +
+ {/* Mobile top bar with hamburger */} +
+ +
+ {activeLabel} +
+
+
+ + {/* Desktop sidebar (always visible on lg) */} + + + {/* Mobile drawer */} + {mobileOpen && ( + <> +
setMobileOpen(false)} + /> + + + )} + + {/* Panel */} +
+ {children} +
+
+ ) +} + +function SidebarInner({ + sidebarLabel, + tabs, + activeTab, + onTabChange, + footer, +}: { + sidebarLabel: string | null + tabs: PageSidebarTab[] + activeTab: string + onTabChange: (id: string) => void + footer?: React.ReactNode +}): React.ReactElement { + return ( + <> + {sidebarLabel && ( +
+ {sidebarLabel} +
+ )} + + {footer && ( +
+ {footer} +
+ )} + + ) +} diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 7d099111..e787511b 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -20,8 +20,9 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager' import AuditLogPanel from '../components/Admin/AuditLogPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' +import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar' interface AdminUser { id: number @@ -183,18 +184,18 @@ export default function AdminPage(): React.ReactElement { const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) const devMode = useAuthStore(s => s.devMode) - const TABS = [ - { id: 'users', label: t('admin.tabs.users') }, - { id: 'config', label: t('admin.tabs.config') }, - { id: 'defaults', label: t('admin.tabs.defaults') }, - { id: 'addons', label: t('admin.tabs.addons') }, - { id: 'settings', label: t('admin.tabs.settings') }, - { id: 'notifications', label: t('admin.tabs.notifications') }, - { id: 'backup', label: t('admin.tabs.backup') }, - { id: 'audit', label: t('admin.tabs.audit') }, - ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []), - { id: 'github', label: t('admin.tabs.github') }, - ...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications' }] : []), + const TABS: PageSidebarTab[] = [ + { id: 'users', label: t('admin.tabs.users'), icon: Users }, + { id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal }, + { id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog }, + { id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle }, + { id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon }, + { id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell }, + { id: 'backup', label: t('admin.tabs.backup'), icon: Database }, + { id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText }, + ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound }] : []), + { id: 'github', label: t('admin.tabs.github'), icon: GitBranch }, + ...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []), ] const [activeTab, setActiveTab] = useState('users') @@ -500,7 +501,7 @@ export default function AdminPage(): React.ReactElement {
-
+
{/* Header */}
@@ -586,24 +587,15 @@ export default function AdminPage(): React.ReactElement {
)} - {/* Tabs */} -
- {TABS.map(tab => ( - - ))} -
- - {/* Tab content */} + {/* Sidebar layout — nav on the left, active panel on the right */} + + {/* Tab content */} {activeTab === 'users' && (
@@ -1618,6 +1610,7 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'defaults' && } {activeTab === 'dev-notifications' && } +
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index fedcdb31..c3ead1c3 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' -import { Settings } from 'lucide-react' +import { Settings, Palette, Map, Bell, Plug, CloudOff, User, Info } from 'lucide-react' import { useTranslation } from '../i18n' import { authApi } from '../api/client' import { useAddonStore } from '../store/addonStore' import Navbar from '../components/Layout/Navbar' +import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar' import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab' import MapSettingsTab from '../components/Settings/MapSettingsTab' import NotificationsTab from '../components/Settings/NotificationsTab' @@ -37,14 +38,18 @@ export default function SettingsPage(): React.ReactElement { } }, [searchParams]) - const TABS = [ - { id: 'display', label: t('settings.tabs.display') }, - { id: 'map', label: t('settings.tabs.map') }, - { id: 'notifications', label: t('settings.tabs.notifications') }, - ...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []), - { id: 'offline', label: t('settings.tabs.offline') }, - { id: 'account', label: t('settings.tabs.account') }, - ...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []), + const tabs: PageSidebarTab[] = [ + { id: 'display', label: t('settings.tabs.display'), icon: Palette }, + { id: 'map', label: t('settings.tabs.map'), icon: Map }, + { id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell }, + ...(hasIntegrations + ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] + : []), + { id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff }, + { id: 'account', label: t('settings.tabs.account'), icon: User }, + ...(appVersion + ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }] + : []), ] return ( @@ -52,7 +57,7 @@ export default function SettingsPage(): React.ReactElement {
-
+
{/* Header */}
@@ -64,33 +69,24 @@ export default function SettingsPage(): React.ReactElement {
- {/* Tab bar */} -
- {TABS.map(tab => ( - - ))} -
- - {/* Tab content */} - {activeTab === 'display' && } - {activeTab === 'map' && } - {activeTab === 'notifications' && } - {activeTab === 'integrations' && hasIntegrations && } - {activeTab === 'offline' && } - {activeTab === 'account' && } - {activeTab === 'about' && appVersion && } + {/* Sidebar layout */} + + {activeTab === 'display' && } + {activeTab === 'map' && } + {activeTab === 'notifications' && } + {activeTab === 'integrations' && hasIntegrations && } + {activeTab === 'offline' && } + {activeTab === 'account' && } + {activeTab === 'about' && appVersion && } +
) -} \ No newline at end of file +}