348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
import { PlusIcon, Trash2 } from "lucide-react";
|
|
import BoardTask from "./Task";
|
|
import { TaskViewProps } from "../TaskView";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu";
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
|
export default function BoardView({
|
|
tags,
|
|
tasks,
|
|
setTasks,
|
|
createTask,
|
|
statuses,
|
|
}: TaskViewProps) {
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
|
|
const [selectionBox, setSelectionBox] = useState<{
|
|
startX: number;
|
|
startY: number;
|
|
endX: number;
|
|
endY: number;
|
|
} | null>(null);
|
|
const boardRef = useRef<HTMLDivElement>(null);
|
|
const taskCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
|
|
|
function onDragEnd(result: any) {
|
|
const { draggableId, source, destination } = result;
|
|
if (!destination) return;
|
|
|
|
const task = Object.values(tasks).find((t) => t.id === draggableId);
|
|
if (!task) return;
|
|
|
|
setTasks({
|
|
...tasks,
|
|
[task.id]: {
|
|
...task,
|
|
statusId: destination.droppableId,
|
|
updatedAt: Date.now(),
|
|
},
|
|
});
|
|
}
|
|
|
|
const deleteSelected = useCallback(() => {
|
|
if (selectedIds.size === 0) return;
|
|
const updated = { ...tasks };
|
|
selectedIds.forEach((id) => delete updated[id]);
|
|
setTasks(updated);
|
|
setSelectedIds(new Set());
|
|
}, [selectedIds, tasks, setTasks]);
|
|
|
|
// Ctrl+A to select all, Backspace/Delete to delete selected
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "a" && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
setSelectedIds(new Set(Object.keys(tasks)));
|
|
}
|
|
if (e.key === "Backspace" || e.key === "Delete") {
|
|
// Don't fire if user is typing in an input/textarea
|
|
const target = e.target as HTMLElement;
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
)
|
|
return;
|
|
deleteSelected();
|
|
}
|
|
}
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [tasks, deleteSelected]);
|
|
|
|
// Mouse drag selection
|
|
useEffect(() => {
|
|
function handleMouseDown(e: MouseEvent) {
|
|
// Only start selection if clicking on the board background (not a card)
|
|
const target = e.target as HTMLElement;
|
|
const isCard = target.closest("[data-task-card]");
|
|
if (isCard) return;
|
|
if (e.button !== 0) return;
|
|
|
|
mouseDownPos.current = { x: e.clientX, y: e.clientY };
|
|
}
|
|
|
|
function handleMouseMove(e: MouseEvent) {
|
|
if (!mouseDownPos.current) return;
|
|
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
|
|
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
|
|
if (dx > 4 || dy > 4) {
|
|
setIsDraggingSelection(true);
|
|
setSelectionBox({
|
|
startX: mouseDownPos.current.x,
|
|
startY: mouseDownPos.current.y,
|
|
endX: e.clientX,
|
|
endY: e.clientY,
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleMouseUp(e: MouseEvent) {
|
|
if (isDraggingSelection && selectionBox) {
|
|
// Find all task cards that intersect with the selection box
|
|
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
|
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
|
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
|
|
|
const newSelected = new Set<string>();
|
|
Object.entries(taskCardRefs.current).forEach(([id, el]) => {
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
if (
|
|
rect.left < maxX &&
|
|
rect.right > minX &&
|
|
rect.top < maxY &&
|
|
rect.bottom > minY
|
|
) {
|
|
newSelected.add(id);
|
|
}
|
|
});
|
|
|
|
if (e.shiftKey) {
|
|
setSelectedIds((prev) => new Set([...prev, ...newSelected]));
|
|
} else {
|
|
setSelectedIds(newSelected);
|
|
}
|
|
} else if (!isDraggingSelection) {
|
|
// Click on empty area deselects
|
|
const target = e.target as HTMLElement;
|
|
const isCard = target.closest("[data-task-card]");
|
|
if (!isCard) {
|
|
setSelectedIds(new Set());
|
|
}
|
|
}
|
|
|
|
mouseDownPos.current = null;
|
|
setIsDraggingSelection(false);
|
|
setSelectionBox(null);
|
|
}
|
|
|
|
window.addEventListener("mousedown", handleMouseDown);
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mouseup", handleMouseUp);
|
|
return () => {
|
|
window.removeEventListener("mousedown", handleMouseDown);
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [isDraggingSelection, selectionBox]);
|
|
|
|
function handleTaskClick(e: React.MouseEvent, taskId: string) {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(taskId)) next.delete(taskId);
|
|
else next.add(taskId);
|
|
return next;
|
|
});
|
|
} else if (e.shiftKey) {
|
|
setSelectedIds((prev) => new Set([...prev, taskId]));
|
|
}
|
|
// Normal click handled by SheetTrigger (task edit) if not multi-select
|
|
}
|
|
|
|
const tasksArray = Object.values(tasks);
|
|
const tasksByStatus = statuses.map((status) => ({
|
|
status,
|
|
tasks: tasksArray
|
|
.filter((t) => t.statusId === status.id)
|
|
.sort((a, b) => a.createdAt - b.createdAt),
|
|
}));
|
|
|
|
const selRect = selectionBox
|
|
? {
|
|
left: Math.min(selectionBox.startX, selectionBox.endX),
|
|
top: Math.min(selectionBox.startY, selectionBox.endY),
|
|
width: Math.abs(selectionBox.endX - selectionBox.startX),
|
|
height: Math.abs(selectionBox.endY - selectionBox.startY),
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
ref={boardRef}
|
|
className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0 relative"
|
|
>
|
|
{/* Selection box overlay */}
|
|
{isDraggingSelection && selRect && (
|
|
<div
|
|
className="fixed pointer-events-none z-50 border border-primary bg-primary/10 rounded"
|
|
style={{
|
|
left: selRect.left,
|
|
top: selRect.top,
|
|
width: selRect.width,
|
|
height: selRect.height,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete bar when items selected */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
|
|
<span className="font-medium">
|
|
{selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
— press Backspace to delete
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
className="ml-auto h-7"
|
|
onClick={deleteSelected}
|
|
>
|
|
<Trash2 className="h-3 w-3 mr-1" />
|
|
Delete
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 text-muted-foreground"
|
|
onClick={() => setSelectedIds(new Set())}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<div className="flex flex-col md:flex-row w-full gap-6 items-start flex-1 h-full min-h-0">
|
|
{tasksByStatus.map(({ status, tasks: statusTasks }) => (
|
|
<Droppable key={status.id} droppableId={status.id}>
|
|
{(provided) => (
|
|
<Card
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
className="bg-background w-full h-full flex flex-col overflow-hidden min-h-0"
|
|
>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>{status.name}</span>
|
|
<Button
|
|
size="sm"
|
|
className="ml-auto"
|
|
variant="outline"
|
|
onClick={() => createTask(status.id)}
|
|
>
|
|
<PlusIcon />
|
|
</Button>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
|
<div className="flex flex-col gap-3">
|
|
{statusTasks.map((task, index) => (
|
|
<Draggable
|
|
key={task.id}
|
|
draggableId={task.id}
|
|
index={index}
|
|
>
|
|
{(provided) => (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
ref={(el) => {
|
|
provided.innerRef(el);
|
|
taskCardRefs.current[task.id] = el;
|
|
}}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
data-task-card={task.id}
|
|
onClick={(e) => handleTaskClick(e, task.id)}
|
|
>
|
|
<BoardTask
|
|
setTask={(t) =>
|
|
setTasks({
|
|
...tasks,
|
|
[t.id]: t,
|
|
})
|
|
}
|
|
provided={provided}
|
|
task={task}
|
|
tags={tags}
|
|
isSelected={selectedIds.has(task.id)}
|
|
onDelete={(id) => {
|
|
const updated = { ...tasks };
|
|
delete updated[id];
|
|
setTasks(updated);
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => {
|
|
const updated = { ...tasks };
|
|
delete updated[task.id];
|
|
setTasks(updated);
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(task.id);
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 className="text-destructive" />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
{statusTasks.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
No tasks
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</Droppable>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
</div>
|
|
);
|
|
}
|