Files
kalendartool/components/layout/Sidebar.tsx

234 lines
8.0 KiB
TypeScript

'use client';
/**
* Einklappbare Sidebar fuer angemeldete Bereiche.
*
* Der Inhalt passt sich dynamisch an:
* - eingeklappt: nur Icons (mit Tooltip via title)
* - ausgeklappt: Icons + Beschriftung
* - Rolle: ADMIN sieht zusaetzlich "Benutzerverwaltung"
*
* Der Zustand wird in localStorage persistiert, damit die Wahl
* ueber Seitenwechsel hinweg erhalten bleibt.
*/
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { ProfileModal } from '@/components/profile/ProfileModal';
const STORAGE_KEY = 'calendar-sidebar-collapsed';
/** Flache 2D-Icons (Outline-Stil, Faerbung via currentColor). */
function NavIcon({ name }: { name: 'dashboard' | 'plus' | 'users' }) {
const common = {
width: 18,
height: 18,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
switch (name) {
case 'dashboard':
return (
<svg {...common}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
case 'plus':
return (
<svg {...common}>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M12 9v6M9 12h6" />
</svg>
);
case 'users':
return (
<svg {...common}>
<circle cx="9" cy="8" r="3.5" />
<path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" />
<path d="M16 4.6a3.5 3.5 0 0 1 0 6.8" />
<path d="M18.5 14.4c1.8.9 3 2.6 3 5.6" />
</svg>
);
}
}
interface NavItem {
href: string;
label: string;
icon: 'dashboard' | 'plus' | 'users';
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
{ href: '/admin/calendars', label: 'Kalender verwalten', icon: 'plus', adminOnly: true },
{ href: '/admin/users', label: 'Benutzerverwaltung', icon: 'users', adminOnly: true },
];
export function Sidebar({
role,
userEmail,
username,
}: {
role: 'ADMIN' | 'USER';
/** E-Mail des angemeldeten Benutzers (Fallback fuer das Profil-Badge). */
userEmail: string;
/** Frei waehlbarer Anzeigename; wird im Badge bevorzugt. */
username?: string | null;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
/** Profil-Modal offen (geoeffnet via Klick auf das Profil-Badge). */
const [isProfileOpen, setIsProfileOpen] = useState(false);
const pathname = usePathname();
const router = useRouter();
// Gespeicherten Zustand wiederherstellen (nur clientseitig, kein
// Hydration-Mismatch, da der erste Render konsistent eingeklappt-los ist).
useEffect(() => {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'true') {
setIsCollapsed(true);
}
setIsInitialized(true);
}, []);
function toggleCollapsed() {
setIsCollapsed((previous) => {
const next = !previous;
window.localStorage.setItem(STORAGE_KEY, String(next));
return next;
});
}
async function logout() {
setIsLoggingOut(true);
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
} finally {
setIsLoggingOut(false);
}
}
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || role === 'ADMIN',
);
return (
<aside
className={`sticky top-0 flex h-screen shrink-0 flex-col border-r border-slate-200 bg-white transition-all duration-200 dark:border-slate-700 dark:bg-slate-900 ${
isCollapsed ? 'w-16' : 'w-60'
}`}
aria-label="Hauptnavigation"
>
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-3 dark:border-slate-700">
{!isCollapsed && (
<span className="truncate text-sm font-bold text-slate-900 dark:text-slate-100">
Kalendartool
</span>
)}
<button
type="button"
onClick={toggleCollapsed}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
{isCollapsed ? '»' : '«'}
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
{visibleItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
title={isCollapsed ? item.label : undefined}
aria-current={isActive ? 'page' : undefined}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
isActive
? 'bg-slate-200 font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
} ${isCollapsed ? 'justify-center' : ''}`}
>
<NavIcon name={item.icon} />
{!isCollapsed && <span className="truncate">{item.label}</span>}
</Link>
);
})}
</nav>
<div className="space-y-1 border-t border-slate-200 p-2 dark:border-slate-700">
<div className={`flex items-center gap-2 ${isCollapsed ? 'justify-center' : 'px-1'}`}>
<ThemeToggle />
{/* Profil-Badge: zeigt den Username (falls gesetzt), sonst die
E-Mail des angemeldeten Benutzers; eingeklappt nur der
Anfangsbuchstabe. Klick oeffnet das Profil-Modal. */}
<button
type="button"
onClick={() => setIsProfileOpen(true)}
title={username ?? userEmail}
aria-label="Profil oeffnen"
className={`inline-flex min-w-0 items-center rounded-full bg-slate-200 font-medium text-slate-800 transition-colors hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600 ${
isCollapsed
? 'h-9 w-9 justify-center text-sm'
: 'max-w-full px-3.5 py-1.5 text-sm'
}`}
>
{isCollapsed ? (
(username ?? userEmail).charAt(0).toUpperCase()
) : (
<span className="truncate">{username ?? userEmail}</span>
)}
</button>
</div>
<button
type="button"
onClick={logout}
disabled={isLoggingOut}
title={isCollapsed ? 'Abmelden' : undefined}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${
isCollapsed ? 'justify-center' : ''
}`}
>
<svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3" />
<path d="M10 17l-5-5 5-5" />
<path d="M15 12H5" />
</svg>
{!isCollapsed && <span>{isLoggingOut ? 'Abmelden …' : 'Abmelden'}</span>}
</button>
</div>
{isProfileOpen && (
<ProfileModal onClose={() => setIsProfileOpen(false)} />
)}
</aside>
);
}