diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5140a2c..ff3cda6b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "bloop-frontend", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-portal": "^1.1.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -356,6 +359,59 @@ "node": ">=6.9.0" } }, + "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/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "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/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6eca8c98..b3d66600 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-portal": "^1.1.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index b2e1d3a1..f62edea5 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -21,15 +21,19 @@ const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes + HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+ HTMLDivElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -

{ + const { isOver, setNodeRef } = useDroppable({ id }); + + return ( +

+ {children} +
+ ); +}; + +export type KanbanCardProps = Pick & { + index: number; + parent: string; + children?: ReactNode; + className?: string; +}; + +export const KanbanCard = ({ + id, + name, + index, + parent, + children, + className, +}: KanbanCardProps) => { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useDraggable({ + id, + data: { index, parent }, + }); + + return ( + + {children ??

{name}

} +
+ ); +}; + +export type KanbanCardsProps = { + children: ReactNode; + className?: string; +}; + +export const KanbanCards = ({ children, className }: KanbanCardsProps) => ( +
{children}
+); + +export type KanbanHeaderProps = + | { + children: ReactNode; + } + | { + name: Status['name']; + color: Status['color']; + className?: string; + }; + +export const KanbanHeader = (props: KanbanHeaderProps) => + 'children' in props ? ( + props.children + ) : ( +
+
+

{props.name}

+
+ ); + +export type KanbanProviderProps = { + children: ReactNode; + onDragEnd: (event: DragEndEvent) => void; + className?: string; +}; + +export const KanbanProvider = ({ + children, + onDragEnd, + className, +}: KanbanProviderProps) => ( + +
+ {children} +
+
+); diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index d70de0f8..ff4d4123 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' +import { useParams, useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' import { Dialog, DialogContent, @@ -27,6 +26,14 @@ import { } from '@/components/ui/select' import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react' import { getAuthHeaders } from '@/lib/auth' +import { + KanbanProvider, + KanbanBoard, + KanbanHeader, + KanbanCards, + KanbanCard, + type DragEndEvent +} from '@/components/ui/shadcn-io/kanban' interface Task { id: string @@ -52,12 +59,7 @@ interface ApiResponse { message: string | null } -const statusColors = { - Todo: 'bg-slate-100 text-slate-800', - InProgress: 'bg-blue-100 text-blue-800', - Done: 'bg-green-100 text-green-800', - Cancelled: 'bg-red-100 text-red-800' -} + const statusLabels = { Todo: 'To Do', @@ -66,6 +68,13 @@ const statusLabels = { Cancelled: 'Cancelled' } +const statusBoardColors = { + Todo: '#64748b', + InProgress: '#3b82f6', + Done: '#22c55e', + Cancelled: '#ef4444' +} + export function ProjectTasks() { const { projectId } = useParams<{ projectId: string }>() const navigate = useNavigate() @@ -219,6 +228,68 @@ export function ProjectTasks() { setIsEditDialogOpen(true) } + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + + if (!over || !active.data.current) return + + const taskId = active.id as string + const newStatus = over.id as Task['status'] + const task = tasks.find(t => t.id === taskId) + + if (!task || task.status === newStatus) return + + // Optimistically update the UI immediately + const previousStatus = task.status + setTasks(prev => prev.map(t => + t.id === taskId ? { ...t, status: newStatus } : t + )) + + try { + const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: task.title, + description: task.description, + status: newStatus + }) + }) + + if (!response.ok) { + // Revert the optimistic update if the API call failed + setTasks(prev => prev.map(t => + t.id === taskId ? { ...t, status: previousStatus } : t + )) + setError('Failed to update task status') + } + } catch (err) { + // Revert the optimistic update if the API call failed + setTasks(prev => prev.map(t => + t.id === taskId ? { ...t, status: previousStatus } : t + )) + setError('Failed to update task status') + } + } + + const groupTasksByStatus = () => { + const groups: Record = { + Todo: [], + InProgress: [], + Done: [], + Cancelled: [] + } + + tasks.forEach(task => { + groups[task.status].push(task) + }) + + return groups + } + if (loading) { return
Loading tasks...
} @@ -295,7 +366,7 @@ export function ProjectTasks() { - {/* Tasks Grid */} + {/* Tasks View */} {tasks.length === 0 ? ( @@ -310,52 +381,81 @@ export function ProjectTasks() { ) : ( -
- {tasks.map((task) => ( - - -
-
- {task.title} - - {statusLabels[task.status]} - -
- - - - - - openEditDialog(task)}> - - Edit - - deleteTask(task.id)} - className="text-red-600" - > - - Delete - - - -
-
- {task.description && ( - -

- {task.description} -

-
- )} -
+ + {Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => ( + + + + {statusTasks.map((task, index) => ( + +
+
+
openEditDialog(task)} + > +

+ {task.title} +

+
+
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + + + + + openEditDialog(task)}> + + Edit + + deleteTask(task.id)} + className="text-red-600" + > + + Delete + + + +
+
+ {task.description && ( +
openEditDialog(task)} + > +

+ {task.description} +

+
+ )} +
+
+ ))} +
+
))} -
+ )} {/* Edit Task Dialog */}