Files
slp-modular-cms/frontend/src/components/layout/Sidebar.tsx
T
Sluijsens bc4de27b97
Continuous Integration / config (pull_request) Successful in 9s
Continuous Integration / backend-build (pull_request) Successful in 5m9s
Continuous Integration / vulnerability-scan (pull_request) Successful in 4m28s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m53s
Continuous Integration / backend-test (pull_request) Successful in 5m29s
Continuous Integration / frontend-build (pull_request) Successful in 2m16s
Continuous Integration / frontend-test (pull_request) Successful in 4m42s
Continuous Integration / frontend-lint (pull_request) Successful in 1m54s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 6m37s
Continuous Integration / deploy-test (pull_request) Skipped
Fixes mobile sidebar collapsing to content height instead of filling the overlay
The aside had no explicit height, so it stretched to full height on
desktop only because its flex-row parent applies align-items:stretch.
In the mobile SidebarOverlay it sits in a plain (non-flex) h-full div,
so it shrank to fit its nav items, pushing settings/profile up under
the nav instead of staying pinned to the bottom.
2026-07-31 00:40:25 +02:00

139 lines
5.8 KiB
TypeScript

import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
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[];
requiredModule?: string;
}
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
];
const SETTINGS_ITEM: NavItem = {
to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'],
};
interface SidebarProps {
onClose?: () => void;
}
export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation();
const { user } = useAuth();
const { data: availability } = useAvailabilityStatus();
const { data: capabilities } = useSystemCapabilities();
const role = user?.role as Role | undefined;
const systemUnavailable = availability?.status === 'NotAvailable';
const visibleItems = NAV_ITEMS.filter(
(item) =>
(!item.roles || (role && item.roles.includes(role))) &&
(!item.requiredModule || capabilities?.modules.includes(item.requiredModule))
);
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
return (
<aside
className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-card"
data-testid="app-sidebar"
>
<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">
{visibleItems.map((item) => {
const Icon = item.icon;
const blocked = systemUnavailable && !ALLOWED_WHEN_UNAVAILABLE.some(p => item.to.startsWith(p));
if (blocked) {
return (
<span
key={item.to}
data-testid={item.testId}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium opacity-40 select-none"
>
<Icon className="size-4" />
{t(item.labelKey)}
</span>
);
}
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'),
}}
>
<Icon className="size-4" />
{t(item.labelKey)}
</Link>
);
})}
</nav>
{showSettings && (
<div className="border-t border-border p-3">
<Link
to={SETTINGS_ITEM.to}
data-testid={SETTINGS_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'),
}}
>
<Settings className="size-4" />
{t(SETTINGS_ITEM.labelKey)}
</Link>
</div>
)}
<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>
);
}