You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
235 lines
6.5 KiB
235 lines
6.5 KiB
<script lang="ts"> |
|
import type { ColumnWithCards } from "$lib/api/kanban"; |
|
import type { KanbanCard } from "$lib/supabase/types"; |
|
import KanbanCardComponent from "./KanbanCard.svelte"; |
|
|
|
interface Props { |
|
columns: ColumnWithCards[]; |
|
onCardClick?: (card: KanbanCard) => void; |
|
onCardMove?: ( |
|
cardId: string, |
|
toColumnId: string, |
|
toPosition: number, |
|
) => void; |
|
onAddCard?: (columnId: string) => void; |
|
onAddColumn?: () => void; |
|
onDeleteCard?: (cardId: string) => void; |
|
onDeleteColumn?: (columnId: string) => void; |
|
canEdit?: boolean; |
|
} |
|
|
|
let { |
|
columns, |
|
onCardClick, |
|
onCardMove, |
|
onAddCard, |
|
onAddColumn, |
|
onDeleteCard, |
|
onDeleteColumn, |
|
canEdit = true, |
|
}: Props = $props(); |
|
|
|
let draggedCard = $state<KanbanCard | null>(null); |
|
let dragOverColumn = $state<string | null>(null); |
|
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>( |
|
null, |
|
); |
|
|
|
function handleDragStart(e: DragEvent, card: KanbanCard) { |
|
draggedCard = card; |
|
if (e.dataTransfer) { |
|
e.dataTransfer.effectAllowed = "move"; |
|
e.dataTransfer.setData("text/plain", card.id); |
|
} |
|
} |
|
|
|
function handleColumnDragOver(e: DragEvent, columnId: string) { |
|
e.preventDefault(); |
|
dragOverColumn = columnId; |
|
} |
|
|
|
function handleColumnDragLeave() { |
|
dragOverColumn = null; |
|
dragOverCardIndex = null; |
|
} |
|
|
|
function handleCardDragOver(e: DragEvent, columnId: string, index: number) { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
if (!draggedCard) return; |
|
|
|
// Determine if we're in the top or bottom half of the card |
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); |
|
const midY = rect.top + rect.height / 2; |
|
const dropIndex = e.clientY < midY ? index : index + 1; |
|
|
|
dragOverColumn = columnId; |
|
dragOverCardIndex = { columnId, index: dropIndex }; |
|
} |
|
|
|
function handleDrop(e: DragEvent, columnId: string) { |
|
e.preventDefault(); |
|
const targetIndex = dragOverCardIndex; |
|
dragOverColumn = null; |
|
dragOverCardIndex = null; |
|
|
|
if (!draggedCard) return; |
|
|
|
const column = columns.find((c) => c.id === columnId); |
|
if (!column) { |
|
draggedCard = null; |
|
return; |
|
} |
|
|
|
let newPosition: number; |
|
if (targetIndex && targetIndex.columnId === columnId) { |
|
newPosition = targetIndex.index; |
|
// If moving within the same column and the card is above the target, adjust |
|
if (draggedCard.column_id === columnId) { |
|
const currentIndex = column.cards.findIndex( |
|
(c) => c.id === draggedCard!.id, |
|
); |
|
if (currentIndex !== -1 && currentIndex < newPosition) { |
|
newPosition = Math.max(0, newPosition - 1); |
|
} |
|
// No-op if dropping in the same position |
|
if (currentIndex === newPosition) { |
|
draggedCard = null; |
|
return; |
|
} |
|
} |
|
} else { |
|
newPosition = column.cards.length; |
|
} |
|
|
|
onCardMove?.(draggedCard.id, columnId, newPosition); |
|
draggedCard = null; |
|
} |
|
</script> |
|
|
|
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"> |
|
{#each columns as column} |
|
<div |
|
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn === |
|
column.id |
|
? 'ring-2 ring-primary' |
|
: ''}" |
|
ondragover={(e) => handleColumnDragOver(e, column.id)} |
|
ondragleave={handleColumnDragLeave} |
|
ondrop={(e) => handleDrop(e, column.id)} |
|
role="list" |
|
> |
|
<!-- Column Header --> |
|
<div class="flex items-center gap-2 p-1 rounded-[32px]"> |
|
<div class="flex items-center gap-2 flex-1 min-w-0"> |
|
<h3 class="font-heading text-h4 text-white truncate"> |
|
{column.name} |
|
</h3> |
|
<div |
|
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0" |
|
> |
|
<span class="font-heading text-h6 text-white" |
|
>{column.cards.length}</span |
|
> |
|
</div> |
|
</div> |
|
<button |
|
type="button" |
|
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0" |
|
onclick={() => onDeleteColumn?.(column.id)} |
|
aria-label="Column options" |
|
> |
|
<span |
|
class="material-symbols-rounded text-light/50" |
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" |
|
> |
|
more_horiz |
|
</span> |
|
</button> |
|
</div> |
|
|
|
<!-- Cards --> |
|
<div class="flex-1 overflow-y-auto flex flex-col gap-0"> |
|
{#each column.cards as card, cardIndex} |
|
<!-- Drop indicator before card --> |
|
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id} |
|
<div |
|
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all" |
|
></div> |
|
{/if} |
|
<div |
|
class="mb-2" |
|
ondragover={(e) => |
|
handleCardDragOver(e, column.id, cardIndex)} |
|
> |
|
<KanbanCardComponent |
|
{card} |
|
isDragging={draggedCard?.id === card.id} |
|
draggable={canEdit} |
|
ondragstart={(e) => handleDragStart(e, card)} |
|
onclick={() => onCardClick?.(card)} |
|
ondelete={canEdit |
|
? (id) => onDeleteCard?.(id) |
|
: undefined} |
|
/> |
|
</div> |
|
{/each} |
|
<!-- Drop indicator at end of column --> |
|
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length} |
|
<div |
|
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all" |
|
></div> |
|
{/if} |
|
</div> |
|
|
|
<!-- Add Card Button (secondary style) --> |
|
{#if canEdit} |
|
<button |
|
type="button" |
|
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors" |
|
onclick={() => onAddCard?.(column.id)} |
|
> |
|
Add card |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
|
|
<!-- Add Column Button --> |
|
{#if canEdit} |
|
<button |
|
type="button" |
|
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors" |
|
onclick={() => onAddColumn?.()} |
|
> |
|
<span |
|
class="material-symbols-rounded" |
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" |
|
> |
|
add |
|
</span> |
|
Add column |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.kanban-scroll { |
|
scrollbar-width: thin; |
|
scrollbar-color: rgba(229, 230, 240, 0.3) transparent; |
|
} |
|
.kanban-scroll::-webkit-scrollbar { |
|
height: 8px; |
|
} |
|
.kanban-scroll::-webkit-scrollbar-track { |
|
background: transparent; |
|
border-radius: 4px; |
|
} |
|
.kanban-scroll::-webkit-scrollbar-thumb { |
|
background: rgba(229, 230, 240, 0.3); |
|
border-radius: 4px; |
|
} |
|
.kanban-scroll::-webkit-scrollbar-thumb:hover { |
|
background: rgba(229, 230, 240, 0.5); |
|
} |
|
</style>
|
|
|