From 2eda3e7a390706277ec98c3fbed4e00e9bc3a504 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:01:45 +0200 Subject: [PATCH] feat(shell): floating panel layout with labelled icon rail --- src/components/shell/app-shell.tsx | 29 ++++++++++++ src/components/shell/sidebar.tsx | 75 ++++++++++++++++++++++++++++++ src/components/shell/top-bar.tsx | 46 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/components/shell/app-shell.tsx create mode 100644 src/components/shell/sidebar.tsx create mode 100644 src/components/shell/top-bar.tsx diff --git a/src/components/shell/app-shell.tsx b/src/components/shell/app-shell.tsx new file mode 100644 index 0000000..4330355 --- /dev/null +++ b/src/components/shell/app-shell.tsx @@ -0,0 +1,29 @@ +import { Sidebar } from "@/components/shell/sidebar"; +import type { Role } from "@/lib/pb/types"; + +/** + * The page background with a raised panel floating on it. + * + * Borrowed from the design reference and the reason the product does not read + * as an admin panel. The panel owns the scroll; the page behind it never + * moves, so the elevation stays believable. + */ +export function AppShell({ + role, + currentPath, + children, +}: { + role: Role; + currentPath: string; + children: React.ReactNode; +}) { + return ( +
+ + +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/shell/sidebar.tsx b/src/components/shell/sidebar.tsx new file mode 100644 index 0000000..1de5c90 --- /dev/null +++ b/src/components/shell/sidebar.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { LayoutGrid, FolderKanban, Settings, Users } from "lucide-react"; + +import { cn } from "@/lib/cn"; +import type { Role } from "@/lib/pb/types"; + +interface NavItem { + href: string; + label: string; + icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; + ownerOnly?: boolean; +} + +const NAV: NavItem[] = [ + { href: "/", label: "Projects", icon: LayoutGrid }, + { href: "/admin/clients", label: "Clients", icon: Users, ownerOnly: true }, + { href: "/admin/projects", label: "Manage", icon: FolderKanban, ownerOnly: true }, + { href: "/admin/settings", label: "Settings", icon: Settings, ownerOnly: true }, +]; + +/** + * The icon rail from the reference: an icon over a small label, not an icon + * alone. Icon-only rails force everyone to learn a private vocabulary, and a + * client manager visits this maybe twice a month. + * + * Owner-only links are filtered out here for tidiness. That is presentation, + * not security — the pages themselves re-check the role. + */ +export function Sidebar({ + role, + currentPath, +}: { + role: Role; + currentPath: string; +}) { + const items = NAV.filter((item) => !item.ownerOnly || role === "owner"); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/shell/top-bar.tsx b/src/components/shell/top-bar.tsx new file mode 100644 index 0000000..eba9fac --- /dev/null +++ b/src/components/shell/top-bar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; + +import { ThemeToggle } from "@/components/theme/theme-toggle"; +import { IconButton } from "@/components/ui/icon-button"; + +export function TopBar({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children?: React.ReactNode; +}) { + const router = useRouter(); + + async function signOut() { + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/login"); + router.refresh(); + } + + return ( +
+
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ +
+ {children} + + + + +
+
+ ); +} \ No newline at end of file