Organize/components/board/group-card.tsx

207 lines
7.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import { Archive, GripVertical, MoreVertical, Pencil, StickyNote, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { TodoCheckbox } from "@/components/board/todo-checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
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 { EditGroupDialog } from "@/components/board/edit-group-dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import type { GroupDTO, TodoDTO } from "@/types/board";
export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup, archiveGroup } = useBoard();
const { resolvedTheme } = useTheme();
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({
id: group.id,
data: { type: "group", categoryId: group.categoryId },
});
// Another card is being dragged and is currently hovering over this one
// -- signal "it'll land near here" with a ring, distinct from this
// card's own accent-colored border. (isDragging guards against this
// card ever ringing itself while it's the one being moved.)
const isDropTarget = isOver && !isDragging;
const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
// A subdued tint of the same stroke color, blended into the theme's own
// card surface -- not a fixed pastel, so it automatically stays correct
// if the neutral card token ever changes, and needs no separate
// light/dark background palette to hand-tune.
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
// Only offer archiving once there's actually something done -- a group
// with no to-dos yet, or with any still open, isn't "finished" yet.
const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
return (
<>
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
borderColor,
backgroundColor,
}}
className={cn(
"rounded-xl border-[3px] p-3 shadow-sm transition-shadow",
"hover:-translate-y-0.5 hover:shadow-md",
isDragging && "opacity-50 shadow-lg",
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
>
<div className="flex items-start justify-between gap-1">
<div className="flex min-w-0 flex-1 items-start gap-1">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="mt-1 flex shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground/60 hover:text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to move ${group.title}`}
>
<GripVertical className="size-4" />
</button>
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
</div>
<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 onClick={() => setEditOpen(true)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
<Trash2 className="size-4" />
Delete group
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ul className="mt-1 space-y-1">
{group.todos.map((todo) => {
const todoButton = (
<button
type="button"
onClick={() => setEditingTodo(todo)}
className={cn(
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
todo.completed && "text-muted-foreground line-through"
)}
>
<span className="min-w-0 truncate">{todo.title}</span>
{todo.details && (
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
)}
</button>
);
return (
<li key={todo.id} className="flex items-start gap-2 py-0.5">
<TodoCheckbox
checked={todo.completed}
onCheckedChange={(checked) =>
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
}
accentColor={borderColor}
isDark={isDark}
className="mt-0.5"
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
/>
{todo.details ? (
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
) : (
todoButton
)}
</li>
);
})}
</ul>
<div className="mt-1">
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
</div>
{canArchive && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
>
<Archive className="size-3.5" />
Archive
</Button>
)}
</div>
{editingTodo && (
<TodoEditDialog
todo={editingTodo}
groupId={group.id}
categoryId={group.categoryId}
open={!!editingTodo}
onOpenChange={(open) => !open && setEditingTodo(null)}
/>
)}
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<ConfirmDeleteDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete group?"
description={`Delete "${group.title}" and all of its to-dos? This can't be undone.`}
onConfirm={() => removeGroup(group.id, group.categoryId)}
/>
</>
);
}