feat(admin): add bottom navigation items and settings page to admin panel

This commit is contained in:
2026-04-22 20:12:18 -04:00
parent 739a0b2399
commit ccdd309414
9 changed files with 175 additions and 8 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
import AdminShell from './components/AdminShell.js';
import { protectAdmin } from './protect.js';
import { buildNavigationSections } from './navigation.js';
import { buildNavigationSections, buildBottomNavItems } from './navigation.js';
import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core';
import './widgets/index.server.js';
@@ -9,6 +9,7 @@ export default async function AdminLayout({ children }) {
const session = await protectAdmin();
const appName = getAppName();
const navigationSections = buildNavigationSections('/');
const bottomNavItems = buildBottomNavItems('/');
return (
<AdminShell
@@ -16,6 +17,7 @@ export default async function AdminLayout({ children }) {
onLogout={logoutAction}
appName={appName}
navigationSections={navigationSections}
bottomNavItems={bottomNavItems}
>
{children}
</AdminShell>
+5 -3
View File
@@ -5,9 +5,10 @@ import './pages/DashboardPage.client.js';
import './pages/UsersPage.client.js';
import './pages/RolesPage.client.js';
import './pages/ProfilePage.client.js';
import './pages/SettingsPage.client.js';
import './widgets/index.client.js';
export default function AdminPageClient({ params, user, widgetData }) {
export default function AdminPageClient({ params, user, widgetData, appConfig }) {
const parts = params?.admin || [];
const [first] = parts;
@@ -17,10 +18,11 @@ export default function AdminPageClient({ params, user, widgetData }) {
if (!page) return null;
const { Component } = page;
// Le tableau de bord reçoit les données collectées côté serveur ; les
// autres pages ne connaissent pas le widget data.
if (slug === 'dashboard') {
return <Component user={user} stats={widgetData} />;
}
if (slug === 'settings') {
return <Component user={user} appConfig={appConfig} />;
}
return <Component user={user} params={parts} />;
}
+3
View File
@@ -1,17 +1,20 @@
import AdminPageClient from './AdminPage.client.js';
import { protectAdmin } from './protect.js';
import { collectWidgetData } from './registry.js';
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
export default async function AdminPage({ params }) {
const resolvedParams = await params;
const session = await protectAdmin();
const widgetData = await collectWidgetData();
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
return (
<AdminPageClient
params={resolvedParams}
user={session.user}
widgetData={widgetData}
appConfig={appConfig}
/>
);
}
+2 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import AdminSidebar from './AdminSidebar.js';
import AdminTop from './AdminTop.js';
export default function AdminShell({ children, user, onLogout, appName, navigationSections }) {
export default function AdminShell({ children, user, onLogout, appName, navigationSections, bottomNavItems }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
@@ -14,6 +14,7 @@ export default function AdminShell({ children, user, onLogout, appName, navigati
setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName}
navigationSections={navigationSections}
bottomNavItems={bottomNavItems}
/>
<div className="flex-1 flex flex-col min-w-0">
<AdminTop
+24 -2
View File
@@ -20,7 +20,7 @@ function resolveIcon(iconNameOrComponent) {
return Icons.DashboardSquare03Icon;
}
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections, bottomNavItems = [] }) => {
const pathname = usePathname();
const [collapsedSections, setCollapsedSections] = useState(() => {
@@ -190,9 +190,31 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
</Link>
{/* Navigation */}
<nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col pb-12">
<nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col">
{navigationSections.map(renderNavSection)}
</nav>
{/* Bottom pinned items */}
{bottomNavItems.length > 0 && (
<div className="px-2 py-2 border-t border-neutral-200 dark:border-neutral-800/70 shrink-0">
{bottomNavItems.map((item) => {
const Icon = resolveIcon(item.icon);
return (
<Link
key={item.href}
href={item.href}
onClick={handleMobileLinkClick}
className={`${base} ${item.current ? parentActif : inactive}`}
>
<div className="flex items-center gap-2">
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{item.name}</span>
</div>
</Link>
);
})}
</div>
)}
</div>
</>
);
+17 -1
View File
@@ -12,6 +12,7 @@ registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMulti
registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 });
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
/**
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
@@ -19,7 +20,7 @@ registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon'
*/
export function buildNavigationSections(pathname) {
const sections = getNavSections();
const items = getNavItems();
const items = getNavItems().filter(item => item.position !== 'bottom');
const bySection = new Map();
for (const item of items) {
@@ -37,3 +38,18 @@ export function buildNavigationSections(pathname) {
.filter(s => bySection.has(s.id))
.map(s => ({ id: s.id, title: s.title, icon: s.icon, items: bySection.get(s.id) }));
}
/**
* Build the list of bottom-pinned nav items for AdminSidebar.
*/
export function buildBottomNavItems(pathname) {
return getNavItems()
.filter(item => item.position === 'bottom')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(item => ({
name: item.label,
href: item.href,
icon: item.icon,
current: pathname === item.href || pathname.startsWith(item.href + '/'),
}));
}
@@ -0,0 +1,92 @@
'use client';
import { useState, useEffect } from 'react';
import { registerPage } from '../registry.js';
import AdminHeader from '../components/AdminHeader.js';
import { Card, Input, Select, TabNav } from '@zen/core/shared/components';
import { applyTheme, getStoredTheme } from '@zen/core/themes';
const TABS = [
{ id: 'general', label: 'Général' },
{ id: 'appearance', label: 'Apparence' },
];
const THEME_OPTIONS = [
{ value: 'light', label: 'Mode clair' },
{ value: 'dark', label: 'Mode sombre' },
{ value: 'auto', label: 'Thème système' },
];
const SettingsPage = ({ appConfig = {} }) => {
const [activeTab, setActiveTab] = useState('general');
const [theme, setTheme] = useState('auto');
useEffect(() => {
setTheme(getStoredTheme());
}, []);
const handleThemeChange = (value) => {
setTheme(value);
applyTheme(value);
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Paramètres" description="Configuration de votre espace ZEN" />
<div className="flex flex-col gap-6">
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'general' && (
<Card title="Informations générales">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom du site"
value={appConfig.name || ''}
readOnly
disabled
/>
<Input
label="URL du site"
value={appConfig.siteUrl || ''}
readOnly
disabled
description="URL publique de votre site"
/>
<Input
label="Fuseau horaire"
value={appConfig.timezone || ''}
readOnly
disabled
/>
<Input
label="Format de date"
value={appConfig.dateFormat || ''}
readOnly
disabled
/>
</div>
</Card>
)}
{activeTab === 'appearance' && (
<Card title="Thème">
<div className="max-w-xs">
<Select
label="Thème de l'interface"
value={theme}
onChange={handleThemeChange}
options={THEME_OPTIONS}
description="S'applique immédiatement et persiste entre les sessions"
/>
</div>
</Card>
)}
</div>
</div>
);
};
export default SettingsPage;
registerPage({ slug: 'settings', title: 'Paramètres', Component: SettingsPage });
+28
View File
@@ -0,0 +1,28 @@
'use client';
import React from 'react';
const TabNav = ({ tabs = [], activeTab, onTabChange }) => {
return (
<div className="flex border-b border-neutral-200 dark:border-neutral-800/70">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`cursor-pointer px-4 py-2.5 text-[13px] font-medium transition-colors duration-[120ms] ease-out border-b-2 -mb-px ${
isActive
? 'border-neutral-900 dark:border-white text-neutral-900 dark:text-white'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white'
}`}
>
{tab.label}
</button>
);
})}
</div>
);
};
export default TabNav;
+1
View File
@@ -29,3 +29,4 @@ export { default as TagInput } from './TagInput';
export { default as UserAvatar } from './UserAvatar';
export { default as ColorPicker } from './ColorPicker.client';
export { default as RelativeDate } from './RelativeDate.client';
export { default as TabNav } from './TabNav';