From b81fb65f3b708bae2b9b23ec36d247f62283e295 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 03:31:25 +0100 Subject: [PATCH] feat(frontend): sortable listes/cartes, arrayMove, collision listes, SortableCard + sortable-utils --- frontend/app/boards/[id]/cardEventHandlers.ts | 272 +++++++---------- frontend/app/boards/[id]/listEventHandlers.ts | 34 +-- frontend/components/BoardView.tsx | 268 ++++++++++++----- frontend/components/CardItem.tsx | 32 +- .../ListColumn/components/SortableCard.tsx | 44 +++ frontend/components/ListColumn/index.tsx | 278 +++++------------- frontend/components/ListColumn/types.ts | 3 +- frontend/components/SortableColumn.tsx | 49 +++ frontend/components/sortable-utils.ts | 17 ++ 9 files changed, 519 insertions(+), 478 deletions(-) create mode 100644 frontend/components/ListColumn/components/SortableCard.tsx create mode 100644 frontend/components/SortableColumn.tsx create mode 100644 frontend/components/sortable-utils.ts diff --git a/frontend/app/boards/[id]/cardEventHandlers.ts b/frontend/app/boards/[id]/cardEventHandlers.ts index 949c66f..63e281c 100644 --- a/frontend/app/boards/[id]/cardEventHandlers.ts +++ b/frontend/app/boards/[id]/cardEventHandlers.ts @@ -1,6 +1,13 @@ import type { QueryClient } from '@tanstack/react-query'; import { Dispatch, SetStateAction } from 'react'; -import { createCard, moveCard, updateCard, deleteCard, archiveCard } from '@/lib/actions/cards'; +import { arrayMove } from '@dnd-kit/sortable'; +import { + createCard, + moveCard, + reorderCards, + deleteCard, + archiveCard, +} from '@/lib/actions/cards'; import { List, Card } from './types'; import { boardQueryKey } from './queries'; import { activityInvalidateKey, activityBoardInvalidateKey } from '@/lib/queries/activity'; @@ -21,28 +28,18 @@ export function createCardEventHandlers( queryClient?.invalidateQueries({ queryKey: activityInvalidateKey }); queryClient?.invalidateQueries({ queryKey: activityBoardInvalidateKey }); }; - // Store full board snapshot before drag for exact rollback + let boardSnapshot: List[] = []; function handleDragStart(e?: DetailEvent<{ cardId: string }>) { const detail = e?.detail; if (!detail) return; - const { cardId } = detail; - - // CRITICAL: Reject temporary cards before capturing snapshot - if (cardId?.startsWith('temp-')) { - console.warn('⚠️ Cannot drag temporary card:', cardId, '- card still being created'); - return; // Don't capture snapshot - } - - // Capture full board state before any mutations + if (cardId?.startsWith('temp-')) return; setLists((prevLists) => { - boardSnapshot = JSON.parse(JSON.stringify(prevLists)); // Deep clone - return prevLists; // No mutation + boardSnapshot = JSON.parse(JSON.stringify(prevLists)); + return prevLists; }); - - console.log('📸 Snapshot captured:', boardSnapshot.length, 'lists'); } async function handleCardCreate(e?: DetailEvent<{ listId: string; title: string }>) { @@ -61,9 +58,7 @@ export function createCardEventHandlers( setLists((prevLists) => prevLists.map((l) => - l.id === listId - ? { ...l, cards: [...(l.cards || []), tempCard] } - : l + l.id === listId ? { ...l, cards: [...(l.cards || []), tempCard] } : l ) ); @@ -71,40 +66,36 @@ export function createCardEventHandlers( const newCard = await createCard({ listId, title }); if (!newCard) throw new Error('Failed to create card'); logAction('✅', 'Card created'); + const createdCard: Card = { + id: newCard.id, + title: newCard.title, + description: newCard.description ?? undefined, + position: newCard.position ?? 0, + listId: newCard.listId ?? listId, + dueDate: newCard.dueDate ?? undefined, + startDate: newCard.startDate ?? undefined, + completed: newCard.completed ?? false, + createdAt: newCard.createdAt ?? new Date().toISOString(), + }; setLists((prevLists) => prevLists.map((l) => l.id === listId ? { - ...l, - cards: (l.cards || []).map((c) => - c.id === tempCard.id ? { - id: newCard.id, - title: newCard.title, - description: newCard.description ?? undefined, - position: newCard.position ?? 0, - listId: newCard.listId ?? listId, - dueDate: newCard.dueDate ?? undefined, - startDate: newCard.startDate ?? undefined, - completed: newCard.completed ?? false, - background: (newCard as { background?: string }).background, - createdAt: (newCard as { createdAt?: string }).createdAt ?? new Date().toISOString(), - labels: undefined, - assignees: undefined, - checklists: undefined, - } : c - ), - } + ...l, + cards: (l.cards || []) + .filter((c) => !c.id.startsWith('temp-')) + .concat(createdCard), + } : l ) ); invalidateBoard(); - invalidateActivity(); } catch (err) { handleAsyncError(err, 'create card'); setLists((prevLists) => prevLists.map((l) => l.id === listId - ? { ...l, cards: (l.cards || []).filter((c) => c.id !== tempCard.id) } + ? { ...l, cards: (l.cards || []).filter((c) => !c.id.startsWith('temp-')) } : l ) ); @@ -112,46 +103,48 @@ export function createCardEventHandlers( } async function handleCardMove( - e?: DetailEvent<{ cardId: string; sourceListId: string; targetListId: string; targetIndex: number; fromIndex: number }> + e?: DetailEvent<{ + cardId: string; + sourceListId: string; + targetListId: string; + targetIndex: number; + fromIndex: number; + }> ) { const detail = e?.detail; if (!detail) return; const { cardId, sourceListId, targetListId, targetIndex, fromIndex } = detail; - // CRITICAL: Reject temporary cards to prevent "Card not found" errors - if (cardId?.startsWith('temp-')) { - console.warn('⚠️ Cannot move temporary card:', cardId, '- card still being created'); - return; - } + if (cardId?.startsWith('temp-')) return; + + let targetListCardPositions: { id: string; position: number }[] = []; - // Optimistic UI update - single source of truth setLists((prevLists) => { const sourceList = prevLists.find((l) => l.id === sourceListId); const movedCard = sourceList?.cards?.find((c) => c.id === cardId); - if (!movedCard) return prevLists; const isSameList = sourceListId === targetListId; if (isSameList) { - return prevLists.map((l) => { - if (l.id === sourceListId) { - const newCards = [...(l.cards || [])]; - const [removed] = newCards.splice(fromIndex, 1); - newCards.splice(targetIndex, 0, removed); - return { ...l, cards: newCards }; - } - return l; - }); + const sourceCards = sourceList!.cards || []; + const insertIndex = fromIndex < targetIndex ? targetIndex - 1 : targetIndex; + const newCards = arrayMove(sourceCards, fromIndex, insertIndex); + targetListCardPositions = newCards.map((c, i) => ({ id: c.id, position: i })); + return prevLists.map((l) => + l.id === sourceListId ? { ...l, cards: newCards } : l + ); } else { + const targetList = prevLists.find((l) => l.id === targetListId); + const newTargetCards = [...(targetList?.cards || [])]; + newTargetCards.splice(targetIndex, 0, movedCard); + targetListCardPositions = newTargetCards.map((c, i) => ({ id: c.id, position: i })); return prevLists.map((l) => { if (l.id === sourceListId) { return { ...l, cards: (l.cards || []).filter((c) => c.id !== cardId) }; } if (l.id === targetListId) { - const newCards = [...(l.cards || [])]; - newCards.splice(targetIndex, 0, movedCard); - return { ...l, cards: newCards }; + return { ...l, cards: newTargetCards }; } return l; }); @@ -159,7 +152,10 @@ export function createCardEventHandlers( }); try { - await moveCard({ cardId, targetListId, position: targetIndex }); + if (sourceListId !== targetListId) { + await moveCard({ cardId, targetListId, position: targetIndex }); + } + await reorderCards({ listId: targetListId, cardPositions: targetListCardPositions }); logAction('✅', 'Card moved'); invalidateBoard(); invalidateActivity(); @@ -175,22 +171,20 @@ export function createCardEventHandlers( const detail = e?.detail; if (!detail) return; const { cardId, title } = detail; - // Sync cache only (modal already called updateCard + setQueryData; emit is for other listeners) setLists((prevLists) => prevLists.map((lst) => ({ ...lst, - cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, title } : c - ), + cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, title } : c)), })) ); } - function handleCardDescriptionUpdate(e?: DetailEvent<{ cardId: string; description: string }>) { + function handleCardDescriptionUpdate( + e?: DetailEvent<{ cardId: string; description: string }> + ) { const detail = e?.detail; if (!detail) return; const { cardId, description } = detail; - // Sync cache only (modal already called updateCard + setQueryData; emit is for other listeners) setLists((prevLists) => prevLists.map((lst) => ({ ...lst, @@ -201,124 +195,79 @@ export function createCardEventHandlers( ); } - function handleCardDueDateUpdate(e?: DetailEvent<{ cardId: string; dueDate?: { date?: string; isComplete?: boolean } }>) { + function handleCardDueDateUpdate( + e?: DetailEvent<{ cardId: string; dueDate: string | undefined }> + ) { const detail = e?.detail; if (!detail) return; const { cardId, dueDate } = detail; - - const dueDateValue = dueDate === undefined ? null : (dueDate?.date ?? null); - setLists((prevLists) => prevLists.map((lst) => ({ ...lst, - cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, dueDate: dueDateValue ?? undefined } : c - ), + cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, dueDate } : c)), })) ); } - function handleCardStartDateUpdate(e?: DetailEvent<{ cardId: string; startDate?: string }>) { + function handleCardStartDateUpdate( + e?: DetailEvent<{ cardId: string; startDate: string | undefined }> + ) { const detail = e?.detail; if (!detail) return; const { cardId, startDate } = detail; - - const startDateValue = startDate === undefined ? null : startDate; - setLists((prevLists) => prevLists.map((lst) => ({ ...lst, - cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, startDate: startDateValue ?? undefined } : c - ), + cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, startDate } : c)), })) ); } - async function handleCardCompletedUpdate(e?: DetailEvent<{ cardId: string; completed: boolean }>) { + function handleCardBackgroundUpdate( + e?: DetailEvent<{ cardId: string; background: string }> + ) { const detail = e?.detail; if (!detail) return; - const { cardId, completed } = detail; - - const prevLists = getLists?.() ?? []; - const doneList = prevLists.find( - (l) => l.title?.toLowerCase().trim() === 'done', - ); - const sourceList = prevLists.find((l) => - l.cards?.some((c) => c.id === cardId), + const { cardId, background } = detail; + setLists((prevLists) => + prevLists.map((lst) => ({ + ...lst, + cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, background } : c)), + })) ); - const card = sourceList?.cards?.find((c) => c.id === cardId); - const shouldMoveToDone = - completed && - !!doneList && - !!sourceList && - !!card && - sourceList.id !== doneList.id; + } - setLists((prevLists) => { - if (shouldMoveToDone && doneList && sourceList && card) { - return prevLists.map((lst) => { - if (lst.id === sourceList.id) { - return { - ...lst, - cards: (lst.cards || []).filter((c) => c.id !== cardId), - }; - } - if (lst.id === doneList.id) { - return { - ...lst, - cards: [...(lst.cards || []), { ...card, completed: true }], - }; - } - return lst; - }); - } - return prevLists.map((lst) => ({ + function handleCardCompletedUpdate( + e?: DetailEvent<{ cardId: string; completed: boolean }> + ) { + const detail = e?.detail; + if (!detail) return; + const { cardId, completed } = detail; + setLists((prevLists) => + prevLists.map((lst) => ({ ...lst, - cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, completed } : c, - ), - })); - }); - - try { - await updateCard({ id: cardId, completed }); - if (shouldMoveToDone && doneList && card) { - await moveCard({ - cardId, - targetListId: doneList.id, - position: doneList.cards?.length ?? 0, - }); - logAction('✅', 'Card completed and moved to Done'); - } else { - logAction('✅', 'Card completed status updated'); - } - invalidateBoard(); - invalidateActivity(); - } catch (err) { - handleAsyncError(err, 'update card completed status'); - } + cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, completed } : c)), + })) + ); } async function handleCardDelete(e?: DetailEvent<{ cardId: string }>) { const detail = e?.detail; if (!detail) return; const { cardId } = detail; - setLists((prevLists) => - prevLists.map((lst) => ({ - ...lst, - cards: (lst.cards || []).filter((c) => c.id !== cardId), + prevLists.map((l) => ({ + ...l, + cards: (l.cards || []).filter((c) => c.id !== cardId), })) ); - try { await deleteCard(cardId); logAction('✅', 'Card deleted'); invalidateBoard(); + invalidateActivity(); } catch (err) { handleAsyncError(err, 'delete card'); - window.location.reload(); } } @@ -326,57 +275,36 @@ export function createCardEventHandlers( const detail = e?.detail; if (!detail) return; const { cardId } = detail; - setLists((prevLists) => - prevLists.map((lst) => ({ - ...lst, - cards: (lst.cards || []).filter((c) => c.id !== cardId), + prevLists.map((l) => ({ + ...l, + cards: (l.cards || []).filter((c) => c.id !== cardId), })) ); - try { await archiveCard(cardId); logAction('✅', 'Card archived'); invalidateBoard(); + invalidateActivity(); } catch (err) { handleAsyncError(err, 'archive card'); } } - function handleCardBackgroundUpdate(e?: DetailEvent<{ cardId: string; background?: string | null; skipBackendUpdate?: boolean }>) { - const detail = e?.detail; - if (!detail) return; - const { cardId, background } = detail; - - const backgroundValue = background === undefined ? null : (background || null); - - setLists((prevLists) => - prevLists.map((lst) => ({ - ...lst, - cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, background: backgroundValue ?? undefined } : c - ), - })) - ); - } - - async function handleCardChecklistsUpdate( - e?: DetailEvent<{ cardId: string; checklists: unknown[] }>, + function handleCardChecklistsUpdate( + e?: DetailEvent<{ cardId: string; checklists: Card['checklists'] }> ) { const detail = e?.detail; if (!detail) return; const { cardId, checklists } = detail; - setLists((prevLists) => prevLists.map((lst) => ({ ...lst, cards: (lst.cards || []).map((c) => - c.id === cardId ? { ...c, checklists: checklists as typeof c.checklists } : c + c.id === cardId ? { ...c, checklists } : c ), })) ); - - logAction('✅', 'Card checklists updated'); } return { diff --git a/frontend/app/boards/[id]/listEventHandlers.ts b/frontend/app/boards/[id]/listEventHandlers.ts index 451e7b7..43bbdf6 100644 --- a/frontend/app/boards/[id]/listEventHandlers.ts +++ b/frontend/app/boards/[id]/listEventHandlers.ts @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import { Dispatch, SetStateAction } from 'react'; +import { arrayMove } from '@dnd-kit/sortable'; import { createList, updateList, reorderLists, deleteList, archiveList } from '@/lib/actions/lists'; import { createCard, moveCard } from '@/lib/actions/cards'; import { List, Card } from './types'; @@ -63,36 +64,27 @@ export function createListEventHandlers( if (!detail) return; const { listId, newPosition } = detail; - let updatedLists: List[] = []; + let listPositions: { id: string; position: number }[] = []; let originalLists: List[] = []; - // Optimistic UI update setLists((prevLists) => { originalLists = [...prevLists]; const sourceIndex = prevLists.findIndex((l) => l.id === listId); - if (sourceIndex === -1 || sourceIndex === newPosition) return prevLists; - const updated = [...prevLists]; - const [moved] = updated.splice(sourceIndex, 1); - updated.splice(newPosition, 0, moved); - updatedLists = updated; + const updated = arrayMove(prevLists, sourceIndex, newPosition); + listPositions = updated.map((l, idx) => ({ id: l.id, position: idx })); return updated; }); - // Backend sync with rollback - (async () => { - try { - const positions = updatedLists.map((l, idx) => ({ id: l.id, position: idx })); - await reorderLists({ boardId, listPositions: positions }); - logAction('✅', 'Lists reordered'); - invalidateBoard(); - } catch (err) { - handleAsyncError(err, 'reorder lists'); - // Rollback on failure - setLists(() => originalLists); - } - })(); + try { + await reorderLists({ boardId, listPositions }); + logAction('✅', 'Lists reordered'); + invalidateBoard(); + } catch (err) { + handleAsyncError(err, 'reorder lists'); + setLists(() => originalLists); + } } async function handleListCopy(e?: DetailEvent<{ sourceListId: string; newListTitle: string }>) { @@ -123,7 +115,6 @@ export function createListEventHandlers( return [...prevLists, newList]; }); - // If no cards to create, exit early if (!cardsToCreate) return; (async () => { @@ -132,7 +123,6 @@ export function createListEventHandlers( const newList = await createList({ boardId, title: newListTitle }); if (!newList) throw new Error('Failed to copy list'); - // 2. Create all cards in the new list const createdCards: Card[] = []; for (let i = 0; i < cardsToCreate.length; i++) { const sourceCard = cardsToCreate[i]; diff --git a/frontend/components/BoardView.tsx b/frontend/components/BoardView.tsx index b8c418a..50ca94a 100644 --- a/frontend/components/BoardView.tsx +++ b/frontend/components/BoardView.tsx @@ -4,31 +4,34 @@ import React, { useRef, useState, useEffect, - useCallback, useMemo, + useCallback, } from 'react'; import { DndContext, - closestCenter, + DragOverlay, + pointerWithin, KeyboardSensor, PointerSensor, useSensor, useSensors, + DragStartEvent, DragEndEvent, + DragOverEvent, + type CollisionDetection, } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, - useSortable, } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; import ListColumn from './ListColumn'; -import type { Board, List } from '@/app/boards/[id]/types'; +import { SortableColumn } from './SortableColumn'; +import CardItem from './CardItem'; +import type { Board, List, Card } from '@/app/boards/[id]/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -/** Descriptions pour les options de visibilité du board (Private, Workspace, Public). */ export function getVisibilityDescription( visibility?: string, workspaceName?: string, @@ -55,9 +58,17 @@ export default function BoardView({ canEdit?: boolean; }) { const lists = useMemo(() => board.lists || [], [board.lists]); + const [draggingCardListId, setDraggingCardListId] = useState( + null, + ); + const [dropTargetListId, setDropTargetListId] = useState(null); + const [activeCard, setActiveCard] = useState(null); + const [activeList, setActiveList] = useState(null); const sensors = useSensors( - useSensor(PointerSensor), + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), @@ -73,7 +84,73 @@ export default function BoardView({ }); }, [board, lists]); + const listIds = useMemo(() => lists.map((l) => l.id), [lists]); + + const collisionDetection: CollisionDetection = useCallback( + (args) => { + const collisions = pointerWithin(args); + const activeId = String(args.active.id); + if (!listIds.includes(activeId) || collisions.length === 0) + return collisions; + const first = collisions[0]; + const overId = String(first.id); + const LIST_DROP_PREFIX = 'list-drop-'; + if (overId.startsWith(LIST_DROP_PREFIX)) { + return [{ ...first, id: overId.slice(LIST_DROP_PREFIX.length) }]; + } + return collisions; + }, + [listIds], + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (!event.over || !draggingCardListId) { + setDropTargetListId(null); + return; + } + const overId = String(event.over.id); + const LIST_DROP_PREFIX = 'list-drop-'; + if (overId.startsWith(LIST_DROP_PREFIX)) { + setDropTargetListId(overId.slice(LIST_DROP_PREFIX.length)); + return; + } + const listContainingCard = lists.find((l) => + l.cards?.some((c) => c.id === overId), + ); + setDropTargetListId(listContainingCard?.id ?? null); + }, + [draggingCardListId, lists], + ); + + const handleDragStart = (event: DragStartEvent) => { + if (!canEdit) return; + const { active } = event; + const activeId = String(active.id); + if (listIds.includes(activeId)) { + setDraggingCardListId(null); + setActiveCard(null); + const list = lists.find((l) => l.id === activeId) ?? null; + setActiveList(list); + return; + } + setActiveList(null); + const sourceList = lists.find((l) => + l.cards?.some((c) => c.id === activeId), + ); + const card = sourceList?.cards?.find((c) => c.id === activeId) ?? null; + setActiveCard(card); + setDraggingCardListId(sourceList?.id ?? null); + window.dispatchEvent( + new CustomEvent('epitrello:drag-start', { detail: { cardId: activeId } }), + ); + }; + const handleDragEnd = (event: DragEndEvent) => { + setDraggingCardListId(null); + setDropTargetListId(null); + setActiveCard(null); + setActiveList(null); if (!canEdit) return; const { active, over } = event; @@ -81,30 +158,86 @@ export default function BoardView({ return; } - const oldIndex = lists.findIndex((list) => list.id === active.id); - const newIndex = lists.findIndex((list) => list.id === over.id); + const activeId = String(active.id); + const overId = String(over.id); + + if (listIds.includes(activeId)) { + const LIST_DROP_PREFIX = 'list-drop-'; + const overListId = overId.startsWith(LIST_DROP_PREFIX) + ? overId.slice(LIST_DROP_PREFIX.length) + : overId; + const oldIndex = listIds.indexOf(activeId); + const newIndex = listIds.indexOf(overListId); + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + window.dispatchEvent( + new CustomEvent('epitrello:list-moved', { + detail: { + listId: activeId, + newPosition: newIndex, + boardId: board.id, + }, + }), + ); + } + return; + } + + const sourceList = lists.find((l) => + l.cards?.some((c) => c.id === activeId), + ); + if (!sourceList) return; + const fromIndex = + sourceList.cards?.findIndex((c) => c.id === activeId) ?? -1; + + const LIST_DROP_PREFIX = 'list-drop-'; + const isDropOnList = + overId.startsWith(LIST_DROP_PREFIX) || listIds.includes(overId); + const resolvedListId = overId.startsWith(LIST_DROP_PREFIX) + ? overId.slice(LIST_DROP_PREFIX.length) + : overId; - if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { - window.dispatchEvent( - new CustomEvent('epitrello:list-moved', { - detail: { - listId: active.id as string, - newPosition: newIndex, - boardId: board.id, - }, - }), + let targetListId: string; + let targetIndex: number; + if (isDropOnList) { + targetListId = resolvedListId; + const targetList = lists.find((l) => l.id === targetListId); + targetIndex = targetList?.cards?.length ?? 0; + } else { + const targetList = lists.find((l) => + l.cards?.some((c) => c.id === overId), ); + if (!targetList) return; + targetListId = targetList.id; + targetIndex = targetList.cards?.findIndex((c) => c.id === overId) ?? 0; } - }; - const listIds = useMemo(() => lists.map((l) => l.id), [lists]); + window.dispatchEvent( + new CustomEvent('epitrello:card-move', { + detail: { + cardId: activeId, + sourceListId: sourceList.id, + targetListId, + targetIndex, + fromIndex, + }, + }), + ); + }; return (
{ + setDraggingCardListId(null); + setDropTargetListId(null); + setActiveCard(null); + setActiveList(null); + }} > ))} @@ -129,53 +264,52 @@ export default function BoardView({ )}
- - - ); -} - -function SortableColumn({ - list, - totalListsCount, - allLists, - boardId, - canEdit, -}: { - list: List; - totalListsCount: number; - allLists: List[]; - boardId: string; - canEdit: boolean; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: list.id, disabled: !canEdit }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- + + {activeList ? ( +
+ +
+ ) : activeCard ? ( +
+ ({ + id: l.id, + name: l.title ?? 'Untitled', + }))} + currentBoardId={board.id} + readOnly + /> +
+ ) : null} +
+
); } diff --git a/frontend/components/CardItem.tsx b/frontend/components/CardItem.tsx index 3af5471..c442a89 100644 --- a/frontend/components/CardItem.tsx +++ b/frontend/components/CardItem.tsx @@ -62,21 +62,24 @@ export default function CardItem({ availableLists = [], currentBoardId, readOnly = false, + dragHandleProps, }: { card: Card; index?: number; onDragStart?: ( e: React.DragEvent, cardId: string, - fromIndex?: number + fromIndex?: number, ) => void; onDragOver?: (e: React.DragEvent, overIndex?: number) => void; availableLists?: Array<{ id: string; name: string }>; currentBoardId?: string; readOnly?: boolean; + dragHandleProps?: React.HTMLAttributes; }) { const [isModalOpen, setIsModalOpen] = useState(false); const [isDragging, setIsDragging] = useState(false); + const useDndKit = !!dragHandleProps; const [isHovering, setIsHovering] = useState(false); const propCompleted = card.completed ?? false; const [optimisticCompleted, setOptimisticCompleted] = useState< @@ -103,6 +106,7 @@ export default function CardItem({ }; const handleDragStart = (e: React.DragEvent) => { + if (useDndKit) return; if (card.id.startsWith('temp-')) { e.preventDefault(); return; @@ -120,6 +124,7 @@ export default function CardItem({ }; const handleDragEnd = (e: React.DragEvent) => { + if (useDndKit) return; setIsDragging(false); if (e.currentTarget instanceof HTMLElement) { @@ -129,6 +134,7 @@ export default function CardItem({ }; const handleDragOver = (e: React.DragEvent) => { + if (useDndKit) return; e.preventDefault(); e.stopPropagation(); if (onDragOver) { @@ -144,7 +150,7 @@ export default function CardItem({ cardId: card.id, completed: checked, }, - }) + }), ); }; @@ -155,13 +161,18 @@ export default function CardItem({ <>
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} - className={`bg-secondary dark:bg-card border-2 rounded-lg select-none transition-all duration-200 overflow-hidden ${ + className={`bg-secondary dark:bg-card border rounded-lg select-none transition-all duration-200 overflow-hidden ${ card.id.startsWith('temp-') ? 'opacity-60 cursor-not-allowed border-accent' : `hover:cursor-pointer border-accent hover:border-blue-500 ${ @@ -171,6 +182,7 @@ export default function CardItem({ onClick={handleClick} tabIndex={0} title={card.id.startsWith('temp-') ? 'Saving card...' : undefined} + {...(dragHandleProps ?? {})} > {card.background && (card.background.startsWith('data:image') || @@ -223,7 +235,7 @@ export default function CardItem({ />
{card.startDate && card.dueDate ? `${formatDate(card.startDate)} → ${formatDate( - card.dueDate + card.dueDate, )}` : card.dueDate - ? `Due: ${formatDate(card.dueDate)}` - : `Start: ${formatDate(card.startDate!)}`} + ? `Due: ${formatDate(card.dueDate)}` + : `Start: ${formatDate(card.startDate!)}`} {localCompleted && ( @@ -276,11 +288,11 @@ export default function CardItem({ {(() => { const total = (card.checklists ?? []).reduce( (s, cl) => s + (cl.items?.length ?? 0), - 0 + 0, ); const checked = (card.checklists ?? []).reduce( (s, cl) => s + (cl.items?.filter((i) => i.checked).length ?? 0), - 0 + 0, ); return total > 0 ? (
; + boardId?: string; +}; + +export function SortableCard({ + card, + index, + readOnly, + availableLists, + boardId, +}: SortableCardProps) { + const disabled = readOnly || card.id.startsWith('temp-'); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: card.id, disabled }); + + return ( +
+ +
+ ); +} diff --git a/frontend/components/ListColumn/index.tsx b/frontend/components/ListColumn/index.tsx index 163c9b1..a492dc2 100644 --- a/frontend/components/ListColumn/index.tsx +++ b/frontend/components/ListColumn/index.tsx @@ -1,13 +1,19 @@ 'use client'; import React, { useState, useEffect, useRef } from 'react'; -import CardItem from '../CardItem'; +import { useDroppable } from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { Card, ListColumnProps, SortOption } from './types'; import { dispatchCustomEvent, generateId, createCardsSignature } from './utils'; import { useFocusWhen } from './hooks'; import { CardComposer } from './components/CardComposer'; import { ListColumnDialogs } from './components/ListColumnDialogs'; +import { SortableCard } from './components/SortableCard'; import { + GripVertical, MoreVertical, Plus, Copy, @@ -39,6 +45,8 @@ export default function ListColumn({ dragHandleProps, boardId, readOnly = false, + draggingCardListId = null, + dropTargetListId = null, }: ListColumnProps) { const [cards, setCards] = useState(list.cards || []); const [lastLocalChange, setLastLocalChange] = useState(0); @@ -47,10 +55,16 @@ export default function ListColumn({ const [title, setTitle] = useState(list.title || 'Untitled'); const inputRef = useRef(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - const [isDragOver, setIsDragOver] = useState(false); - const [addingCard, setAddingCard] = useState(false); + const droppableId = `list-drop-${list.id}`; + const { setNodeRef: setDroppableRef, isOver: isDropOver } = useDroppable({ + id: droppableId, + }); + const isDropTarget = + (isDropOver || dropTargetListId === list.id) && + draggingCardListId != null && + draggingCardListId !== list.id; + const showDropHighlight = isDropTarget; const addButtonRef = useRef(null); const [isHoveringColumn, setIsHoveringColumn] = useState(false); @@ -68,16 +82,15 @@ export default function ListColumn({ const incomingSignature = createCardsSignature(incoming); if (localSignature === incomingSignature) return; - // Sync immédiat si le parent a plus de cartes (création temps réel ou handler) const hasNewCardsFromParent = (incoming?.length ?? 0) > (cards?.length ?? 0); if (!hasNewCardsFromParent && Date.now() - lastLocalChange < 400) return; - setCards(incoming); + queueMicrotask(() => setCards(incoming)); }, [list.cards, cards, lastLocalChange, list.id, list.title]); useEffect(() => { - setTitle(list.title || 'Untitled'); + queueMicrotask(() => setTitle(list.title || 'Untitled')); }, [list.title]); useFocusWhen(editing, inputRef as React.RefObject, true); @@ -177,24 +190,24 @@ export default function ListColumn({ sortedCards.sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; // Newest first + return dateB - dateA; }); break; case 'date-oldest': sortedCards.sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateA - dateB; // Oldest first + return dateA - dateB; }); break; case 'due-date': sortedCards.sort((a, b) => { if (!a.dueDate && !b.dueDate) return 0; - if (!a.dueDate) return 1; // Cards without due date go to the end + if (!a.dueDate) return 1; if (!b.dueDate) return -1; const dateA = new Date(a.dueDate).getTime(); const dateB = new Date(b.dueDate).getTime(); - return dateA - dateB; // Earliest due date first + return dateA - dateB; }); break; case 'alpha-asc': @@ -209,178 +222,13 @@ export default function ListColumn({ setLastLocalChange(Date.now()); }; - // Drag & drop handlers - const handleCardDragStart = ( - e: React.DragEvent, - cardId: string, - fromIndex?: number - ) => { - console.log('🎬 Drag start:', { - cardId, - isTemp: cardId?.startsWith('temp-'), - fromIndex, - listId: list.id, - }); - - // CRITICAL: Extra safety check - should not reach here due to draggable=false, but just in case - if (cardId?.startsWith('temp-')) { - console.warn('⚠️ Attempted to drag temporary card:', cardId); - e.preventDefault(); - return; - } - - try { - const fromIndexCalculated = - typeof fromIndex === 'number' - ? fromIndex - : cards.findIndex((c) => c.id === cardId); - - const dragData = { - cardId, - fromListId: list.id, - fromIndex: fromIndexCalculated, - }; - e.dataTransfer.setData('application/json', JSON.stringify(dragData)); - e.dataTransfer.effectAllowed = 'move'; - - console.log('📦 Drag data set:', dragData); - - // Dispatch snapshot event - store full board state before drag - dispatchCustomEvent('epitrello:drag-start', { - cardId, - fromListId: list.id, - fromIndex: fromIndexCalculated, - }); - - // Set drag image for better UX - const draggedCard = cards.find((c) => c.id === cardId); - if (draggedCard && e.currentTarget instanceof HTMLElement) { - const clone = e.currentTarget.cloneNode(true) as HTMLElement; - clone.style.opacity = '0.8'; - clone.style.transform = 'rotate(5deg)'; - document.body.appendChild(clone); - e.dataTransfer.setDragImage(clone, 0, 0); - setTimeout(() => document.body.removeChild(clone), 0); - } - } catch (error) { - console.error('Error setting drag data:', error); - } - }; - - const handleCardDragOver = (e: React.DragEvent, overIndex?: number) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - - // Calculate precise drop index based on mouse position - if (typeof overIndex === 'number') { - const cardElements = - e.currentTarget.parentElement?.querySelectorAll('[draggable="true"]'); - if (cardElements && cardElements[overIndex]) { - const rect = cardElements[overIndex].getBoundingClientRect(); - const midpoint = rect.top + rect.height / 2; - const adjustedIndex = e.clientY > midpoint ? overIndex + 1 : overIndex; - setDragOverIndex(adjustedIndex); - } else { - setDragOverIndex(overIndex); - } - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (readOnly) { - setDragOverIndex(null); - return; - } - - const raw = e.dataTransfer.getData('application/json'); - if (!raw) { - setDragOverIndex(null); - return; - } - - try { - const data = JSON.parse(raw); - console.log('📥 Drop data received:', { - cardId: data?.cardId, - fromListId: data?.fromListId, - toListId: list.id, - isTemp: data?.cardId?.startsWith('temp-'), - }); - - if (!data?.cardId) { - setDragOverIndex(null); - return; - } - - let targetIndex = dragOverIndex !== null ? dragOverIndex : cards.length; - const fromIndex = data.fromIndex; - const isIntralistMove = data.fromListId === list.id; - - if (isIntralistMove && (fromIndex === -1 || targetIndex === fromIndex)) { - setDragOverIndex(null); - return; - } - - if (isIntralistMove && fromIndex < targetIndex) { - targetIndex = Math.max(0, targetIndex - 1); - } - - dispatchCustomEvent('epitrello:card-move', { - cardId: data.cardId, - sourceListId: data.fromListId, - targetListId: list.id, - targetIndex: targetIndex, - fromIndex: fromIndex, - }); - } catch (error) { - console.error('Error handling drop:', error); - } finally { - setDragOverIndex(null); - } - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.stopPropagation(); - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const { clientX, clientY } = e; - - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDragOverIndex(null); - } - }; - return (
{ - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - if (dragOverIndex === null && cards.length > 0) { - setDragOverIndex(cards.length); - } else if (cards.length === 0) { - setDragOverIndex(0); - } - }} - onDrop={handleDrop} - onDragEnter={(e) => { - e.stopPropagation(); - setIsDragOver(true); - }} - onDragLeave={handleDragLeave} + ref={setDroppableRef} onMouseEnter={() => setIsHoveringColumn(true)} onMouseLeave={() => setIsHoveringColumn(false)} className={`w-[272px] min-w-[272px] shrink-0 rounded-2xl flex flex-col animate-slide-in transition-all duration-200 ${ - isDragOver + showDropHighlight ? 'bg-primary/20 ring-2 ring-primary shadow-lg' : 'bg-white dark:bg-black' }`} @@ -390,16 +238,31 @@ export default function ListColumn({
{!editing ? ( -

!readOnly && setEditing(true)} - title={readOnly ? undefined : 'Click to edit'} - {...(readOnly ? {} : dragHandleProps)} - > - {title} -

+ <> + {!readOnly && dragHandleProps && ( +
e.stopPropagation()} + onPointerDown={(e) => { + dragHandleProps.onPointerDown?.(e); + e.stopPropagation(); + }} + aria-label='Drag to reorder list' + > + +
+ )} +

!readOnly && setEditing(true)} + title={readOnly ? undefined : 'Click to edit'} + > + {title} +

+ ) : ( @@ -555,41 +418,44 @@ export default function ListColumn({
- {/* Cards area */} + {/* Cards area (droppable for @dnd-kit) */}
0 ? 'max-h-full' : '' }`} > - {cards.length === 0 && dragOverIndex === 0 && ( + {showDropHighlight && cards.length === 0 && (
Drop card here
)} - {cards.map((c, i) => ( -
- {dragOverIndex === i && ( -
- )} - c.id)} + strategy={verticalListSortingStrategy} + > + {cards.map((c, i) => ( + ({ id: l.id, name: l.title, }))} - currentBoardId={boardId} - readOnly={readOnly} + boardId={boardId} /> -
- ))} - {dragOverIndex === cards.length && cards.length > 0 && ( -
- )} + ))} + {showDropHighlight && cards.length > 0 && ( +
+ + Drop here + +
+ )} +
{/* Footer - hide when read only */} diff --git a/frontend/components/ListColumn/types.ts b/frontend/components/ListColumn/types.ts index c6eed05..c86c103 100644 --- a/frontend/components/ListColumn/types.ts +++ b/frontend/components/ListColumn/types.ts @@ -23,8 +23,9 @@ export type ListColumnProps = { allLists?: List[]; dragHandleProps?: React.HTMLAttributes; boardId?: string; - /** When true, hide add card, list menu and disable editing (view only). */ readOnly?: boolean; + draggingCardListId?: string | null; + dropTargetListId?: string | null; }; export type SortOption = 'date-newest' | 'date-oldest' | 'due-date' | 'alpha-asc' | 'alpha-desc'; diff --git a/frontend/components/SortableColumn.tsx b/frontend/components/SortableColumn.tsx new file mode 100644 index 0000000..43626ae --- /dev/null +++ b/frontend/components/SortableColumn.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import ListColumn from './ListColumn'; +import { getSortableStyle } from './sortable-utils'; +import type { List } from '@/app/boards/[id]/types'; + +type SortableColumnProps = { + list: List; + totalListsCount: number; + allLists: List[]; + boardId: string; + canEdit: boolean; + draggingCardListId: string | null; + dropTargetListId: string | null; +}; + +export function SortableColumn({ + list, + totalListsCount, + allLists, + boardId, + canEdit, + draggingCardListId, + dropTargetListId, +}: SortableColumnProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: list.id, disabled: !canEdit }); + + return ( +
+ +
+ ); +} diff --git a/frontend/components/sortable-utils.ts b/frontend/components/sortable-utils.ts new file mode 100644 index 0000000..cc35c56 --- /dev/null +++ b/frontend/components/sortable-utils.ts @@ -0,0 +1,17 @@ +import { CSS } from '@dnd-kit/utilities'; + +const DEFAULT_TRANSFORM = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; +const DRAGGING_OPACITY = 0.4; + +export function getSortableStyle( + transform: { x: number; y: number; scaleX: number; scaleY: number } | null | undefined, + transition: string | undefined, + isDragging: boolean, + draggingOpacity = DRAGGING_OPACITY, +): React.CSSProperties { + return { + transform: CSS.Transform.toString(transform ?? DEFAULT_TRANSFORM), + transition, + opacity: isDragging ? draggingOpacity : 1, + }; +}