Organize/components/board/category-lane.tsx

211 lines
8.5 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, 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 (
<>
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane",
isDragging && "opacity-50"
)}
>
<div className="flex items-center gap-1.5 px-3 pt-3 pb-2">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="-ml-1 cursor-grab touch-none rounded-md p-1 text-muted-foreground/70 hover:bg-accent hover:text-foreground active:cursor-grabbing"
aria-label={`Drag to reorder ${category.name}`}
>
<GripVertical className="size-3.5" />
</button>
<h2 className="min-w-0 flex-1 truncate font-heading text-[15px] font-semibold tracking-tight">
{category.name}
</h2>
<span
className={cn(
"flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
openTodos > 0
? "bg-foreground/8 text-foreground"
: "bg-foreground/5 text-muted-foreground"
)}
aria-label={`${openTodos} open to-dos in ${category.name}`}
>
{openTodos}
</span>
<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-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
isDropTargetLane && "bg-primary/10"
)}
>
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{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 (
<div
key={group.id}
className="grid transition-[grid-template-rows] duration-200 ease-in"
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div
className="transition-opacity duration-1000 ease-in"
style={{
opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1,
}}
>
<GroupCard group={group} />
</div>
</div>
</div>
);
})}
</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)}
/>
</>
);
}