2026-02-27 19:25:34 +00:00
|
|
|
import { useParams, Link } from 'react-router-dom';
|
2026-02-26 14:12:19 +00:00
|
|
|
import { useBoard } from '../hooks/useBoard';
|
|
|
|
|
import { useCardMutations } from '../hooks/useCardMutations';
|
|
|
|
|
import { useListMutations } from '../hooks/useListMutations';
|
2026-02-27 20:26:25 +00:00
|
|
|
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
|
2026-02-27 07:53:36 +00:00
|
|
|
import { CreateListModal } from '../components/kanban/CreateListModal';
|
2026-02-27 19:25:34 +00:00
|
|
|
import { CardPreviewModal } from '../components/CardPreviewModal';
|
2026-03-20 15:08:39 +00:00
|
|
|
import { BoardSidebar } from '../components/BoardSidebar';
|
2026-02-27 07:53:36 +00:00
|
|
|
import { useModal } from '../context/modals/useModal';
|
2026-02-26 14:12:19 +00:00
|
|
|
import {
|
|
|
|
|
DndContext,
|
|
|
|
|
DragEndEvent,
|
|
|
|
|
DragOverlay,
|
|
|
|
|
DragStartEvent,
|
|
|
|
|
DragOverEvent,
|
|
|
|
|
PointerSensor,
|
|
|
|
|
useSensor,
|
|
|
|
|
useSensors,
|
|
|
|
|
closestCenter,
|
|
|
|
|
} from '@dnd-kit/core';
|
2026-02-27 20:26:25 +00:00
|
|
|
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
|
|
|
|
import { Card as CardType, ListWithCards } from '../types/kanban';
|
2026-02-26 14:12:19 +00:00
|
|
|
import { useState } from 'react';
|
2026-03-20 15:08:39 +00:00
|
|
|
import { WidePageLayout } from '@/components/WidePageLayout';
|
2026-02-26 14:12:19 +00:00
|
|
|
|
|
|
|
|
export function BoardDetail() {
|
|
|
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
|
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
|
2026-02-27 07:53:36 +00:00
|
|
|
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
|
2026-02-27 19:38:38 +00:00
|
|
|
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
|
2026-02-27 07:53:36 +00:00
|
|
|
const { openModal } = useModal();
|
2026-02-26 14:12:19 +00:00
|
|
|
|
|
|
|
|
const [activeCard, setActiveCard] = useState<CardType | null>(null);
|
2026-02-27 20:26:25 +00:00
|
|
|
const [activeList, setActiveList] = useState<ListWithCards | null>(null);
|
2026-02-26 14:12:19 +00:00
|
|
|
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, {
|
|
|
|
|
activationConstraint: {
|
|
|
|
|
distance: 8,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
|
|
|
const { active } = event;
|
2026-02-27 20:26:25 +00:00
|
|
|
const [activeType, activeIdStr] = (active.id as string).split('_');
|
|
|
|
|
|
|
|
|
|
if (activeType === 'COLUMN') {
|
|
|
|
|
// Dragging a column
|
|
|
|
|
const listId = parseInt(activeIdStr);
|
|
|
|
|
const list = board?.lists.find((l) => l.id === listId);
|
|
|
|
|
if (list) {
|
|
|
|
|
setActiveList(list);
|
|
|
|
|
}
|
|
|
|
|
} else if (activeType === 'CARD') {
|
|
|
|
|
// Dragging a card
|
|
|
|
|
const cardId = parseInt(activeIdStr);
|
|
|
|
|
if (board) {
|
|
|
|
|
const card = board.lists.flatMap((list) => list.cards).find((c) => c.id === cardId);
|
|
|
|
|
if (card) {
|
|
|
|
|
setActiveCard(card);
|
|
|
|
|
}
|
2026-02-26 14:12:19 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (event: DragOverEvent) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
|
|
|
|
|
if (!over) return;
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
const overIdStr = (over.id as string).split('_')[1];
|
|
|
|
|
const overId = parseInt(overIdStr, 10);
|
|
|
|
|
const activeIdStr = (active.id as string).split('_')[1];
|
|
|
|
|
const activeId = parseInt(activeIdStr, 10);
|
2026-02-26 14:12:19 +00:00
|
|
|
if (activeId === overId) return;
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
// Find active card and its current list
|
2026-02-26 14:12:19 +00:00
|
|
|
if (!board) return;
|
|
|
|
|
|
|
|
|
|
const activeList = board.lists.find((list) => list.cards.some((card) => card.id === activeId));
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
// If we're hovering over a card in same list, do nothing
|
2026-02-26 14:12:19 +00:00
|
|
|
if (activeList) {
|
|
|
|
|
const overCard = activeList.cards.find((card) => card.id === overId);
|
|
|
|
|
if (overCard) return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
|
|
|
const { active, over } = event;
|
|
|
|
|
setActiveCard(null);
|
2026-02-27 20:26:25 +00:00
|
|
|
setActiveList(null);
|
2026-02-26 14:12:19 +00:00
|
|
|
|
|
|
|
|
if (!over || !board) return;
|
|
|
|
|
|
2026-02-27 20:26:25 +00:00
|
|
|
const [activeType, activeIdStr] = (active.id as string).split('_');
|
2026-02-27 07:53:36 +00:00
|
|
|
const [overType, overIdStr] = (over.id as string).split('_');
|
|
|
|
|
const activeId = parseInt(activeIdStr, 10);
|
2026-02-27 20:26:25 +00:00
|
|
|
const overId = parseInt(overIdStr, 10);
|
2026-02-26 14:12:19 +00:00
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
if (active.id === over.id) return;
|
2026-02-26 14:12:19 +00:00
|
|
|
|
2026-02-27 20:26:25 +00:00
|
|
|
// Handle column reordering
|
|
|
|
|
if (activeType === 'COLUMN') {
|
|
|
|
|
let overListIndex = -1;
|
|
|
|
|
const activeList = board.lists.find((l) => l.id === activeId);
|
|
|
|
|
if (overType === 'CARD') {
|
|
|
|
|
overListIndex = board.lists.findIndex((l) => {
|
|
|
|
|
const foundIndex = l.cards.findIndex((card) => card.id === overId);
|
|
|
|
|
return foundIndex >= 0;
|
|
|
|
|
});
|
|
|
|
|
} else if (overType === 'LIST') {
|
|
|
|
|
overListIndex = board.lists.findIndex((l) => l.id === overId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
|
|
|
|
|
|
|
|
|
|
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
|
|
|
|
|
|
|
|
|
|
// Calculate new positions for all lists
|
|
|
|
|
const reorderedLists = [...board.lists];
|
|
|
|
|
const [movedList] = reorderedLists.splice(activeListIndex, 1);
|
|
|
|
|
reorderedLists.splice(overListIndex, 0, movedList);
|
|
|
|
|
|
|
|
|
|
await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle card reordering (existing logic)
|
2026-02-27 07:53:36 +00:00
|
|
|
// Find active card
|
2026-02-26 14:12:19 +00:00
|
|
|
let activeCard: CardType | undefined;
|
|
|
|
|
let activeList: (typeof board.lists)[0] | undefined;
|
|
|
|
|
|
|
|
|
|
for (const list of board.lists) {
|
|
|
|
|
const card = list.cards.find((c) => c.id === activeId);
|
|
|
|
|
if (card) {
|
|
|
|
|
activeCard = card;
|
|
|
|
|
activeList = list;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!activeCard || !activeList) return;
|
|
|
|
|
|
|
|
|
|
// Check if we're dropping on a list or a card
|
2026-02-27 20:26:25 +00:00
|
|
|
if (overType.toLowerCase() === 'list') {
|
2026-02-27 07:53:36 +00:00
|
|
|
const overList = board.lists.find((list) => list.id === overId);
|
|
|
|
|
// Dropping on a list - append to end
|
2026-02-26 14:12:19 +00:00
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
if (!overList || overList.id === activeList.id) return; // Same list, do nothing
|
2026-02-26 14:12:19 +00:00
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
await moveCard(activeCard, activeList.id, overList.id, overList.cards.length);
|
2026-02-26 14:12:19 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dropping on a card - find which list it belongs to
|
|
|
|
|
let overCard: CardType | undefined;
|
|
|
|
|
let overListContainingCard: (typeof board.lists)[0] | undefined;
|
|
|
|
|
|
|
|
|
|
for (const list of board.lists) {
|
|
|
|
|
const card = list.cards.find((c) => c.id === overId);
|
|
|
|
|
if (card) {
|
|
|
|
|
overCard = card;
|
|
|
|
|
overListContainingCard = list;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!overCard || !overListContainingCard) return;
|
|
|
|
|
|
|
|
|
|
// Calculate new position
|
|
|
|
|
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
|
2026-02-26 14:12:19 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-27 19:25:34 +00:00
|
|
|
const handleOpenCardModal = (card: CardType) => {
|
|
|
|
|
openModal((props) => <CardPreviewModal card={card as any} onClose={props.onClose} />);
|
2026-02-26 14:12:19 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
const handleAddList = () => {
|
|
|
|
|
openModal((props) => (
|
|
|
|
|
<CreateListModal
|
|
|
|
|
{...props}
|
|
|
|
|
onCreate={async (name) => {
|
|
|
|
|
await createList(name, board ? board.lists.length : 0);
|
|
|
|
|
fetchBoard(); // Refresh board data
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
));
|
2026-02-26 14:12:19 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-27 19:38:38 +00:00
|
|
|
const handleEditList = async (listId: number, name: string) => {
|
|
|
|
|
const list = board?.lists.find((l) => l.id === listId);
|
|
|
|
|
if (list) {
|
|
|
|
|
await updateList(listId, { name, pos: list.pos });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteList = async (listId: number) => {
|
|
|
|
|
await deleteList(listId);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 07:53:36 +00:00
|
|
|
const handleAddCard =
|
|
|
|
|
(listId: number) => async (data: { name: string; description?: string }) => {
|
|
|
|
|
await createCard(listId, {
|
|
|
|
|
name: data.name,
|
|
|
|
|
description: data.description,
|
|
|
|
|
pos: board ? board.lists.find((list) => list.id === listId)?.cards.length || 0 : 0,
|
|
|
|
|
});
|
|
|
|
|
fetchBoard(); // Refresh board data
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 14:12:19 +00:00
|
|
|
if (!board) {
|
|
|
|
|
return <div className="text-white">Loading...</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2026-03-20 15:08:39 +00:00
|
|
|
<WidePageLayout>
|
|
|
|
|
<div className="flex justify-between items-start">
|
|
|
|
|
<div>
|
|
|
|
|
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
|
|
|
|
← Back to Boards
|
|
|
|
|
</Link>
|
|
|
|
|
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
|
|
|
|
|
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/boards/${id}/edit`}
|
|
|
|
|
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Edit Board
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleAddList}
|
|
|
|
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
+ Add List
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-26 14:12:19 +00:00
|
|
|
</div>
|
2026-03-20 15:08:39 +00:00
|
|
|
</WidePageLayout>
|
|
|
|
|
|
2026-03-20 17:17:01 +00:00
|
|
|
<div className="px-4 sm:px-6 lg:px-8 ">
|
2026-03-20 15:08:39 +00:00
|
|
|
<div className="flex justify-between items-start gap-6">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragStart={handleDragStart}
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={board.lists.map((list) => `COLUMN_${list.id}`)}
|
|
|
|
|
strategy={horizontalListSortingStrategy}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
|
|
|
{board.lists.map((list) => (
|
|
|
|
|
<SortableKanbanColumn
|
|
|
|
|
key={list.id}
|
|
|
|
|
list={list}
|
|
|
|
|
cards={list.cards}
|
|
|
|
|
onOpenCardModal={handleOpenCardModal}
|
|
|
|
|
onCardCreate={handleAddCard(list.id)}
|
|
|
|
|
onListEdit={(name) => handleEditList(list.id, name)}
|
|
|
|
|
onListDelete={() => handleDeleteList(list.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
|
|
|
|
|
<DragOverlay>
|
|
|
|
|
{activeCard ? (
|
|
|
|
|
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
|
|
|
|
|
<h3 className="text-white font-medium">{activeCard.name}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
) : activeList ? (
|
|
|
|
|
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
|
|
|
|
|
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
|
|
|
|
|
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
|
|
|
|
|
{activeList.cards.length} cards
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</DragOverlay>
|
|
|
|
|
</DndContext>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<BoardSidebar boardId={id || ''} />
|
2026-02-26 14:12:19 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|