feat(nav): sidebar Kanban flyout, grouped by client
This commit is contained in:
parent
140703e0eb
commit
2cb745779a
4 changed files with 200 additions and 27 deletions
|
|
@ -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}
|
||||
|
|
|
|||
101
src/components/shell/kanban-nav.tsx
Normal file
101
src/components/shell/kanban-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue