From 2cb745779a7ad0fc0aecd69af3613cb5c6216e5f Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 17 Sep 2026 10:42:50 +0200 Subject: [PATCH] feat(nav): sidebar Kanban flyout, grouped by client --- src/components/shell/app-shell.tsx | 10 ++- src/components/shell/kanban-nav.tsx | 101 ++++++++++++++++++++++++++++ src/components/shell/sidebar.tsx | 63 ++++++++++------- src/lib/data/board.ts | 53 +++++++++++++++ 4 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 src/components/shell/kanban-nav.tsx diff --git a/src/components/shell/app-shell.tsx b/src/components/shell/app-shell.tsx index 4330355..5d9eb4c 100644 --- a/src/components/shell/app-shell.tsx +++ b/src/components/shell/app-shell.tsx @@ -1,4 +1,5 @@ import { Sidebar } from "@/components/shell/sidebar"; +import { loadSidebarProjects } from "@/lib/data/board"; import type { Role } from "@/lib/pb/types"; /** @@ -7,8 +8,11 @@ import type { Role } from "@/lib/pb/types"; * 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. + * + * Async so every existing call site gets the sidebar's Kanban tree for free — + * no page.tsx needs to change to pass it down. */ -export function AppShell({ +export async function AppShell({ role, currentPath, children, @@ -17,9 +21,11 @@ export function AppShell({ currentPath: string; children: React.ReactNode; }) { + const kanbanGroups = await loadSidebarProjects(); + return (
- +
{children} diff --git a/src/components/shell/kanban-nav.tsx b/src/components/shell/kanban-nav.tsx new file mode 100644 index 0000000..a05a2de --- /dev/null +++ b/src/components/shell/kanban-nav.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { Kanban } from "lucide-react"; + +import { cn } from "@/lib/cn"; +import type { SidebarProjectGroup } from "@/lib/data/board"; +import type { Role } from "@/lib/pb/types"; + +/** + * The sidebar's Kanban entry. + * + * Not a Link like the rest of the rail: clicking it opens a flyout beside the + * rail, and picking a project from it navigates straight into that project's + * board. This is the one nav item that is a picker rather than a destination, + * so it owns its own open/closed state rather than reading currentPath. + */ +export function KanbanNav({ + role, + groups, + active, +}: { + role: Role; + groups: SidebarProjectGroup[]; + active: boolean; +}) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + + function onPointerDown(event: PointerEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + } + + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + return ( +
+ + + {open && ( +
+ {groups.length === 0 ? ( +

No projects yet.

+ ) : ( + groups.map((group) => ( +
+ {/* A client manager only ever has one group — their own — so a + header naming their own client would be pure noise. */} + {role === "owner" && ( +

+ {group.client.name} +

+ )} + {group.projects.map((project) => ( + setOpen(false)} + className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary" + > + {project.name} + {project.kind === "prototype" && ( + + Prototype + + )} + + ))} +
+ )) + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/shell/sidebar.tsx b/src/components/shell/sidebar.tsx index 410bf8e..8ef984f 100644 --- a/src/components/shell/sidebar.tsx +++ b/src/components/shell/sidebar.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import { LayoutGrid, FolderKanban, Settings, Users, CalendarRange } from "lucide-react"; import { cn } from "@/lib/cn"; +import { KanbanNav } from "@/components/shell/kanban-nav"; +import type { SidebarProjectGroup } from "@/lib/data/board"; import type { Role } from "@/lib/pb/types"; interface NavItem { @@ -19,6 +21,25 @@ const NAV: NavItem[] = [ { href: "/admin/settings", label: "Settings", icon: Settings, ownerOnly: true }, ]; +function NavLink({ item, active }: { item: NavItem; active: boolean }) { + const Icon = item.icon; + return ( + + + {item.label} + + ); +} + /** * 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 @@ -26,15 +47,22 @@ const NAV: NavItem[] = [ * * Owner-only links are filtered out here for tidiness. That is presentation, * not security — the pages themselves re-check the role. + * + * "Kanban" is not in NAV: it is not a destination, it is a picker (see + * KanbanNav), so it renders separately between Projects and the rest. */ export function Sidebar({ role, currentPath, + kanbanGroups, }: { role: Role; currentPath: string; + kanbanGroups: SidebarProjectGroup[]; }) { const items = NAV.filter((item) => !item.ownerOnly || role === "owner"); + const isActive = (item: NavItem) => + item.href === "/" ? currentPath === "/" : currentPath.startsWith(item.href); return (
- {items.map((item) => { - const Icon = item.icon; - const active = - item.href === "/" - ? currentPath === "/" - : currentPath.startsWith(item.href); + - return ( - - - - {item.label} - - - ); - })} + + + {items.slice(1).map((item) => ( + + ))} ); } \ No newline at end of file diff --git a/src/lib/data/board.ts b/src/lib/data/board.ts index 7eb6525..bbf2dc8 100644 --- a/src/lib/data/board.ts +++ b/src/lib/data/board.ts @@ -130,4 +130,57 @@ export async function loadProjectSummaries(): Promise { } return summaries; +} + +export interface SidebarProject { + id: string; + name: string; + slug: string; + kind: ProjectRecord["kind"]; +} + +export interface SidebarProjectGroup { + client: ClientRecord; + projects: SidebarProject[]; +} + +/** + * The sidebar's Kanban flyout tree, grouped by client. + * + * Read with the caller's own token, exactly like loadProjectSummaries above — + * an owner gets every client, a client manager gets only their own project(s), + * with the read rules doing the scoping rather than a role branch here. + */ +export async function loadSidebarProjects(): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return []; + + const projects = await pb.collection("projects").getFullList({ + filter: 'status != "archived"', + sort: "name", + fields: "id,name,slug,kind,client", + }); + if (projects.length === 0) return []; + + const clients = await pb.collection("clients").getFullList(); + const clientsById = new Map(clients.map((c) => [c.id, c])); + + const groups = new Map(); + for (const project of projects) { + const client = clientsById.get(project.client); + if (!client) continue; + + const group = groups.get(client.id) ?? { client, projects: [] }; + group.projects.push({ + id: project.id, + name: project.name, + slug: project.slug, + kind: project.kind, + }); + groups.set(client.id, group); + } + + return [...groups.values()].sort((a, b) => + a.client.name.localeCompare(b.client.name) + ); } \ No newline at end of file