153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useTheme } from "next-themes";
|
|
import { MoreVertical, Trash2 } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { getGroupColor } from "@/lib/colors";
|
|
import { useBoard } from "@/components/board/board-context";
|
|
import { NotesDialog } from "@/components/board/notes-dialog";
|
|
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
|
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
|
import type { GroupDTO, TodoDTO } from "@/types/board";
|
|
|
|
export function GroupCard({ group }: { group: GroupDTO }) {
|
|
const { toggleTodoDone, removeGroup } = useBoard();
|
|
const { resolvedTheme } = useTheme();
|
|
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
setActivatorNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: group.id,
|
|
data: { type: "group", categoryId: group.categoryId },
|
|
});
|
|
|
|
// dnd-kit's KeyboardSensor only treats Space/Enter as "pick up this card"
|
|
// when the key event's target is this specific activator node -- but
|
|
// that guard is skipped entirely if activatorNode.current is never set,
|
|
// which left it preventDefault()-ing every Space/Enter keydown that
|
|
// bubbled up through React's tree (not the DOM tree), including from
|
|
// portaled Popover/Dialog content nested inside the card (the notes
|
|
// editor, the "add to-do" popover). Wiring the same node as the
|
|
// activator restores the target check.
|
|
//
|
|
// This must be memoized: a fresh function identity on every render makes
|
|
// React detach+reattach the ref each time, which during a drag (many
|
|
// re-renders as transform/isDragging change) resets dnd-kit's node
|
|
// registration and broke drop detection entirely.
|
|
const setCardRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
setNodeRef(node);
|
|
setActivatorNodeRef(node);
|
|
},
|
|
[setNodeRef, setActivatorNodeRef]
|
|
);
|
|
|
|
const color = getGroupColor(group.color);
|
|
const borderColor = resolvedTheme === "dark" ? color.dark : color.light;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={setCardRef}
|
|
{...attributes}
|
|
{...listeners}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
borderColor,
|
|
}}
|
|
className={cn(
|
|
"cursor-grab touch-none rounded-xl border-[3px] bg-card p-3 shadow-sm transition-shadow",
|
|
"hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing",
|
|
isDragging && "opacity-50 shadow-lg"
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-1">
|
|
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
|
|
<div className="flex shrink-0 items-center">
|
|
<NotesDialog group={group} />
|
|
<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 ${group.title}`}
|
|
>
|
|
<MoreVertical className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => removeGroup(group.id, group.categoryId)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
Delete group
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="mt-1 space-y-1">
|
|
{group.todos.map((todo) => (
|
|
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
|
<Checkbox
|
|
checked={todo.completed}
|
|
onCheckedChange={(checked) =>
|
|
toggleTodoDone(todo.id, group.id, group.categoryId, checked === true)
|
|
}
|
|
className="mt-0.5"
|
|
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingTodo(todo)}
|
|
className={cn(
|
|
"min-w-0 flex-1 truncate text-left text-sm hover:underline",
|
|
todo.completed && "text-muted-foreground line-through"
|
|
)}
|
|
>
|
|
{todo.title}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="mt-1">
|
|
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
|
</div>
|
|
</div>
|
|
|
|
{editingTodo && (
|
|
<TodoEditDialog
|
|
todo={editingTodo}
|
|
groupId={group.id}
|
|
categoryId={group.categoryId}
|
|
open={!!editingTodo}
|
|
onOpenChange={(open) => !open && setEditingTodo(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|