feat(frontend): Linksseitige Navigation, Seitenanimation, ADHS-freundliche Gruppierung

Ersetzt die horizontale Kopfzeilen-Navigation durch eine feste
Sidebar links: gruppiert nach Betrieb / Arbeit / Ernte & Lager /
Verwaltung mit Icon+Label je Eintrag, um kognitive Last zu
reduzieren. Aktiver Zustand klar farblich hervorgehoben. Mobile
Ansicht (Bottom-Nav + FAB) bleibt unveraendert bestehen.

Sanfte, kurze Seitenuebergangsanimation (PageTransition, respektiert
prefers-reduced-motion ueber motion-safe:) fuer Orientierung ohne
Ablenkung.

Ausserdem: wiederkehrenden Anzeigefehler in Status-Filtern behoben
(Select zeigte den rohen Sentinel-Wert "ALL" statt "Alle Status" an)
auf Voelker-, Honigchargen-, Aufgaben-, Koeniginnen- und
Bienenstand-Listen. Live in Desktop-, Tablet- und Mobilbreite
verifiziert, kein horizontales Overflow.
This commit is contained in:
Nick Szaunig committed 2026-08-23 06:18:35 +02:00
1 parent fb2b09a929
commit ecb3afe1f5
9 files changed
+195 -100

No files matched your search

@@ -48,7 +48,7 @@ export default function ApiariesPage() {
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ApiaryStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ApiaryStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
@@ -69,7 +69,7 @@ export default function ColoniesPage() {
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
@@ -70,7 +70,7 @@ export default function HoneyBatchesPage() {
} }
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
+30 -94
View File
@@ -2,61 +2,18 @@
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Hexagon, MoreHorizontal } from "lucide-react"; import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { NotificationBell } from "@/components/notification-bell"; import { NotificationBell } from "@/components/notification-bell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Sidebar } from "./sidebar";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { BottomNav } from "./bottom-nav"; import { BottomNav } from "./bottom-nav";
import { QuickAddMenu } from "./quick-add-menu"; import { QuickAddMenu } from "./quick-add-menu";
import { PageTransition } from "./page-transition";
const NAV_LINK_CLASS =
"shrink-0 rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary";
const PRIMARY_LINKS = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Bienenstände", href: "/apiaries" },
{ label: "Völker", href: "/colonies" },
{ label: "Königinnen", href: "/queens" },
{ label: "Aufgaben", href: "/tasks" },
{ label: "Kalender", href: "/calendar" },
{ label: "Honigchargen", href: "/honey-batches" },
{ label: "Lager", href: "/storage" },
{ label: "Suche", href: "/search" },
];
const MORE_LINKS = [
{ label: "Produkte", href: "/products" },
{ label: "Abfüllungen", href: "/bottling/new" },
{ label: "Inventar", href: "/equipment" },
];
const ADMIN_LINKS = [
{ label: "Benutzer", href: "/settings/users" },
{ label: "Audit-Log", href: "/settings/audit-log" },
];
function NavLink({ href, label, pathname }: { href: string; label: string; pathname: string }) {
return (
<Link
href={href}
aria-current={pathname.startsWith(href) ? "page" : undefined}
className={NAV_LINK_CLASS}
>
{label}
</Link>
);
}
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user, isLoading, logout } = useAuth(); const { user, isLoading, logout } = useAuth();
useEffect(() => { useEffect(() => {
@@ -70,58 +27,37 @@ export default function AppLayout({ children }: { children: ReactNode }) {
} }
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen">
<header className="flex items-center justify-between gap-3 border-b bg-card px-4 py-2.5"> <Sidebar user={user} />
<nav className="flex min-w-0 items-center gap-3 max-sm:gap-2"> <div className="flex min-w-0 flex-1 flex-col">
<Link href="/dashboard" className="flex shrink-0 items-center gap-1.5 font-heading font-semibold"> <header className="flex items-center justify-between gap-3 border-b bg-card px-4 py-2.5 sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center gap-1.5 font-heading font-semibold sm:hidden"
>
<Hexagon className="size-5 fill-primary/15 text-primary" strokeWidth={2.25} /> <Hexagon className="size-5 fill-primary/15 text-primary" strokeWidth={2.25} />
ApiCore ApiCore
</Link> </Link>
<div className="hidden min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap sm:flex"> <div className="flex shrink-0 items-center gap-2 text-sm">
{PRIMARY_LINKS.map((item) => ( <NotificationBell />
<NavLink key={item.href} {...item} pathname={pathname} /> <span className="hidden max-w-40 truncate text-muted-foreground md:inline">
))} {user.email}
<DropdownMenu> </span>
<DropdownMenuTrigger <Button
render={<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" />} variant="outline"
> size="sm"
<MoreHorizontal className="size-4" /> onClick={() => {
Mehr void logout().then(() => router.push("/login"));
</DropdownMenuTrigger> }}
<DropdownMenuContent align="start"> >
{MORE_LINKS.map((item) => ( Abmelden
<DropdownMenuItem key={item.href} render={<Link href={item.href} />}> </Button>
{item.label}
</DropdownMenuItem>
))}
{user.role === "ADMIN"
? ADMIN_LINKS.map((item) => (
<DropdownMenuItem key={item.href} render={<Link href={item.href} />}>
{item.label}
</DropdownMenuItem>
))
: null}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</nav> </header>
<div className="flex shrink-0 items-center gap-2 text-sm"> <div className="flex-1 overflow-y-auto pb-16 sm:pb-0">
<NotificationBell /> <PageTransition>{children}</PageTransition>
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
{user.email}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
void logout().then(() => router.push("/login"));
}}
>
Abmelden
</Button>
</div> </div>
</header> </div>
<div className="flex-1 pb-16 sm:pb-0">{children}</div>
<BottomNav /> <BottomNav />
<QuickAddMenu /> <QuickAddMenu />
</div> </div>
@@ -0,0 +1,13 @@
"use client";
import { ReactNode } from "react";
import { usePathname } from "next/navigation";
export function PageTransition({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<div key={pathname} className="motion-safe:animate-page-in">
{children}
</div>
);
}
@@ -56,7 +56,7 @@ export default function QueensPage() {
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
+134
View File
@@ -0,0 +1,134 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
CalendarDays,
Crown,
Droplet,
FileClock,
Hexagon,
History,
LayoutDashboard,
Layers,
ListTodo,
type LucideIcon,
MapPin,
Package,
Search,
ShoppingBag,
Users,
Warehouse,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
interface NavItem {
label: string;
href: string;
icon: LucideIcon;
}
interface NavGroup {
label: string | null;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Suche", href: "/search", icon: Search },
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling/new", icon: Package },
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
];
const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{ label: "Benutzer", href: "/settings/users", icon: Users },
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
],
};
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
return (
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-150",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="size-4.5 shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
);
}
export function Sidebar({ user }: { user: AuthUser }) {
const pathname = usePathname();
const groups = user.role === "ADMIN" ? [...NAV_GROUPS, ADMIN_GROUP] : NAV_GROUPS;
return (
<aside className="hidden w-60 shrink-0 flex-col gap-1 overflow-y-auto border-r bg-card px-3 py-4 sm:flex">
<Link
href="/dashboard"
className="mb-4 flex items-center gap-2 px-2 font-heading text-lg font-semibold"
>
<Hexagon className="size-5.5 fill-primary/15 text-primary" strokeWidth={2.25} />
ApiCore
</Link>
<nav className="flex flex-1 flex-col gap-4">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col gap-0.5">
{group.label ? (
<p className="px-3 pb-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{group.label}
</p>
) : null}
{group.items.map((item) => (
<SidebarLink key={item.href} item={item} pathname={pathname} />
))}
</div>
))}
</nav>
<div className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground">
<FileClock className="size-3.5 shrink-0" />
<span className="truncate">v1.x</span>
</div>
</aside>
);
}
@@ -69,7 +69,7 @@ export default function TasksPage() {
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as TaskStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as TaskStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
@@ -87,7 +87,7 @@ export default function TasksPage() {
} }
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Priorität" /> <SelectValue placeholder="Priorität">{priority || "Alle Prioritäten"}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Prioritäten</SelectItem> <SelectItem value="ALL">Alle Prioritäten</SelectItem>
+12
View File
@@ -50,6 +50,18 @@
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
--animate-page-in: page-in 260ms ease-out;
}
@keyframes page-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
:root { :root {