"use client"; import { useState } from "react"; import { DndContext, DragOverlay, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; import { LayoutDashboard, Plus } from "lucide-react"; import { BoardProvider, useBoard } from "@/components/board/board-context"; import { CategoryLane } from "@/components/board/category-lane"; import { AddCategoryLane } from "@/components/board/add-category-lane"; import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { EmptyState } from "@/components/board/empty-state"; import { ViewSwitcher } from "@/components/board/view-switcher"; import { SummaryButton } from "@/components/board/summary-button"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { ProjectThemePicker } from "@/components/projects/project-theme-picker"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { CategoryDTO, GroupDTO } from "@/types/board"; function Board({ title, projectId, aiConfigured, }: { title: string; projectId?: string; aiConfigured: boolean; }) { const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const [draggedGroup, setDraggedGroup] = useState(null); const [quickAddOpen, setQuickAddOpen] = useState(false); // The "+ to-do" quick action targets the first group it can find -- the // goal is one click from a keyboard or pointer to start typing a to-do // somewhere sensible, not a journey through lanes and cards. const quickTargetCategory = categories.find((c) => c.groups.length > 0); const quickTargetGroup = quickTargetCategory?.groups[0]; const sensors = useSensors( // A short activation distance lets a plain click (checkbox, notes icon, // todo text, ...) fire normally -- drag only kicks in once the pointer // has actually moved, which is what makes the whole card grabbable // without swallowing clicks on its interactive children. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor) ); function findCategory(categoryId: string): CategoryDTO | undefined { return categories.find((c) => c.id === categoryId); } function handleDragStart(event: DragStartEvent) { const data = event.active.data.current; if (data?.type === "group") { const category = findCategory(data.categoryId as string); const group = category?.groups.find((g) => g.id === event.active.id); setDraggedGroup(group ?? null); } } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; setDraggedGroup(null); if (!over) return; const activeData = active.data.current; const overData = over.data.current; if (!activeData) return; if (activeData.type === "category") { if (overData?.type !== "category" || active.id === over.id) return; const oldIndex = categories.findIndex((c) => c.id === active.id); const newIndex = categories.findIndex((c) => c.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(categories, oldIndex, newIndex); reorderLanes(reordered.map((c) => c.id)); return; } if (activeData.type === "group") { const groupId = active.id as string; const sourceCategoryId = activeData.categoryId as string; const sourceCategory = findCategory(sourceCategoryId); if (!sourceCategory) return; let destCategoryId: string; let destIndex: number; if (overData?.type === "group") { destCategoryId = overData.categoryId as string; const destCategory = findCategory(destCategoryId); destIndex = destCategory ? destCategory.groups.findIndex((g) => g.id === over.id) : 0; } else if (overData?.type === "category-dropzone") { destCategoryId = overData.categoryId as string; const destCategory = findCategory(destCategoryId); destIndex = destCategory ? destCategory.groups.length : 0; } else { return; } if (sourceCategoryId === destCategoryId) { const oldIndex = sourceCategory.groups.findIndex((g) => g.id === groupId); if (oldIndex === -1 || oldIndex === destIndex) return; const reordered = arrayMove(sourceCategory.groups, oldIndex, destIndex); reorderGroups(sourceCategoryId, reordered.map((g) => g.id)); return; } const sourceIds = sourceCategory.groups.filter((g) => g.id !== groupId).map((g) => g.id); const destCategory = findCategory(destCategoryId); const targetIds = destCategory ? destCategory.groups.map((g) => g.id) : []; const clampedIndex = Math.min(Math.max(destIndex, 0), targetIds.length); targetIds.splice(clampedIndex, 0, groupId); moveGroup(groupId, sourceCategoryId, destCategoryId, targetIds, sourceIds); } } const categoryIds = categories.map((c) => c.id); return (

{title}

{/* Per-project theme picker, to the right of the project's name -- only projects have their own theme assignment. */} {projectId && }

{categories.length === 0 ? "Add a lane to get started" : `${categories.length} ${categories.length === 1 ? "lane" : "lanes"} · ${categories.reduce( (n, c) => n + c.groups.length, 0 )} groups · ${categories.reduce( (n, c) => n + c.groups.reduce((m, g) => m + g.todos.filter((t) => !t.completed).length, 0), 0 )} open to-dos`}

{aiConfigured && } setQuickAddOpen(true)} > To-do } /> {quickTargetGroup ? "Quickly add a to-do to any group" : "Add a group first, then quickly add to-dos"}
{categories.length === 0 ? (
) : (
{categories.map((category) => ( ))}
)}
{draggedGroup ? : null} {quickTargetCategory && quickTargetGroup && ( )}
); } export function KanbanBoard({ initialCategories, projectId, title = "Home", aiConfigured, }: { initialCategories: CategoryDTO[]; // Undefined renders the caller's Home board; set it to scope the whole // board -- creating/reordering categories, and everything nested under // them -- to that Project instead. projectId?: string; title?: string; // Whether the admin-configured AI provider has an API URL + model saved // -- gates the "Add using AI" to-do button. Computed once server-side // per page load rather than re-checked per group card. aiConfigured: boolean; }) { return ( ); }