- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern, Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte. - AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build neben dem 3D-Budget (Spec 17.6). - PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5); Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk. - Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full. - Kein transition-all, kein Hover-Heben, keine Breiten-Animation von Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { FileClock } from "lucide-react";
|
|
import { BrandMark } from "@/components/brand-mark";
|
|
import { cn } from "@/lib/utils";
|
|
import { NavCounter } from "@/components/suggestions/nav-counter";
|
|
import type { AuthUser } from "@/lib/auth/auth-context";
|
|
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
|
|
|
|
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
|
const Icon = item.icon;
|
|
const isActive = isNavActive(pathname, item.href);
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
aria-current={isActive ? "page" : undefined}
|
|
className={cn(
|
|
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
|
|
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
|
|
)}
|
|
>
|
|
<Icon className="size-4.5 shrink-0" />
|
|
<span className="truncate">{item.label}</span>
|
|
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function Sidebar({ user }: { user: AuthUser }) {
|
|
const pathname = usePathname();
|
|
const groups = visibleNavGroups(user);
|
|
|
|
return (
|
|
// The page scrolls on the window; the sidebar stays put beside it and
|
|
// scrolls on its own when the nav is taller than the screen. It is a
|
|
// sibling of the page content, so its overflow cannot capture sticky
|
|
// elements there (StickySubmit).
|
|
<aside className="sticky top-0 z-10 hidden h-dvh w-64 shrink-0 flex-col gap-1 self-start overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
|
|
<Link
|
|
href="/dashboard"
|
|
className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold"
|
|
>
|
|
<BrandMark size={32} withWordmark className="gap-2.5" />
|
|
</Link>
|
|
<nav className="flex flex-1 flex-col gap-5">
|
|
{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-semibold tracking-wide text-muted-foreground/80 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>
|
|
);
|
|
}
|