"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, Plus, 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 { useHoldOnComplete } from "@/components/hold-on-complete"; import type { CategoryDTO } from "@/types/board"; export function CategoryLane({ category }: { category: CategoryDTO }) { const { removeCategory } = useBoard(); const { view } = useBoardView(); const { holds } = useHoldOnComplete(); 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. Two things override that and force a group to stay // visible a while longer (see useHoldOnComplete): // - a to-do still mid-hold counts as "unchecked" here too, so checking // off a group's very last open to-do doesn't yank the whole card (and // its undo checkbox) out from under the fade-out. // - a group itself can be held right after creation, so a freshly-made // (necessarily empty) group gets a moment on screen instead of never // appearing at all. const visibleGroups = view === "compact" ? category.groups.filter( (g) => holds.has(g.id) || g.todos.some((t) => !t.completed || holds.has(t.id)) ) : 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); // Small open-count for the header -- "what's still to do in this lane" // at a glance, the number a user organizing their day actually wants. const openTodos = category.groups.reduce( (n, g) => n + g.todos.filter((t) => !t.completed).length, 0 ); return ( <>

{category.name}

0 ? "bg-foreground/8 text-foreground" : "bg-foreground/5 text-muted-foreground" )} aria-label={`${openTodos} open to-dos in ${category.name}`} > {openTodos} } /> setConfirmOpen(true)} > Delete category
{visibleGroups.map((group) => { // Only a group being shown *solely* because of its own hold // (i.e. still empty) actually animates out -- once it has // real open work it's staying for good, so it renders at // rest even if its hold hasn't technically expired yet. const hasOpenTodo = group.todos.some((t) => !t.completed || holds.has(t.id)); const holdPhase = view === "compact" && !hasOpenTodo ? holds.get(group.id) : undefined; return (
); })}
removeCategory(category.id)} /> ); }