From 608e6a8e102bdf1a32b1e45c8a171c52d5a9d856 Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Tue, 4 Nov 2025 16:21:20 +0100 Subject: [PATCH] feat: Integrate Kanban board with drag-and-drop functionality using @dnd-kit --- apps/infoalloggi/package-lock.json | 96 ++++- apps/infoalloggi/package.json | 6 +- .../src/components/KabanExample.tsx | 117 ++++++ apps/infoalloggi/src/components/ui/kanban.tsx | 346 ++++++++++++++++++ apps/infoalloggi/src/pages/test.tsx | 14 +- 5 files changed, 569 insertions(+), 10 deletions(-) create mode 100644 apps/infoalloggi/src/components/KabanExample.tsx create mode 100644 apps/infoalloggi/src/components/ui/kanban.tsx diff --git a/apps/infoalloggi/package-lock.json b/apps/infoalloggi/package-lock.json index d9121f8..21fb1c1 100644 --- a/apps/infoalloggi/package-lock.json +++ b/apps/infoalloggi/package-lock.json @@ -8,11 +8,14 @@ "name": "infoalloggi", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fattureincloud/fattureincloud-ts-sdk": "^2.1.1", "@hookform/resolvers": "^5.2.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", @@ -84,6 +87,7 @@ "tailwind-merge": "3.3", "tailwind-scrollbar": "^4.0.2", "tailwindcss": "^4.1.11", + "tunnel-rat": "^0.1.2", "tw-animate-css": "^1.3.6", "type-fest": "^4.41.0", "uuid": "^11.1.0", @@ -1212,6 +1216,59 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "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/@electric-sql/pglite": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.7.tgz", @@ -17908,6 +17965,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, "node_modules/tw-animate-css": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.7.tgz", @@ -18695,6 +18761,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/apps/infoalloggi/package.json b/apps/infoalloggi/package.json index 086eac4..8a25f8b 100644 --- a/apps/infoalloggi/package.json +++ b/apps/infoalloggi/package.json @@ -19,11 +19,14 @@ }, "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fattureincloud/fattureincloud-ts-sdk": "^2.1.1", "@hookform/resolvers": "^5.2.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", @@ -95,6 +98,7 @@ "tailwind-merge": "3.3", "tailwind-scrollbar": "^4.0.2", "tailwindcss": "^4.1.11", + "tunnel-rat": "^0.1.2", "tw-animate-css": "^1.3.6", "type-fest": "^4.41.0", "uuid": "^11.1.0", diff --git a/apps/infoalloggi/src/components/KabanExample.tsx b/apps/infoalloggi/src/components/KabanExample.tsx new file mode 100644 index 0000000..b77bf86 --- /dev/null +++ b/apps/infoalloggi/src/components/KabanExample.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { + KanbanBoard, + KanbanCard, + KanbanCards, + KanbanHeader, + KanbanProvider, +} from "~/components/ui/kanban"; + +const columns = [ + { id: "todo", name: "To Do", color: "red" }, + { id: "inprogress", name: "In Progress", color: "blue" }, + { id: "done", name: "Done", color: "green" }, +]; +const exampleFeatures = [ + { + id: "1", + name: "Design new homepage", + column: "todo", + startAt: new Date("2024-06-01"), + endAt: new Date("2024-06-10"), + owner: { + name: "Alice", + image: "https://i.pravatar.cc/150?img=1", + }, + }, + { + id: "2", + name: "Implement authentication", + column: "inprogress", + startAt: new Date("2024-06-05"), + endAt: new Date("2024-06-15"), + owner: { + name: "Bob", + image: "https://i.pravatar.cc/150?img=2", + }, + }, + { + id: "3", + name: "Set up database", + column: "done", + startAt: new Date("2024-05-20"), + endAt: new Date("2024-05-30"), + owner: { + name: "Charlie", + image: "https://i.pravatar.cc/150?img=3", + }, + }, +]; + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); +const shortDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); +const Example = () => { + const [features, setFeatures] = useState(exampleFeatures); + return ( + + {(column) => ( + + +
+
+ {column.name} +
+ + + {(feature: (typeof features)[number]) => ( + +
+
+

+ {feature.name} +

+
+ {feature.owner && ( + + + + {feature.owner.name?.slice(0, 2)} + + + )} +
+

+ {shortDateFormatter.format(feature.startAt)} -{" "} + {dateFormatter.format(feature.endAt)} +

+
+ )} +
+ + )} + + ); +}; +export default Example; diff --git a/apps/infoalloggi/src/components/ui/kanban.tsx b/apps/infoalloggi/src/components/ui/kanban.tsx new file mode 100644 index 0000000..f9ec75c --- /dev/null +++ b/apps/infoalloggi/src/components/ui/kanban.tsx @@ -0,0 +1,346 @@ +"use client"; + +import type { + Announcements, + DndContextProps, + DragEndEvent, + DragOverEvent, + DragStartEvent, +} from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + MouseSensor, + TouchSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + createContext, + type HTMLAttributes, + type ReactNode, + useContext, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import tunnel from "tunnel-rat"; +import { Card } from "~/components/ui/card"; +import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area"; +import { cn } from "~/lib/utils"; + +//https://www.shadcn.io/components/data/kanban#installation +const t = tunnel(); + +export type { DragEndEvent } from "@dnd-kit/core"; + +type KanbanItemProps = { + id: string; + name: string; + column: string; +} & Record; + +type KanbanColumnProps = { + id: string; + name: string; +} & Record; + +type KanbanContextProps< + T extends KanbanItemProps = KanbanItemProps, + C extends KanbanColumnProps = KanbanColumnProps, +> = { + columns: C[]; + data: T[]; + activeCardId: string | null; +}; + +const KanbanContext = createContext({ + columns: [], + data: [], + activeCardId: null, +}); + +export type KanbanBoardProps = { + id: string; + children: ReactNode; + className?: string; +}; + +export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => { + const { isOver, setNodeRef } = useDroppable({ + id, + }); + + return ( +
+ {children} +
+ ); +}; + +export type KanbanCardProps = T & { + children?: ReactNode; + className?: string; +}; + +export const KanbanCard = ({ + id, + name, + children, + className, +}: KanbanCardProps) => { + const { + attributes, + listeners, + setNodeRef, + transition, + transform, + isDragging, + } = useSortable({ + id, + }); + const { activeCardId } = useContext(KanbanContext) as KanbanContextProps; + + const style = { + transition, + transform: CSS.Transform.toString(transform), + }; + + return ( + <> +
+ + {children ??

{name}

} +
+
+ {activeCardId === id && ( + + + {children ??

{name}

} +
+
+ )} + + ); +}; + +export type KanbanCardsProps = + Omit, "children" | "id"> & { + children: (item: T) => ReactNode; + id: string; + }; + +export const KanbanCards = ({ + children, + className, + ...props +}: KanbanCardsProps) => { + const { data } = useContext(KanbanContext) as KanbanContextProps; + const filteredData = data.filter((item) => item.column === props.id); + const items = filteredData.map((item) => item.id); + + return ( + + +
+ {filteredData.map(children)} +
+
+ +
+ ); +}; + +export type KanbanHeaderProps = HTMLAttributes; + +export const KanbanHeader = ({ className, ...props }: KanbanHeaderProps) => ( +
+); + +export type KanbanProviderProps< + T extends KanbanItemProps = KanbanItemProps, + C extends KanbanColumnProps = KanbanColumnProps, +> = Omit & { + children: (column: C) => ReactNode; + className?: string; + columns: C[]; + data: T[]; + onDataChange?: (data: T[]) => void; + onDragStart?: (event: DragStartEvent) => void; + onDragEnd?: (event: DragEndEvent) => void; + onDragOver?: (event: DragOverEvent) => void; +}; + +export const KanbanProvider = < + T extends KanbanItemProps = KanbanItemProps, + C extends KanbanColumnProps = KanbanColumnProps, +>({ + children, + onDragStart, + onDragEnd, + onDragOver, + className, + columns, + data, + onDataChange, + ...props +}: KanbanProviderProps) => { + const [activeCardId, setActiveCardId] = useState(null); + + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor), + ); + + const handleDragStart = (event: DragStartEvent) => { + const card = data.find((item) => item.id === event.active.id); + if (card) { + setActiveCardId(event.active.id as string); + } + onDragStart?.(event); + }; + + const handleDragOver = (event: DragOverEvent) => { + const { active, over } = event; + + if (!over) { + return; + } + + const activeItem = data.find((item) => item.id === active.id); + const overItem = data.find((item) => item.id === over.id); + + if (!activeItem) { + return; + } + + const activeColumn = activeItem.column; + const overColumn = + overItem?.column || + columns.find((col) => col.id === over.id)?.id || + columns[0]?.id; + + if (!overColumn) { + return; + } + + if (activeColumn !== overColumn) { + let newData = [...data]; + const activeIndex = newData.findIndex((item) => item.id === active.id); + const overIndex = newData.findIndex((item) => item.id === over.id); + + if (newData[activeIndex] === undefined) { + return; + } + newData[activeIndex].column = overColumn; // Now TypeScript knows this is a string + newData = arrayMove(newData, activeIndex, overIndex); + + onDataChange?.(newData); + } + + onDragOver?.(event); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveCardId(null); + + onDragEnd?.(event); + + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + let newData = [...data]; + + const oldIndex = newData.findIndex((item) => item.id === active.id); + const newIndex = newData.findIndex((item) => item.id === over.id); + + newData = arrayMove(newData, oldIndex, newIndex); + + onDataChange?.(newData); + }; + + const announcements: Announcements = { + onDragStart({ active }) { + const { name, column } = data.find((item) => item.id === active.id) ?? {}; + + return `Picked up the card "${name}" from the "${column}" column`; + }, + onDragOver({ active, over }) { + const { name } = data.find((item) => item.id === active.id) ?? {}; + const newColumn = columns.find((column) => column.id === over?.id)?.name; + + return `Dragged the card "${name}" over the "${newColumn}" column`; + }, + onDragEnd({ active, over }) { + const { name } = data.find((item) => item.id === active.id) ?? {}; + const newColumn = columns.find((column) => column.id === over?.id)?.name; + + return `Dropped the card "${name}" into the "${newColumn}" column`; + }, + onDragCancel({ active }) { + const { name } = data.find((item) => item.id === active.id) ?? {}; + + return `Cancelled dragging the card "${name}"`; + }, + }; + + return ( + + +
+ {columns.map((column) => children(column))} +
+ {typeof window !== "undefined" && + createPortal( + + + , + document.body, + )} +
+
+ ); +}; diff --git a/apps/infoalloggi/src/pages/test.tsx b/apps/infoalloggi/src/pages/test.tsx index 6cb1b7a..8b446e0 100644 --- a/apps/infoalloggi/src/pages/test.tsx +++ b/apps/infoalloggi/src/pages/test.tsx @@ -1,5 +1,6 @@ import type { NextPage } from "next"; -import Image from "next/image"; +import Example from "~/components/KabanExample"; +import { NoSSR } from "~/lib/nossr"; /* import { useState } from "react"; @@ -149,13 +150,10 @@ export default Test; const Test: NextPage = () => { return ( -
- Test page - asdad +
+ + +
); };