Organize/components/board/group-card.tsx

508 lines
22 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
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 } from "@/lib/colors";
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import { NEW_GROUP_HOLD_MS, 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, pauseHold } = useHoldOnComplete();
const compact = view === "compact";
// True for any theme whose surfaces are dark -- Dark and Ocean both use
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
const isDark = isDarkTheme();
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);
// True while one of this card's "add to-do" windows is open and we paused
// the group's own hold to keep the card (and the window mounted inside it)
// from unmounting mid-form -- a brand-new empty group in compact view is
// the case that needs it: its creation hold would otherwise expire while
// the user is still typing and close the window right out from under them.
const addTodoHoldPausedRef = useRef(false);
// Opens or closes one of this card's "add to-do" windows (the plain
// dialog or the AI dialog). While at least one is open, a group that is
// on screen only because of its own hold (i.e. still empty) gets that
// hold frozen, so the card -- and the window mounted inside it -- can't
// be unmounted mid-form. When the last window closes, a still-empty
// group gets a fresh grace period to be noticed or filled before finally
// fading out; a group the window did fill drops the paused hold entirely
// (it stays visible for its own open work, and a leftover hold entry
// would otherwise pin it to compact view forever).
function handleAddTodoWindow(
next: boolean,
setter: (open: boolean) => void,
otherOpen: boolean
) {
setter(next);
if (next) {
if (!otherOpen && compact && holds.has(group.id)) {
addTodoHoldPausedRef.current = true;
pauseHold(group.id);
}
} else if (!otherOpen && addTodoHoldPausedRef.current) {
addTodoHoldPausedRef.current = false;
if (group.todos.some((t) => !t.completed)) cancelHold(group.id);
else beginHold(group.id, NEW_GROUP_HOLD_MS);
}
}
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;
// The group's color for the ACTIVE theme: themes with their own palette
// (cyberpunk, blueprint, vaporwave, notebook, starfield) retint the card,
// every other theme keeps the base GROUP_COLORS. Falls back to the base
// color pre-mount so the server render matches.
const color = useGroupColor(group.color);
const borderColor = isDark ? color.dark : color.light;
// The card fill is a calm, hand-picked tint of the same stroke color
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
// own card surface so it sits naturally on any theme -- the 85% weight
// keeps the tint in charge while still letting a tinted theme surface show
// through. Dark themes use `softDark`: the same hue at a dark-surface
// lightness, instead of the near-white pastel of `soft`.
const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, 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}
data-group-card=""
style={{
transform: CSS.Transform.toString(transform),
transition,
borderColor,
backgroundColor,
}}
className={cn(
"relative rounded-2xl 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 font-heading text-[15px] font-semibold tracking-tight">
{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-2 space-y-0.5">
{visibleTodos.map((todo, index) => {
const todoButton = (
<button
type="button"
onClick={() => setEditingTodo(todo)}
style={todo.completed ? undefined : { color: todoTextColor }}
className={cn(
"-mx-1 flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 text-left text-sm transition-colors hover:bg-foreground/5",
todo.completed && "text-muted-foreground line-through hover:bg-transparent"
)}
>
<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={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
/>
)}
</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={() => handleAddTodoWindow(true, setTodoCreateOpen, todoAiOpen)}
onAiClick={() => handleAddTodoWindow(true, setTodoAiOpen, todoCreateOpen)}
aiConfigured={aiConfigured}
/>
</div>
)
) : (
<>
<div className="mt-2">
<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 || group.todos.length > 0) && (
<div className="mt-1 flex items-center gap-1 border-t border-foreground/[0.07] pt-1.5">
{canArchive && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
onClick={() => archiveGroup(group.id, group.categoryId)}
aria-label="Archive group"
>
<Archive className="size-3.5" />
</Button>
}
/>
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
</Tooltip>
)}
{/* pr-2.5: the corner progress pie (TodoProgressPie) hugs
the card's right edge right here, so the label keeps a
little room of its own -- together the two clear each
other without either spilling past the lane's edge. */}
<span className="ml-auto pr-2.5 text-[11px] font-medium tabular-nums text-muted-foreground">
{completedCount}/{group.todos.length} done
</span>
</div>
)}
<TodoProgressPie
completed={completedCount}
total={group.todos.length}
accentColor={pieAccentColor}
/>
</>
)}
</>
)}
{/* Vaporwave's VHS tracking band sweeps inside this clip box (see
.vw-glitch-clip in app/globals.css) instead of the card itself
using overflow: hidden -- the card must stay overflow-visible so
the progress pie can poke past the bottom-right corner without
being clipped by it or the card's rounded corner. display:none
(and thus inert) in every other theme. */}
<div className="vw-glitch-clip" aria-hidden />
</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={(next) => handleAddTodoWindow(next, setTodoAiOpen, todoCreateOpen)}
/>
<TodoCreateDialog
groupId={group.id}
categoryId={group.categoryId}
open={todoCreateOpen}
onOpenChange={(next) => handleAddTodoWindow(next, setTodoCreateOpen, todoAiOpen)}
/>
<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)}
/>
</>
);
}