"use client"; import { useEffect, useRef, 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 { CategoryChips } from "@/components/board/category-chips"; 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); const pagerRef = useRef(null); // Which lane is front-and-center in the mobile pager (see the observer // below) -- null until the first intersection report lands, after which // CategoryChips falls back to the first lane for the brief gap. const [activeCategoryId, setActiveCategoryId] = useState(null); // 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); // Mobile pager awareness: watch the lanes inside the scroll pager and // keep the chip rail in sync with whichever lane is most visible. Only // runs when lanes actually exist; re-runs when the lane set changes so // added/removed lanes get observed. (No-op on desktop -- the lanes are // there, but the chips are hidden, so the state is simply unused.) useEffect(() => { const pager = pagerRef.current; if (!pager) return; const lanes = Array.from(pager.querySelectorAll("[data-category-id]")); if (lanes.length === 0) return; const ratios = new Map(); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const el = entry.target as HTMLElement; if (entry.isIntersecting) ratios.set(el, entry.intersectionRatio); else ratios.delete(el); } let best: HTMLElement | null = null; let bestRatio = 0; for (const lane of lanes) { const ratio = ratios.get(lane) ?? 0; if (ratio > bestRatio) { bestRatio = ratio; best = lane; } } if (best?.dataset.categoryId) setActiveCategoryId(best.dataset.categoryId); }, { root: pager, threshold: [0.25, 0.5, 0.75, 1] } ); lanes.forEach((lane) => observer.observe(lane)); return () => observer.disconnect(); }, [categories]); // Chip tap: glide the pager to that lane (snap then settles it exactly // onto the lane's start edge). function handleSelectChip(id: string) { const pager = pagerRef.current; const lane = pager?.querySelector(`[data-category-id="${id}"]`); if (!pager || !lane) return; const left = lane.getBoundingClientRect().left - pager.getBoundingClientRect().left + pager.scrollLeft; pager.scrollTo({ left, behavior: "smooth" }); } 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)} aria-label="Add to-do" > To-do } /> {quickTargetGroup ? "Quickly add a to-do to any group" : "Add a group first, then quickly add to-dos"}
{categories.length === 0 ? (
) : ( <> {/* Mobile only: the category rail. On desktop the lanes are already labeled and side-by-side, so this stays hidden. */}
{/* Mobile: one lane per screen, snap-paged (swipe = next lane), while each lane still scrolls its cards vertically. Desktop keeps the plain side-by-side scroll (snap disabled at md). */}
{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 ( ); }