feat(unit-3): Layout & Navigation — sidebar, mobile overlay, theme toggle

- Remove Topbar; move UserMenu + LanguageSwitcher to sidebar footer
- Add ThemeToggle (dark/light) with localStorage persistence
- Add useTheme hook; no-flash inline script in index.html
- Add MobileBar (hamburger + app name, mobile-only)
- Add SidebarOverlay (slide-in from left, backdrop closes it)
- Sidebar: role-filtered nav (BR-U3-01–06), onClose prop for mobile
- AppLayout: desktop sidebar-only layout, mobile bar + overlay
- i18n: theme.* and nav.openMenu/closeMenu keys (NL + EN)
- Tests: 43/43 passing (14 new — useTheme, Sidebar roles, AppLayout)

Stories: US-08, US-18, US-19

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:03:10 +02:00
co-authored by Claude Haiku 4.5
parent 7ed388ed5b
commit 76b7bfc09b
14 changed files with 458 additions and 38 deletions
+51 -9
View File
@@ -1,42 +1,76 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText } from 'lucide-react';
import { LayoutDashboard, Users, FileText, Settings, User, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
type Role = 'Owner' | 'Administrator' | 'User';
interface NavItem {
to: string;
labelKey: string;
icon: LucideIcon;
testId: string;
roles?: Role[];
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
{ to: '/profile', labelKey: 'nav.profile', icon: User, testId: 'nav-profile' },
];
export function Sidebar() {
interface SidebarProps {
onClose?: () => void;
}
export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation();
const { user } = useAuth();
const role = user?.role as Role | undefined;
const visibleItems = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role))
);
return (
<aside
className="hidden w-64 shrink-0 border-r border-border bg-card md:flex md:flex-col"
className="flex w-64 shrink-0 flex-col border-r border-border bg-card"
data-testid="app-sidebar"
>
<div className="flex h-16 items-center gap-2 border-b border-border px-6">
<span className="h-3 w-3 rounded-full bg-primary" aria-hidden="true" />
<span className="text-lg font-semibold">{t('common.appName')}</span>
<div className="flex h-16 items-center justify-between border-b border-border px-6">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-primary" aria-hidden="true" />
<span className="text-lg font-semibold">{t('common.appName')}</span>
</div>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('nav.closeMenu')}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
data-testid="sidebar-close-button"
>
<X className="size-4" />
</button>
)}
</div>
<nav className="flex-1 space-y-1 p-3">
{NAV_ITEMS.map((item) => {
{visibleItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.to}
to={item.to}
data-testid={item.testId}
onClick={onClose}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
activeProps={{
className: cn('bg-accent text-accent-foreground'),
@@ -48,6 +82,14 @@ export function Sidebar() {
);
})}
</nav>
<div className="flex items-center gap-1 border-t border-border p-3">
<div className="flex-1 min-w-0">
<UserMenu />
</div>
<LanguageSwitcher />
<ThemeToggle />
</div>
</aside>
);
}