feat(board): owner drag and drop with optimistic persistence

This commit is contained in:
Dave 2026-09-16 10:03:22 +02:00
parent 4649c82432
commit 972d45d38b
4 changed files with 319 additions and 0 deletions

56
package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "client-board",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"clsx": "^2.1.1",
"lucide-react": "^1.46.0",
@ -42,6 +45,59 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",

View file

@ -15,6 +15,9 @@
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"clsx": "^2.1.1",
"lucide-react": "^1.46.0",

View file

@ -0,0 +1,39 @@
import { notFound, redirect } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { Board } from "@/components/board/board";
import { getSession } from "@/lib/auth/session";
import { loadBoard } from "@/lib/data/board";
export default async function BoardPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { clientSlug, projectSlug } = await params;
const board = await loadBoard(clientSlug, projectSlug);
// loadBoard returns null both for "does not exist" and "not yours". Rendering
// the same 404 for both is deliberate: a distinct 403 would confirm to one
// client that another client's project is real.
if (!board) notFound();
return (
<AppShell
role={session.role}
currentPath={`/p/${clientSlug}/${projectSlug}`}
>
<TopBar title={board.project.name} subtitle={board.client.name} />
<Board
role={session.role}
projectId={board.project.id}
columns={board.columns}
initialCards={board.cards}
/>
</AppShell>
);
}

View file

@ -0,0 +1,221 @@
"use client";
import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { BoardColumn } from "@/components/board/board-column";
import { CardTile } from "@/components/board/card-tile";
import { ClientCardControls } from "@/components/board/client-card-controls";
import { computeDropPosition } from "@/lib/board/move";
import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types";
function SortableCard({
card,
role,
onPatched,
}: {
card: CardRecord;
role: Role;
onPatched: (card: CardRecord) => void;
}) {
const draggable = role === "owner";
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id, disabled: !draggable });
return (
<CardTile
ref={setNodeRef}
card={card}
dragging={isDragging}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={draggable ? "cursor-grab active:cursor-grabbing" : undefined}
{...(draggable ? attributes : {})}
{...(draggable ? listeners : {})}
>
{role === "client_manager" && (
<ClientCardControls card={card} onPatched={onPatched} />
)}
</CardTile>
);
}
function Droppable({
column,
count,
children,
}: {
column: ColumnRecord;
count: number;
children: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id: `column:${column.id}` });
return (
<BoardColumn
column={column}
count={count}
isOver={isOver}
setDroppableRef={setNodeRef}
>
{children}
</BoardColumn>
);
}
export function Board({
role,
projectId,
columns,
initialCards,
}: {
role: Role;
projectId: string;
columns: ColumnRecord[];
initialCards: CardRecord[];
}) {
const [cards, setCards] = useState<CardRecord[]>(initialCards);
const [activeId, setActiveId] = useState<string | null>(null);
// A client manager gets no sensors, so @dnd-kit never starts a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
const byColumn = useMemo(() => {
const map = new Map<string, CardRecord[]>();
for (const column of columns) map.set(column.id, []);
for (const card of cards) map.get(card.column)?.push(card);
for (const list of map.values()) list.sort((a, b) => a.position - b.position);
return map;
}, [cards, columns]);
const activeCard = activeId ? cards.find((c) => c.id === activeId) : null;
function onDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
async function onDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const card = cards.find((c) => c.id === String(active.id));
if (!card) return;
const overId = String(over.id);
// Dropping on a column's empty area gives "column:<id>"; dropping on
// another card gives that card's id and the destination is its column.
let destinationColumnId: string;
let dropIndex: number;
if (overId.startsWith("column:")) {
destinationColumnId = overId.slice("column:".length);
dropIndex = (byColumn.get(destinationColumnId) ?? []).length;
} else {
const overCard = cards.find((c) => c.id === overId);
if (!overCard) return;
destinationColumnId = overCard.column;
const list = byColumn.get(destinationColumnId) ?? [];
dropIndex = list.findIndex((c) => c.id === overCard.id);
}
const destination = byColumn.get(destinationColumnId) ?? [];
const sameColumn = card.column === destinationColumnId;
const position = computeDropPosition(
destination,
dropIndex,
sameColumn ? card.id : null
);
if (sameColumn && position === card.position) return;
const previous = cards;
const optimistic = cards.map((c) =>
c.id === card.id ? { ...c, column: destinationColumnId, position } : c
);
setCards(optimistic);
// Through the server route, not the browser SDK: the auth token lives in
// an httpOnly cookie that JavaScript cannot read, so the browser-side
// PocketBase instance has no credentials. See Task 10.
const response = await fetch(`/api/owner/cards/${card.id}/move`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ column: destinationColumnId, position }),
});
if (!response.ok) {
console.error("[board] move failed", response.status);
setCards(previous);
}
}
function onPatched(updated: CardRecord) {
setCards((current) =>
current.map((c) => (c.id === updated.id ? { ...c, ...updated } : c))
);
}
const content = (
<div className="flex h-full gap-3 overflow-x-auto p-6">
{columns.map((column) => {
const list = byColumn.get(column.id) ?? [];
return (
<Droppable key={column.id} column={column} count={list.length}>
<SortableContext
items={list.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{list.map((card) => (
<SortableCard
key={card.id}
card={card}
role={role}
onPatched={onPatched}
/>
))}
</SortableContext>
</Droppable>
);
})}
</div>
);
if (role !== "owner") {
return <div className="flex-1 overflow-hidden">{content}</div>;
}
return (
<div className="flex-1 overflow-hidden" data-project={projectId}>
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{content}
<DragOverlay>
{activeCard && <CardTile card={activeCard} dragging />}
</DragOverlay>
</DndContext>
</div>
);
}