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