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" && (
+
+ );
+}
\ 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