geraete/app/(app)/layout.tsx
2025-11-14 17:03:26 +01:00

427 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /app/(app)/layout.tsx
'use client';
import { useState, type ReactNode } from 'react';
import {
Dialog,
DialogBackdrop,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
} from '@headlessui/react';
import {
Bars3Icon,
BellIcon,
CalendarIcon,
CameraIcon,
ChartPieIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
FolderIcon,
ComputerDesktopIcon,
HomeIcon,
UsersIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { Skeleton } from '@/components/ui/Skeleton';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Geräte', href: '/devices', icon: ComputerDesktopIcon },
];
const teams = [
{ id: 1, name: 'Heroicons', href: '#', initial: 'H' },
{ id: 2, name: 'Tailwind Labs', href: '#', initial: 'T' },
{ id: 3, name: 'Workcation', href: '#', initial: 'W' },
];
const userNavigation = [
{ name: 'Your profile', href: '#' },
{ name: 'Abmelden', href: '#' },
];
function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
// feste Liste an erlaubten Tailwind-Hintergrundfarben
const AVATAR_COLORS = [
'bg-indigo-500',
'bg-sky-500',
'bg-emerald-500',
'bg-amber-500',
'bg-rose-500',
'bg-purple-500',
];
function getInitial(name: string) {
const trimmed = name.trim();
if (!trimmed) return '';
return trimmed.charAt(0).toUpperCase();
}
// deterministische "zufällige" Farbe auf Basis des Namens
function getAvatarColor(name: string) {
if (!name) return AVATAR_COLORS[0];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash + name.charCodeAt(i)) % AVATAR_COLORS.length;
}
return AVATAR_COLORS[hash];
}
/* ───────── User-Menü, wenn Session geladen ───────── */
type UserMenuProps = {
displayName: string;
avatarInitial: string;
avatarColorClass: string;
};
function UserMenu({ displayName, avatarInitial, avatarColorClass }: UserMenuProps) {
return (
<Menu as="div" className="relative">
<MenuButton className="relative flex items-center">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
{/* Avatar mit Zufallsfarbe & Initial */}
<div
className={classNames(
'flex size-8 items-center justify-center rounded-full text-sm font-semibold text-white shadow-inner',
avatarColorClass,
)}
>
{avatarInitial}
</div>
<span className="hidden lg:flex lg:items-center">
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white">
{displayName}
</span>
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400 dark:text-gray-500" />
</span>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline-1 outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{userNavigation.map((item) => (
<MenuItem key={item.name}>
{item.name === 'Abmelden' ? (
<button
type="button"
onClick={() => signOut({ callbackUrl: '/login' })}
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
{item.name}
</button>
) : (
<a
href={item.href}
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
{item.name}
</a>
)}
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
/* ───────── Layout ───────── */
export default function AppLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const { data: session, status } = useSession();
const rawName =
status === 'authenticated'
? (session?.user?.name ?? session?.user?.email ?? '')
: '';
const displayName = rawName;
const avatarInitial = getInitial(rawName);
const avatarColorClass = getAvatarColor(rawName);
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
{/* Mobile Sidebar */}
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
>
<TransitionChild>
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
{/* Sidebar mobil */}
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-4 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
<div className="relative flex h-16 shrink-0 items-center">
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
</div>
<nav className="relative flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => {
const isCurrent = pathname === item.href;
return (
<li key={item.name}>
<a
href={item.href}
className={classNames(
isCurrent
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
isCurrent
? 'text-indigo-600 dark:text-white'
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
);
})}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Sidebar Desktop */}
<div className="hidden bg-gray-900 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4 dark:border-white/10 dark:bg-black/10">
<div className="flex h-16 shrink-0 items-center">
<span className="text-lg font-semibold text-gray-900 dark:text-white">Inventar</span>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => {
const isCurrent = pathname === item.href;
return (
<li key={item.name}>
<a
href={item.href}
className={classNames(
isCurrent
? 'bg-gray-50 text-indigo-600 dark:bg-white/5 dark:text-white'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
isCurrent
? 'text-indigo-600 dark:text-white'
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
);
})}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className="group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-[0.625rem] font-medium text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:group-hover:border-white/20 dark:group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</div>
{/* Topbar + Inhalt */}
<div className="lg:pl-72">
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900 dark:shadow-none">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="-m-2.5 p-2.5 text-gray-700 hover:text-gray-900 lg:hidden dark:text-gray-400 dark:hover:text-white"
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon aria-hidden="true" className="size-6" />
</button>
<div aria-hidden="true" className="h-6 w-px bg-gray-200 lg:hidden dark:bg-white/10" />
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
{/* Suche */}
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
<input
name="search"
placeholder="Suchen..."
aria-label="Suchen..."
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
/>
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
/>
</form>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* Trennstrich zwischen Suche und Kamera nur mobil */}
<div
aria-hidden="true"
className="h-6 w-px bg-gray-200 dark:bg-white/10 lg:hidden"
/>
{/* Kamera-Button nur mobil */}
<button
type="button"
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white lg:hidden"
>
<span className="sr-only">Gerät mit Kamera erfassen</span>
<CameraIcon aria-hidden="true" className="size-5" />
</button>
{/* Trennstrich zwischen Kamera und Notifications nur mobil */}
<div
aria-hidden="true"
className="h-6 w-px bg-gray-200 dark:bg-white/10 lg:hidden"
/>
{/* Notifications immer sichtbar */}
<button
type="button"
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-white"
>
<span className="sr-only">View notifications</span>
<BellIcon aria-hidden="true" className="size-6" />
</button>
{/* Trennstrich vor User-Menü nur Desktop */}
<div
aria-hidden="true"
className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200 dark:lg:bg-white/10"
/>
{/* Hier je nach Status: Skeleton oder echtes Menü */}
{status === 'loading' && (
<Skeleton
primaryLabel="Benutzer"
secondaryLabel="Profildaten werden geladen"
/>
)}
{status === 'authenticated' && rawName && (
<UserMenu
displayName={displayName}
avatarInitial={avatarInitial}
avatarColorClass={avatarColorClass}
/>
)}
</div>
</div>
</div>
{/* hier kommt der Seiteninhalt rein */}
<main className="py-10">
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
</main>
</div>
</div>
);
}