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

<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>