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.
 
 
 
 
 

369 lines
9.3 KiB

<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createBoard,
moveCard,
} from "$lib/api/kanban";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
} from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
boards: KanbanBoardType[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let boards = $state(data.boards);
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
let showCardDetailModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
let editBoardName = $state("");
let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit");
async function loadBoard(boardId: string) {
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
async function handleCreateBoard() {
if (!newBoardName.trim()) return;
const newBoard = await createBoard(supabase, data.org.id, newBoardName);
boards = [...boards, newBoard];
await loadBoard(newBoard.id);
showCreateBoardModal = false;
newBoardName = "";
}
let editingBoardId = $state<string | null>(null);
function openEditBoardModal(board: KanbanBoardType) {
editingBoardId = board.id;
editBoardName = board.name;
showEditBoardModal = true;
}
async function handleEditBoard() {
if (!editingBoardId || !editBoardName.trim()) return;
const { error } = await supabase
.from("kanban_boards")
.update({ name: editBoardName })
.eq("id", editingBoardId);
if (!error) {
if (selectedBoard?.id === editingBoardId) {
selectedBoard = { ...selectedBoard, name: editBoardName };
}
boards = boards.map((b) =>
b.id === editingBoardId ? { ...b, name: editBoardName } : b,
);
}
showEditBoardModal = false;
editingBoardId = null;
}
async function handleDeleteBoard(e: MouseEvent, board: KanbanBoardType) {
e.stopPropagation();
if (!confirm(`Delete "${board.name}" and all its cards?`)) return;
const { error } = await supabase
.from("kanban_boards")
.delete()
.eq("id", board.id);
if (!error) {
boards = boards.filter((b) => b.id !== board.id);
if (selectedBoard?.id === board.id) {
selectedBoard = null;
}
}
}
async function handleAddCard(columnId: string) {
targetColumnId = columnId;
selectedCard = null;
cardModalMode = "create";
showCardDetailModal = true;
}
function handleCardCreated(newCard: KanbanCard) {
if (!selectedBoard) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) =>
col.id === newCard.column_id
? { ...col, cards: [...col.cards, newCard] }
: col,
),
};
targetColumnId = null;
}
async function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
if (!selectedBoard) return;
// Optimistic UI update - move card immediately
const fromColumn = selectedBoard.columns.find((c) =>
c.cards.some((card) => card.id === cardId),
);
const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId);
if (!fromColumn || !toColumn) return;
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
if (cardIndex === -1) return;
const [movedCard] = fromColumn.cards.splice(cardIndex, 1);
movedCard.column_id = toColumnId;
toColumn.cards.splice(toPosition, 0, movedCard);
// Trigger reactivity
selectedBoard = { ...selectedBoard };
// Persist to database in background
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
console.error("Failed to persist card move:", err);
// Reload to sync state on error
loadBoard(selectedBoard!.id);
});
}
function handleCardClick(card: KanbanCard) {
selectedCard = card;
cardModalMode = "edit";
showCardDetailModal = true;
}
function handleCardUpdate(updatedCard: KanbanCard) {
if (!selectedBoard) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => ({
...col,
cards: col.cards.map((c) =>
c.id === updatedCard.id ? updatedCard : c,
),
})),
};
selectedCard = updatedCard;
}
async function handleCardDelete(cardId: string) {
if (!selectedBoard) return;
await loadBoard(selectedBoard.id);
}
</script>
<svelte:head>
<title
>{selectedBoard ? `${selectedBoard.name} - ` : ""}Kanban - {data.org
.name} | Root</title
>
</svelte:head>
<div class="flex h-full">
<aside class="w-64 border-r border-light/10 flex flex-col">
<div
class="p-4 border-b border-light/10 flex items-center justify-between"
>
<h2 class="font-semibold text-light">Boards</h2>
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if boards.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No boards yet</p>
</div>
{:else}
{#each boards as board}
<div
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
board.id
? 'bg-primary text-white'
: 'text-light/70 hover:bg-light/5'}"
onclick={() => loadBoard(board.id)}
role="button"
tabindex="0"
>
<span class="flex-1 truncate">{board.name}</span>
<div
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
>
<button
class="p-1 rounded hover:bg-light/20"
onclick={(e) => {
e.stopPropagation();
openEditBoardModal(board);
}}
title="Rename"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
</button>
<button
class="p-1 rounded hover:bg-error/20 hover:text-error"
onclick={(e) => handleDeleteBoard(e, board)}
title="Delete"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3,6 5,6 21,6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
</div>
</div>
{/each}
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden p-6">
{#if selectedBoard}
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">
{selectedBoard.name}
</h1>
</header>
<KanbanBoard
columns={selectedBoard.columns}
onCardClick={handleCardClick}
onCardMove={handleCardMove}
onAddCard={handleAddCard}
/>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
<p>Select a board or create a new one</p>
</div>
</div>
{/if}
</main>
</div>
<Modal
isOpen={showCreateBoardModal}
onClose={() => (showCreateBoardModal = false)}
title="Create Board"
>
<div class="space-y-4">
<Input
label="Board Name"
bind:value={newBoardName}
placeholder="e.g. Sprint 1"
/>
<div class="flex justify-end gap-2">
<Button
variant="ghost"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<Modal
isOpen={showEditBoardModal}
onClose={() => (showEditBoardModal = false)}
title="Edit Board"
>
<div class="space-y-4">
<Input
label="Board Name"
bind:value={editBoardName}
placeholder="Board name"
/>
<div class="flex justify-end gap-2">
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
>Cancel</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
>
</div>
</div>
</Modal>
<CardDetailModal
card={selectedCard}
isOpen={showCardDetailModal}
onClose={() => {
showCardDetailModal = false;
selectedCard = null;
targetColumnId = null;
}}
onUpdate={handleCardUpdate}
onDelete={handleCardDelete}
mode={cardModalMode}
columnId={targetColumnId ?? undefined}
userId={data.user?.id}
onCreate={handleCardCreated}
/>