Organize/components/board/category-lane.tsx

158 lines
6.0 KiB
TypeScript

"use client";
import { useState } from "react";
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 { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import type { CategoryDTO } from "@/types/board";
export function CategoryLane({ category }: { category: CategoryDTO }) {
const { removeCategory } = useBoard();
const { view } = useBoardView();
const [confirmOpen, setConfirmOpen] = useState(false);
const isEmpty = category.groups.length === 0;
// Compact hides any group with no unchecked to-dos left -- which also
// covers a brand-new group with no to-dos at all, since it has zero
// unchecked ones too.
const visibleGroups =
view === "compact"
? category.groups.filter((g) => g.todos.some((t) => !t.completed))
: category.groups;
// 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, over } = useDndContext();
const isDraggingGroup = active?.data.current?.type === "group";
// While a group is being dragged, light up this lane the moment the
// pointer is over anything inside it -- a card or the empty dropzone
// below them -- so an (especially empty or short) lane still gives clear
// "drop here" feedback even when there's no card directly under the
// cursor to highlight.
const overData = over?.data.current as { categoryId?: string } | undefined;
const isDropTargetLane = isDraggingGroup && overData?.categoryId === category.id;
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 = visibleGroups.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"
disabled={!isEmpty}
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="size-4" />
Delete category
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
ref={setDropzoneRef}
className={cn(
// pt-2 (rather than relying on the header's own bottom padding)
// leaves enough clearance that a card's hover lift
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
// under the lane header above it.
"flex-1 space-y-2 overflow-y-auto rounded-lg px-3 pt-2 pb-2 transition-colors",
isDropTargetLane &&
"bg-primary/10 outline-2 outline-dashed outline-primary/50 -outline-offset-2"
)}
>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{visibleGroups.map((group) => (
<GroupCard key={group.id} group={group} />
))}
</SortableContext>
</div>
<div className="p-2">
<AddGroupPopover categoryId={category.id} />
</div>
</div>
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete category?"
description={`Delete "${category.name}"? This can't be undone.`}
onConfirm={() => removeCategory(category.id)}
/>
</>
);
}