feat(nav): sidebar Kanban flyout, grouped by client

This commit is contained in:
Dave 2026-09-17 10:42:50 +02:00
parent 140703e0eb
commit 2cb745779a
4 changed files with 200 additions and 27 deletions

View file

@ -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 (
<div className="flex h-screen overflow-hidden bg-page px-3 pb-3 pt-0">
<Sidebar role={role} currentPath={currentPath} />
<Sidebar role={role} currentPath={currentPath} kanbanGroups={kanbanGroups} />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-[var(--radius-panel)] border border-border-subtle bg-panel shadow-[var(--shadow-panel)]">
{children}

View file

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
className={cn(
"flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
active || open
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
)}
>
<Kanban size={18} strokeWidth={1.75} />
<span className="text-[10px] font-medium leading-none">Kanban</span>
</button>
{open && (
<div className="absolute left-[68px] top-0 z-20 max-h-[80vh] w-64 overflow-y-auto rounded-xl border border-border-subtle bg-panel p-2 shadow-[var(--shadow-panel)]">
{groups.length === 0 ? (
<p className="p-2 text-xs text-text-muted">No projects yet.</p>
) : (
groups.map((group) => (
<div key={group.client.id} className="mb-2 last:mb-0">
{/* A client manager only ever has one group — their own — so a
header naming their own client would be pure noise. */}
{role === "owner" && (
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{group.client.name}
</p>
)}
{group.projects.map((project) => (
<Link
key={project.id}
href={`/p/${group.client.slug}/${project.slug}`}
onClick={() => 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"
>
<span className="truncate">{project.name}</span>
{project.kind === "prototype" && (
<span className="shrink-0 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium text-accent">
Prototype
</span>
)}
</Link>
))}
</div>
))
)}
</div>
)}
</div>
);
}

View file

@ -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 (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
active
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
)}
>
<Icon size={18} strokeWidth={1.75} />
<span className="text-[10px] font-medium leading-none">{item.label}</span>
</Link>
);
}
/**
* 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 (
<nav
@ -45,32 +73,17 @@ export function Sidebar({
<span className="text-sm font-semibold">CB</span>
</div>
{items.map((item) => {
const Icon = item.icon;
const active =
item.href === "/"
? currentPath === "/"
: currentPath.startsWith(item.href);
<NavLink item={items[0]} active={isActive(items[0])} />
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
active
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
)}
>
<Icon size={18} strokeWidth={1.75} />
<span className="text-[10px] font-medium leading-none">
{item.label}
</span>
</Link>
);
})}
<KanbanNav
role={role}
groups={kanbanGroups}
active={currentPath.startsWith("/p/")}
/>
{items.slice(1).map((item) => (
<NavLink key={item.href} item={item} active={isActive(item)} />
))}
</nav>
);
}

View file

@ -130,4 +130,57 @@ export async function loadProjectSummaries(): Promise<ProjectSummary[]> {
}
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<SidebarProjectGroup[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const projects = await pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status != "archived"',
sort: "name",
fields: "id,name,slug,kind,client",
});
if (projects.length === 0) return [];
const clients = await pb.collection("clients").getFullList<ClientRecord>();
const clientsById = new Map(clients.map((c) => [c.id, c]));
const groups = new Map<string, SidebarProjectGroup>();
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)
);
}