diff --git a/package-lock.json b/package-lock.json index 8a25ade..baf1769 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eaeedab..da316d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/p/[clientSlug]/[projectSlug]/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/page.tsx new file mode 100644 index 0000000..1ede8f5 --- /dev/null +++ b/src/app/p/[clientSlug]/[projectSlug]/page.tsx @@ -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 ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx new file mode 100644 index 0000000..a0bfa44 --- /dev/null +++ b/src/components/board/board.tsx @@ -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 ( + + {role === "client_manager" && ( + + )} + + ); +} + +function Droppable({ + column, + count, + children, +}: { + column: ColumnRecord; + count: number; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ id: `column:${column.id}` }); + + return ( + + {children} + + ); +} + +export function Board({ + role, + projectId, + columns, + initialCards, +}: { + role: Role; + projectId: string; + columns: ColumnRecord[]; + initialCards: CardRecord[]; +}) { + const [cards, setCards] = useState(initialCards); + const [activeId, setActiveId] = useState(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(); + 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:"; 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 = ( +
+ {columns.map((column) => { + const list = byColumn.get(column.id) ?? []; + return ( + + c.id)} + strategy={verticalListSortingStrategy} + > + {list.map((card) => ( + + ))} + + + ); + })} +
+ ); + + if (role !== "owner") { + return
{content}
; + } + + return ( +
+ + {content} + + {activeCard && } + + +
+ ); +} \ No newline at end of file