115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
import { useDndContext, useDroppable } from "@dnd-kit/core";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { GripVertical, MoreVertical, Trash2 } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { GroupCard } from "@/components/board/group-card";
|
|
import { AddGroupPopover } from "@/components/board/add-group-popover";
|
|
import { useBoard } from "@/components/board/board-context";
|
|
import type { CategoryDTO } from "@/types/board";
|
|
|
|
export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|
const { removeCategory } = useBoard();
|
|
|
|
// The lane itself is sortable (so lanes can be reordered), which
|
|
// registers its *entire* rect -- header, cards, empty space, all of it --
|
|
// as a droppable. Dragging a group card over empty lane space then had
|
|
// two overlapping droppables to resolve against: this lane's own
|
|
// "category" droppable and the narrower "category-dropzone" below. With
|
|
// `closestCenter`, the much larger lane rect often won, so drop handling
|
|
// never saw a "group" or "category-dropzone" target and silently did
|
|
// nothing. Disabling this lane's droppable side while a *group* (not a
|
|
// lane) is being dragged removes it from collision detection entirely,
|
|
// leaving only the group cards and the dropzone as valid targets.
|
|
const { active } = useDndContext();
|
|
const isDraggingGroup = active?.data.current?.type === "group";
|
|
|
|
const {
|
|
setNodeRef,
|
|
setActivatorNodeRef,
|
|
attributes,
|
|
listeners,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: category.id,
|
|
data: { type: "category" },
|
|
disabled: { draggable: false, droppable: isDraggingGroup },
|
|
});
|
|
|
|
const { setNodeRef: setDropzoneRef } = useDroppable({
|
|
id: `category-dropzone-${category.id}`,
|
|
data: { type: "category-dropzone", categoryId: category.id },
|
|
});
|
|
|
|
const groupIds = category.groups.map((g) => g.id);
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
|
className={cn(
|
|
"flex h-full w-72 shrink-0 flex-col rounded-xl bg-muted/40",
|
|
isDragging && "opacity-50"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1 p-3">
|
|
<button
|
|
ref={setActivatorNodeRef}
|
|
{...attributes}
|
|
{...listeners}
|
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
|
aria-label={`Drag to reorder ${category.name}`}
|
|
>
|
|
<GripVertical className="size-4" />
|
|
</button>
|
|
<h2 className="min-w-0 flex-1 truncate font-semibold">{category.name}</h2>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button
|
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
aria-label={`More options for ${category.name}`}
|
|
>
|
|
<MoreVertical className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => removeCategory(category.id)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
Delete category
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div ref={setDropzoneRef} className="flex-1 space-y-2 overflow-y-auto px-3 pb-2">
|
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
|
{category.groups.map((group) => (
|
|
<GroupCard key={group.id} group={group} />
|
|
))}
|
|
</SortableContext>
|
|
</div>
|
|
|
|
<div className="p-2">
|
|
<AddGroupPopover categoryId={category.id} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|