Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
234
components/layout/Sidebar.tsx
Normal file
234
components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user