feat(board): owner drag and drop with optimistic persistence
This commit is contained in:
parent
4649c82432
commit
972d45d38b
4 changed files with 319 additions and 0 deletions
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
39
src/app/p/[clientSlug]/[projectSlug]/page.tsx
Normal file
39
src/app/p/[clientSlug]/[projectSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
src/components/board/board.tsx
Normal file
221
src/components/board/board.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue