This commit is contained in:
Louis Knight-Webb
2025-06-14 19:02:37 -04:00
parent b96277195f
commit 0041f5e92a
5 changed files with 370 additions and 62 deletions

View File

@@ -8,8 +8,11 @@
"name": "bloop-frontend", "name": "bloop-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@@ -356,6 +359,59 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",

View File

@@ -10,8 +10,11 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",

View File

@@ -21,15 +21,19 @@ const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> <div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
)) ))
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLDivElement,
React.HTMLAttributes<HTMLHeadingElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h3 <div
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight",
@@ -41,10 +45,10 @@ const CardTitle = React.forwardRef<
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLDivElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}

View File

@@ -0,0 +1,145 @@
'use client';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
DndContext,
rectIntersection,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type { ReactNode } from 'react';
export type { DragEndEvent } from '@dnd-kit/core';
export type Status = {
id: string;
name: string;
color: string;
};
export type Feature = {
id: string;
name: string;
startAt: Date;
endAt: Date;
status: Status;
};
export type KanbanBoardProps = {
id: Status['id'];
children: ReactNode;
className?: string;
};
export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
const { isOver, setNodeRef } = useDroppable({ id });
return (
<div
className={cn(
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
isOver ? 'outline-primary' : 'outline-transparent',
className
)}
ref={setNodeRef}
>
{children}
</div>
);
};
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
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 (
<Card
className={cn(
'rounded-md p-3 shadow-sm',
isDragging && 'cursor-grabbing',
className
)}
style={{
transform: transform
? `translateX(${transform.x}px) translateY(${transform.y}px)`
: 'none',
}}
{...listeners}
{...attributes}
ref={setNodeRef}
>
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
</Card>
);
};
export type KanbanCardsProps = {
children: ReactNode;
className?: string;
};
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
);
export type KanbanHeaderProps =
| {
children: ReactNode;
}
| {
name: Status['name'];
color: Status['color'];
className?: string;
};
export const KanbanHeader = (props: KanbanHeaderProps) =>
'children' in props ? (
props.children
) : (
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: props.color }}
/>
<p className="m-0 font-semibold text-sm">{props.name}</p>
</div>
);
export type KanbanProviderProps = {
children: ReactNode;
onDragEnd: (event: DragEndEvent) => void;
className?: string;
};
export const KanbanProvider = ({
children,
onDragEnd,
className,
}: KanbanProviderProps) => (
<DndContext collisionDetection={rectIntersection} onDragEnd={onDragEnd}>
<div
className={cn('grid w-full auto-cols-fr grid-flow-col gap-4', className)}
>
{children}
</div>
</DndContext>
);

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react' 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 { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -27,6 +26,14 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react' import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
import { getAuthHeaders } from '@/lib/auth' import { getAuthHeaders } from '@/lib/auth'
import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
KanbanCard,
type DragEndEvent
} from '@/components/ui/shadcn-io/kanban'
interface Task { interface Task {
id: string id: string
@@ -52,12 +59,7 @@ interface ApiResponse<T> {
message: string | null 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 = { const statusLabels = {
Todo: 'To Do', Todo: 'To Do',
@@ -66,6 +68,13 @@ const statusLabels = {
Cancelled: 'Cancelled' Cancelled: 'Cancelled'
} }
const statusBoardColors = {
Todo: '#64748b',
InProgress: '#3b82f6',
Done: '#22c55e',
Cancelled: '#ef4444'
}
export function ProjectTasks() { export function ProjectTasks() {
const { projectId } = useParams<{ projectId: string }>() const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -219,6 +228,68 @@ export function ProjectTasks() {
setIsEditDialogOpen(true) 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<Task['status'], Task[]> = {
Todo: [],
InProgress: [],
Done: [],
Cancelled: []
}
tasks.forEach(task => {
groups[task.status].push(task)
})
return groups
}
if (loading) { if (loading) {
return <div className="text-center py-8">Loading tasks...</div> return <div className="text-center py-8">Loading tasks...</div>
} }
@@ -295,7 +366,7 @@ export function ProjectTasks() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Tasks Grid */} {/* Tasks View */}
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
@@ -310,52 +381,81 @@ export function ProjectTasks() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <KanbanProvider onDragEnd={handleDragEnd}>
{tasks.map((task) => ( {Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
<Card key={task.id} className="relative"> <KanbanBoard key={status} id={status as Task['status']}>
<CardHeader className="pb-3"> <KanbanHeader
<div className="flex items-start justify-between"> name={statusLabels[status as Task['status']]}
<div className="flex-1"> color={statusBoardColors[status as Task['status']]}
<CardTitle className="text-lg">{task.title}</CardTitle> />
<Badge <KanbanCards>
className={`mt-2 ${statusColors[task.status]}`} {statusTasks.map((task, index) => (
variant="secondary" <KanbanCard
> key={task.id}
{statusLabels[task.status]} id={task.id}
</Badge> name={task.title}
</div> index={index}
<DropdownMenu> parent={status}
<DropdownMenuTrigger asChild> >
<Button variant="ghost" size="sm"> <div className="space-y-2">
<MoreHorizontal className="h-4 w-4" /> <div className="flex items-start justify-between">
</Button> <div
</DropdownMenuTrigger> className="flex-1 cursor-pointer pr-2"
<DropdownMenuContent align="end"> onClick={() => openEditDialog(task)}
<DropdownMenuItem onClick={() => openEditDialog(task)}> >
<Edit className="h-4 w-4 mr-2" /> <h4 className="font-medium text-sm">
Edit {task.title}
</DropdownMenuItem> </h4>
<DropdownMenuItem </div>
onClick={() => deleteTask(task.id)} <div
className="text-red-600" className="flex-shrink-0"
> onPointerDown={(e) => e.stopPropagation()}
<Trash2 className="h-4 w-4 mr-2" /> onMouseDown={(e) => e.stopPropagation()}
Delete onClick={(e) => e.stopPropagation()}
</DropdownMenuItem> >
</DropdownMenuContent> <DropdownMenu>
</DropdownMenu> <DropdownMenuTrigger asChild>
</div> <Button
</CardHeader> variant="ghost"
{task.description && ( size="sm"
<CardContent> className="h-8 w-8 p-0 hover:bg-gray-100"
<p className="text-sm text-muted-foreground"> >
{task.description} <MoreHorizontal className="h-4 w-4" />
</p> </Button>
</CardContent> </DropdownMenuTrigger>
)} <DropdownMenuContent align="end">
</Card> <DropdownMenuItem onClick={() => openEditDialog(task)}>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteTask(task.id)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{task.description && (
<div
className="cursor-pointer"
onClick={() => openEditDialog(task)}
>
<p className="text-xs text-muted-foreground">
{task.description}
</p>
</div>
)}
</div>
</KanbanCard>
))}
</KanbanCards>
</KanbanBoard>
))} ))}
</div> </KanbanProvider>
)} )}
{/* Edit Task Dialog */} {/* Edit Task Dialog */}