"use client"; import * as React from "react"; import { useParams } from "next/navigation"; import { DndContext, DragOverlay, PointerSensor, closestCorners, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data"; import { useViewData } from "@/lib/hooks/use-view-data"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { BoardCardPreview } from "./board-card"; import { BoardColumn } from "./board-column"; const STATUS_ORDER = ["open", "in_progress", "done", "closed"] as const; const COLUMN_THEME: Record< string, { dot: string; borderTop: string; label: string } > = { open: { dot: "bg-muted-foreground/55", borderTop: "border-t-[3px] border-t-muted-foreground/45", label: "Open", }, in_progress: { dot: "bg-blue-500", borderTop: "border-t-[3px] border-t-blue-500", label: "In progress", }, done: { dot: "bg-green-500", borderTop: "border-t-[3px] border-t-green-500", label: "Done", }, closed: { dot: "bg-slate-500", borderTop: "border-t-[3px] border-t-slate-500", label: "Closed", }, }; function getColumnTheme(columnId: string) { return ( COLUMN_THEME[columnId] ?? { dot: "bg-muted-foreground/50", borderTop: "border-t-[3px] border-t-muted-foreground/35", label: columnId.replace(/_/g, " "), } ); } function getColumnKeys( groupBy: string | null, grouped: Record, ): string[] { if (groupBy === "status" || groupBy === null) { return [...STATUS_ORDER]; } return Object.keys(grouped).sort(); } function buildColumnsFromGrouped( columnKeys: string[], grouped: Record, ): Record { const out: Record = {}; for (const k of columnKeys) { out[k] = grouped[k] ? [...grouped[k]] : []; } return out; } function findContainer( id: string, cols: Record, ): string | undefined { if (id in cols) return id; for (const key of Object.keys(cols)) { if (cols[key].some((o) => o.id === id)) return key; } return undefined; } function patchObjectForColumn( object: ViewObject, columnId: string, groupField: string, ): ViewObject { const field = groupField === "status" ? "status" : groupField; if (field === "status") { return { ...object, status: columnId }; } return { ...object, [field]: columnId } as ViewObject; } export interface BoardViewProps { config: ViewConfig; className?: string; } export function BoardView({ config, className }: BoardViewProps) { const params = useParams(); const workspaceId = typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; const parentId = typeof params?.projectId === "string" ? params.projectId : undefined; const effectiveConfig = React.useMemo( () => ({ ...config, groupBy: config.groupBy ?? "status", }), [config], ); const { grouped, isLoading, total } = useViewData( effectiveConfig, workspaceId, parentId, ); const groupField = effectiveConfig.groupBy ?? "status"; const columnKeys = React.useMemo( () => getColumnKeys(effectiveConfig.groupBy, grouped), [effectiveConfig.groupBy, grouped], ); const initialColumns = React.useMemo( () => buildColumnsFromGrouped(columnKeys, grouped), [columnKeys, grouped], ); const [columns, setColumns] = React.useState>(initialColumns); const [activeId, setActiveId] = React.useState(null); const [creatingColumnId, setCreatingColumnId] = React.useState(null); const [newTitle, setNewTitle] = React.useState(""); const utils = api.useUtils(); const createObject = api.objects.create.useMutation({ onSuccess: () => { utils.objects.list.invalidate(); setNewTitle(""); setCreatingColumnId(null); }, }); React.useEffect(() => { setColumns(initialColumns); }, [initialColumns]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), ); const activeObject = React.useMemo(() => { if (!activeId) return null; for (const list of Object.values(columns)) { const found = list.find((o) => o.id === activeId); if (found) return found; } return null; }, [activeId, columns]); const handleDragStart = (event: DragStartEvent) => { setActiveId(String(event.active.id)); }; const handleDragCancel = () => { setActiveId(null); }; const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; if (!over) return; const activeIdStr = String(active.id); const overIdStr = String(over.id); if (activeIdStr === overIdStr) return; setColumns((prev) => { const activeContainer = findContainer(activeIdStr, prev); const overContainer = findContainer(overIdStr, prev); if (!activeContainer || !overContainer) return prev; if (activeContainer === overContainer) return prev; const activeItems = [...prev[activeContainer]]; const overItems = [...prev[overContainer]]; const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr); if (activeIndex === -1) return prev; let newIndex: number; if (overIdStr in prev) { newIndex = overItems.length; } else { const overItemIndex = overItems.findIndex((i) => i.id === overIdStr); const isBelowOverItem = over.rect && active.rect.current.translated && active.rect.current.translated.top > over.rect.top + over.rect.height; const modifier = isBelowOverItem ? 1 : 0; newIndex = overItemIndex >= 0 ? overItemIndex + modifier : overItems.length; } const [removed] = activeItems.splice(activeIndex, 1); const patched = patchObjectForColumn(removed, overContainer, groupField); const nextOver = [...overItems]; nextOver.splice(newIndex, 0, patched); return { ...prev, [activeContainer]: activeItems, [overContainer]: nextOver, }; }); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); if (!over) return; const activeIdStr = String(active.id); const overIdStr = String(over.id); setColumns((prev) => { const activeContainer = findContainer(activeIdStr, prev); const overContainer = findContainer(overIdStr, prev); if (!activeContainer || !overContainer) return prev; if (activeContainer !== overContainer) { const activeItems = [...prev[activeContainer]]; const overItems = [...prev[overContainer]]; const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr); if (activeIndex === -1) return prev; const [removed] = activeItems.splice(activeIndex, 1); const patched = patchObjectForColumn(removed, overContainer, groupField); let newIndex = overItems.length; if (!(overIdStr in prev)) { const overItemIndex = overItems.findIndex((i) => i.id === overIdStr); if (overItemIndex >= 0) newIndex = overItemIndex; } const nextOver = [...overItems]; nextOver.splice(newIndex, 0, patched); return { ...prev, [activeContainer]: activeItems, [overContainer]: nextOver, }; } const list = [...prev[activeContainer]]; const oldIndex = list.findIndex((i) => i.id === activeIdStr); if (oldIndex === -1) return prev; if (overIdStr in prev) { return prev; } const newIndex = list.findIndex((i) => i.id === overIdStr); if (newIndex === -1 || oldIndex === newIndex) return prev; return { ...prev, [activeContainer]: arrayMove(list, oldIndex, newIndex), }; }); }; if (isLoading) { return (

Loading board…

); } return (
{total} task{total === 1 ? "" : "s"}
{columnKeys.map((columnId) => { const theme = getColumnTheme(columnId); return ( { setCreatingColumnId(columnId); setNewTitle(""); }, onSubmit: () => { const t = newTitle.trim(); if (!t || !workspaceId || createObject.isPending) return; createObject.mutate({ type: "task", title: t, workspaceId, parentId: parentId ?? undefined, ...(groupField === "status" ? { status: columnId } : {}), }); }, onCancel: () => { setCreatingColumnId(null); setNewTitle(""); }, isPending: createObject.isPending, }} /> ); })}
{activeObject ? (
) : null}
); }