218 lines
9.0 KiB
TypeScript
218 lines
9.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, 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
|
|
data-board-chrome=""
|
|
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" }}
|
|
>
|
|
{/* overflow-hidden only while actually shrinking (grid-template-rows
|
|
easing to 0fr above) -- at rest it clips nothing, but clipped
|
|
unconditionally it also cropped the card's own hover lift
|
|
(hover:-translate-y-0.5 in GroupCard), cutting off its top stroke. */}
|
|
<div className={cn(holdPhase === "collapsing" ? "overflow-hidden" : "overflow-visible")}>
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|