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
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.
139 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|