436 lines
18 KiB
TypeScript
436 lines
18 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,
|
|
ChevronRight,
|
|
ClipboardList,
|
|
GripVertical,
|
|
MoreVertical,
|
|
Pencil,
|
|
Plus,
|
|
Sparkles,
|
|
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 { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
|
import { useBoard } from "@/components/board/board-context";
|
|
import { useBoardView } from "@/components/board/board-view-provider";
|
|
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
|
import { NotesDialog } from "@/components/board/notes-dialog";
|
|
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
|
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
|
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
|
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
|
|
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
|
|
import { StatusUpdateDialog } from "@/components/board/status-update-dialog";
|
|
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
|
import type { GroupDTO, TodoDTO } from "@/types/board";
|
|
|
|
// Compact's "+" -- sits inline on the last visible to-do row (its sibling
|
|
// text button is flex-1, so this shrink-0 trigger naturally lands at the
|
|
// row's right edge) instead of on a row of its own.
|
|
function AddTodoMenu({
|
|
group,
|
|
aiConfigured,
|
|
onAddClick,
|
|
onAiClick,
|
|
}: {
|
|
group: GroupDTO;
|
|
aiConfigured: boolean;
|
|
onAddClick: () => void;
|
|
onAiClick: () => void;
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button
|
|
className="flex size-6 shrink-0 items-center justify-center self-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
aria-label={`Add a to-do to ${group.title}`}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={onAddClick}>
|
|
<Plus className="size-4" />
|
|
Add to-do
|
|
</DropdownMenuItem>
|
|
{aiConfigured && (
|
|
<DropdownMenuItem onClick={onAiClick}>
|
|
<Sparkles className="size-4" />
|
|
Add using AI
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export function GroupCard({ group }: { group: GroupDTO }) {
|
|
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
|
const { view } = useBoardView();
|
|
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
|
const compact = view === "compact";
|
|
const { resolvedTheme } = useTheme();
|
|
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
|
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
|
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
|
const [expandedWhileComplete, setExpandedWhileComplete] = 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))`;
|
|
// Opposing hue from the group's own color, for the progress pie below --
|
|
// so it reads as a distinct accent rather than blending into the border.
|
|
const pieAccentColor = getComplementaryColor(
|
|
isDark ? color.dark : color.light,
|
|
isDark ? "dark" : "light"
|
|
);
|
|
// Un-checked to-do text: a brighter tint of the group's own color rather
|
|
// than the default (near-white in dark mode) foreground, so the group
|
|
// title reads as the most prominent thing on the card.
|
|
const todoTextColor = getBrighterColor(borderColor, isDark ? "dark" : "light");
|
|
|
|
// 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);
|
|
const completedCount = group.todos.filter((t) => t.completed).length;
|
|
// Compact surfaces open work, plus anything still mid-hold after being
|
|
// freshly checked off (see useCompactHold) -- it stays on screen,
|
|
// crossed out, through its fade before actually dropping out of the list.
|
|
const visibleTodos = compact
|
|
? group.todos.filter((t) => !t.completed || holds.has(t.id))
|
|
: group.todos;
|
|
// The "+" rides on the last still-open row (see isLastRow below), not
|
|
// necessarily the last row overall -- a held to-do can be lingering at
|
|
// the tail while it fades out. -1 when every visible row is mid-hold,
|
|
// which the fallback block below covers.
|
|
let lastOpenTodoIndex = -1;
|
|
if (compact) {
|
|
visibleTodos.forEach((t, i) => {
|
|
if (!t.completed) lastOpenTodoIndex = i;
|
|
});
|
|
}
|
|
|
|
// In the default view, a fully-checked-off group collapses down to just
|
|
// its header (title, notes icon, kebab menu) with a disclosure triangle
|
|
// in place of the drag-grip dots, so a board full of "done" groups
|
|
// doesn't stay as visually loud as one still full of open work. The
|
|
// triangle re-expands it back to the normal full card. Compact already
|
|
// hides finished groups outright (see CategoryLane), so this only
|
|
// applies to the default view.
|
|
const showDisclosure = !compact && canArchive;
|
|
// Reset back to collapsed each time the group freshly becomes fully
|
|
// done, rather than remembering a stale expanded choice from its last
|
|
// completion cycle. Adjusting state during render (comparing against
|
|
// last render's value) instead of in a useEffect avoids an extra render
|
|
// pass -- see https://react.dev/learn/you-might-not-need-an-effect.
|
|
const [wasComplete, setWasComplete] = useState(canArchive);
|
|
if (canArchive !== wasComplete) {
|
|
setWasComplete(canArchive);
|
|
if (canArchive) setExpandedWhileComplete(false);
|
|
}
|
|
const isCollapsed = showDisclosure && !expandedWhileComplete;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
borderColor,
|
|
backgroundColor,
|
|
}}
|
|
className={cn(
|
|
"relative 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
|
|
ref={setActivatorNodeRef}
|
|
{...attributes}
|
|
{...listeners}
|
|
className="group flex min-w-0 flex-1 cursor-grab touch-none items-start gap-1 rounded-md outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
aria-label={`Drag to move ${group.title}`}
|
|
>
|
|
{showDisclosure ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpandedWhileComplete((v) => !v);
|
|
}}
|
|
className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
|
|
aria-label={expandedWhileComplete ? `Collapse ${group.title}` : `Expand ${group.title}`}
|
|
aria-expanded={expandedWhileComplete}
|
|
>
|
|
<ChevronRight
|
|
className={cn("size-4 transition-transform", expandedWhileComplete && "rotate-90")}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<span className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground">
|
|
<GripVertical className="size-4" />
|
|
</span>
|
|
)}
|
|
<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>
|
|
{aiConfigured && (
|
|
<DropdownMenuItem onClick={() => setStatusUpdateOpen(true)}>
|
|
<ClipboardList className="size-4" />
|
|
Status Update
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
|
|
<Trash2 className="size-4" />
|
|
Delete group
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{!isCollapsed && (
|
|
<>
|
|
<ul className="mt-1 space-y-1">
|
|
{visibleTodos.map((todo, index) => {
|
|
const todoButton = (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingTodo(todo)}
|
|
style={todo.completed ? undefined : { color: todoTextColor }}
|
|
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>
|
|
);
|
|
|
|
// In compact, the "+" rides along on the last *open* row
|
|
// instead of taking a row of its own -- a to-do mid-hold
|
|
// (see useCompactHold) can be lingering after it, fading
|
|
// out, so this isn't simply the last row overall.
|
|
const isLastRow = compact && index === lastOpenTodoIndex;
|
|
// Held to-dos are the only completed ones compact ever
|
|
// keeps mounted (see visibleTodos above); everywhere else
|
|
// this is undefined and the row renders at rest, exactly
|
|
// as before.
|
|
const holdPhase = compact ? holds.get(todo.id) : undefined;
|
|
|
|
return (
|
|
<li
|
|
key={todo.id}
|
|
className="grid transition-[grid-template-rows] duration-200 ease-in"
|
|
style={{ gridTemplateRows: holdPhase === "collapsing" ? "0fr" : "1fr" }}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div
|
|
className="flex items-start gap-2 py-0.5 transition-opacity duration-1000 ease-in"
|
|
style={{
|
|
opacity: holdPhase === "fading" || holdPhase === "collapsing" ? 0 : 1,
|
|
}}
|
|
>
|
|
<TodoCheckbox
|
|
checked={todo.completed}
|
|
onCheckedChange={(checked) => {
|
|
toggleTodoDone(todo.id, group.id, group.categoryId, checked);
|
|
if (!compact) return;
|
|
// Checking off holds it on screen for a grace
|
|
// period; unchecking -- including as a
|
|
// change-of-mind mid-hold -- cancels that hold
|
|
// outright so it snaps back to a normal open row.
|
|
if (checked) beginHold(todo.id);
|
|
else cancelHold(todo.id);
|
|
}}
|
|
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
|
|
)}
|
|
{isLastRow && (
|
|
<AddTodoMenu
|
|
group={group}
|
|
aiConfigured={aiConfigured}
|
|
onAddClick={() => setTodoCreateOpen(true)}
|
|
onAiClick={() => setTodoAiOpen(true)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
|
|
{compact ? (
|
|
// No open row to ride on -- either every to-do here is
|
|
// mid-hold after being freshly checked off (about to clear
|
|
// the group entirely once its fade finishes), or, as a
|
|
// defensive fallback that shouldn't otherwise happen,
|
|
// CategoryLane failed to hide an already-empty group.
|
|
lastOpenTodoIndex === -1 && (
|
|
<div className="mt-1 flex justify-end">
|
|
<AddTodoMenu
|
|
group={group}
|
|
onAddClick={() => setTodoCreateOpen(true)}
|
|
onAiClick={() => setTodoAiOpen(true)}
|
|
aiConfigured={aiConfigured}
|
|
/>
|
|
</div>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="mt-1">
|
|
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
|
</div>
|
|
|
|
{aiConfigured && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-start gap-2 text-muted-foreground"
|
|
onClick={() => setTodoAiOpen(true)}
|
|
>
|
|
<Sparkles className="size-3.5" />
|
|
Add using AI
|
|
</Button>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
<TodoProgressPie
|
|
completed={completedCount}
|
|
total={group.todos.length}
|
|
accentColor={pieAccentColor}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</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} />
|
|
|
|
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
|
|
|
<TodoCreateDialog
|
|
groupId={group.id}
|
|
categoryId={group.categoryId}
|
|
open={todoCreateOpen}
|
|
onOpenChange={setTodoCreateOpen}
|
|
/>
|
|
|
|
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
|
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|