A click outside the Kanban sidebar flyout called setOpen(false) even when the click was on a Link, so the deferred state update landed during the Link navigation's RSC stream and corrupted Turbopack's flight client (vercel/next.js#92362). Skip the close when the click target is inside an <a> and flush the close synchronously from the Link's own onClick so the state update completes before navigation, not during RSC processing.
111 lines
No EOL
4.1 KiB
TypeScript
111 lines
No EOL
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { flushSync } from "react-dom";
|
|
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)
|
|
) {
|
|
// Skip closing when the click is on an anchor. The Link's own
|
|
// onClick closes the flyout via flushSync below, and racing a
|
|
// deferred state update with the navigation's RSC stream
|
|
// corrupts Turbopack's flight client (vercel/next.js#92362).
|
|
if ((event.target as HTMLElement).closest("a")) return;
|
|
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={() => {
|
|
// Force the close to land before navigation, not
|
|
// during RSC stream processing — see #92362 above.
|
|
flushSync(() => 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>
|
|
);
|
|
} |