Private
Public Access
1
0
Files
Kanban/src/routes/boards/[boardId]/+page.svelte
T
2026-02-27 18:35:00 -05:00

919 lines
36 KiB
Svelte

<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { onMount } from 'svelte';
import { generatePosition } from '$lib/utils/fractional-index.js';
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
import CardModal from '$lib/components/CardModal.svelte';
import BoardSettingsModal from '$lib/components/BoardSettingsModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte';
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
import ColumnSortMenu from '$lib/components/ColumnSortMenu.svelte';
import Avatar from '$lib/components/Avatar.svelte';
import type { FilterState } from '$lib/components/BoardFilters.svelte';
let { data } = $props();
// Local mutable state derived from server data
let columns = $state(
data.board.columns.map((col: any) => ({
...col,
cards: col.cards.map((card: any) => ({ ...card, id: card.id }))
}))
);
let addingColumnTitle = $state('');
let showAddColumn = $state(false);
let addingCardColumnId = $state<string | null>(null);
let newCardTitle = $state('');
let selectedCard = $state<any>(null);
let editingColumnId = $state<string | null>(null);
let editingColumnTitle = $state('');
let viewingUsers = $state<any[]>([]);
let showActivity = $state(false);
let showSettings = $state(false);
let showArchivedCards = $state(false);
let archivedCards = $state<any[]>([]);
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
let selectedCardIds = $state(new Set<string>());
let selectMode = $derived(selectedCardIds.size > 0);
let columnSorts = $state<Record<string, string | null>>({});
const flipDurationMs = 200;
// ─── Filtering ──────────────────────────────────────
let hasActiveFilters = $derived(
activeFilters.labelIds.length > 0 || activeFilters.assigneeIds.length > 0 || activeFilters.due !== null
);
function filterCards(cards: any[]): any[] {
if (!hasActiveFilters) return cards;
return cards.filter((card: any) => {
// Label filter
if (activeFilters.labelIds.length > 0) {
const cardTagIds = (card.labels || []).map((l: any) => l.tag.id);
if (!activeFilters.labelIds.some((id) => cardTagIds.includes(id))) return false;
}
// Assignee filter
if (activeFilters.assigneeIds.length > 0) {
const cardUserIds = (card.assignees || []).map((a: any) => a.user.id);
if (!activeFilters.assigneeIds.some((id) => cardUserIds.includes(id))) return false;
}
// Due date filter
if (activeFilters.due) {
const now = new Date();
const soon = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
if (activeFilters.due === 'overdue') {
if (!card.dueDate || new Date(card.dueDate) >= now) return false;
} else if (activeFilters.due === 'due-soon') {
if (!card.dueDate) return false;
const d = new Date(card.dueDate);
if (d < now || d > soon) return false;
} else if (activeFilters.due === 'no-date') {
if (card.dueDate) return false;
}
}
return true;
});
}
// ─── Card Sorting ────────────────────────────────────
function sortCards(cards: any[], sort: string | null): any[] {
if (!sort) return cards;
const sorted = [...cards];
switch (sort) {
case 'due-date':
sorted.sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
break;
case 'created':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'title':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'assignee':
sorted.sort((a, b) => {
const aName = a.assignees?.[0]?.user?.name || '';
const bName = b.assignees?.[0]?.user?.name || '';
return aName.localeCompare(bName);
});
break;
}
return sorted;
}
// ─── Archived cards ─────────────────────────────────
async function toggleArchivedCards() {
showArchivedCards = !showArchivedCards;
if (showArchivedCards && archivedCards.length === 0) {
const { ok, data: result } = await api(`/api/boards/${data.board.id}/archived-cards`);
if (ok) {
archivedCards = result.cards;
}
}
}
function getArchivedCardsForColumn(columnId: string): any[] {
return archivedCards.filter((c: any) => c.columnId === columnId);
}
// ─── Real-Time Event Handlers ────────────────────
function isSelf(d: any) {
return d?.socketId && d.socketId === getSocketId();
}
function onColumnCreated(d: any) {
if (isSelf(d)) return;
if (!columns.find((c: any) => c.id === d.column.id)) {
columns = [...columns, { ...d.column, cards: d.column.cards ?? [] }];
}
}
function onColumnUpdated(d: any) {
if (isSelf(d)) return;
const col = columns.find((c: any) => c.id === d.column.id);
if (col) {
col.title = d.column.title;
col.position = d.column.position;
}
}
function onColumnDeleted(d: any) {
if (isSelf(d)) return;
columns = columns.filter((c: any) => c.id !== d.columnId);
}
function onCardCreated(d: any) {
if (isSelf(d)) return;
const col = columns.find((c: any) => c.id === d.columnId);
if (col && !col.cards.find((c: any) => c.id === d.card.id)) {
col.cards = [...col.cards, d.card];
}
}
async function onCardUpdated(d: any) {
if (isSelf(d)) return;
// Re-fetch card from API to get full updated data
const colId = d.columnId || d.card?.columnId;
const cardId = d.cardId || d.card?.id;
if (!colId || !cardId) return;
const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
if (!ok) return;
const freshCard = result.card;
// The card might have moved columns — remove from old location first
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === cardId);
if (idx !== -1) {
if (col.id === freshCard.columnId) {
// Same column — update in-place
col.cards[idx] = { ...col.cards[idx], ...freshCard };
} else {
// Moved to a different column — remove from old
col.cards = col.cards.filter((c: any) => c.id !== cardId);
// Add to new column
const newCol = columns.find((c: any) => c.id === freshCard.columnId);
if (newCol) newCol.cards = [...newCol.cards, freshCard];
}
break;
}
}
// Update selected card if it's the one being viewed
if (selectedCard?.id === cardId) {
selectedCard = { ...selectedCard, ...freshCard };
}
}
function onCardDeleted(d: any) {
if (isSelf(d)) return;
const col = columns.find((c: any) => c.id === d.columnId);
if (col) col.cards = col.cards.filter((c: any) => c.id !== d.cardId);
if (selectedCard?.id === d.cardId) selectedCard = null;
}
function onBoardUpdated(d: any) {
if (isSelf(d)) return;
if (d.board) {
data.board.title = d.board.title;
data.board.visibility = d.board.visibility;
if (d.board.description !== undefined) data.board.description = d.board.description;
if (d.board.background !== undefined) data.board.background = d.board.background;
if (d.board.categoryId !== undefined) data.board.categoryId = d.board.categoryId;
}
}
function onBoardDeleted(d: any) {
if (isSelf(d)) return;
window.location.href = '/boards';
}
function onPresenceUpdate(users: any[]) {
viewingUsers = users;
}
onMount(() => {
joinBoard(data.board.id);
onBoardEvent('column:created', onColumnCreated);
onBoardEvent('column:updated', onColumnUpdated);
onBoardEvent('column:deleted', onColumnDeleted);
onBoardEvent('card:created', onCardCreated);
onBoardEvent('card:updated', onCardUpdated);
onBoardEvent('card:deleted', onCardDeleted);
onBoardEvent('board:updated', onBoardUpdated);
onBoardEvent('board:deleted', onBoardDeleted);
onBoardEvent('presence:update', onPresenceUpdate);
// Check for ?card= param to auto-open a card
const cardParam = new URL(window.location.href).searchParams.get('card');
if (cardParam) {
for (const col of columns) {
const found = col.cards.find((c: any) => c.id === cardParam);
if (found) {
selectedCard = found;
break;
}
}
}
return () => {
leaveBoard(data.board.id);
offBoardEvent('column:created', onColumnCreated);
offBoardEvent('column:updated', onColumnUpdated);
offBoardEvent('column:deleted', onColumnDeleted);
offBoardEvent('card:created', onCardCreated);
offBoardEvent('card:updated', onCardUpdated);
offBoardEvent('card:deleted', onCardDeleted);
offBoardEvent('board:updated', onBoardUpdated);
offBoardEvent('board:deleted', onBoardDeleted);
offBoardEvent('presence:update', onPresenceUpdate);
};
});
function getChecklistProgress(card: any): { done: number; total: number } | null {
if (!card.checklists || card.checklists.length === 0) return null;
let done = 0, total = 0;
for (const cl of card.checklists) {
for (const item of cl.items) {
total++;
if (item.completed) done++;
}
}
return total > 0 ? { done, total } : null;
}
// ─── Column DnD ──────────────────────────────────
function handleColumnSort(e: CustomEvent<any>) {
columns = e.detail.items;
if (e.detail.info.trigger === 'droppedIntoZone') {
columns.forEach((col: any, idx: number) => {
const before = idx > 0 ? columns[idx - 1].position : null;
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
if (col.id === e.detail.info.id) {
const newPos = generatePosition(before, after);
col.position = newPos;
api(`/api/boards/${data.board.id}/columns/${col.id}`, {
method: 'PATCH',
body: JSON.stringify({ position: newPos })
});
}
});
}
}
// ─── Card DnD ────────────────────────────────────
function handleCardSort(columnId: string, e: CustomEvent<any>) {
const col = columns.find((c: any) => c.id === columnId);
if (!col) return;
col.cards = e.detail.items;
if (e.detail.info.trigger === 'droppedIntoZone') {
const cardId = e.detail.info.id;
const cardIdx = col.cards.findIndex((c: any) => c.id === cardId);
if (cardIdx === -1) return;
const before = cardIdx > 0 ? col.cards[cardIdx - 1].position : null;
const after = cardIdx < col.cards.length - 1 ? col.cards[cardIdx + 1].position : null;
const newPos = generatePosition(before, after);
col.cards[cardIdx].position = newPos;
api(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH',
body: JSON.stringify({ columnId, position: newPos })
});
}
}
// ─── Add Column ──────────────────────────────────
async function addColumn() {
if (!addingColumnTitle.trim()) return;
const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns`, {
method: 'POST',
body: JSON.stringify({ title: addingColumnTitle })
});
if (ok) {
columns = [...columns, { ...result.column, cards: [] }];
addingColumnTitle = '';
showAddColumn = false;
toast.success('Column created');
}
}
// ─── Add Card ────────────────────────────────────
async function addCard(columnId: string) {
if (!newCardTitle.trim()) return;
const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
method: 'POST',
body: JSON.stringify({ title: newCardTitle })
});
if (ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = [...col.cards, result.card];
newCardTitle = '';
addingCardColumnId = null;
toast.success('Card added');
}
}
// ─── Delete Column ───────────────────────────────
async function deleteColumn(columnId: string) {
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'DELETE'
});
if (ok) {
columns = columns.filter((c: any) => c.id !== columnId);
toast.success('Column deleted');
}
}
// ─── Rename Column ───────────────────────────────
async function renameColumn(columnId: string) {
if (!editingColumnTitle.trim()) return;
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'PATCH',
body: JSON.stringify({ title: editingColumnTitle })
});
if (ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.title = editingColumnTitle;
}
editingColumnId = null;
}
// ─── Delete Card ─────────────────────────────────
async function deleteCard(columnId: string, cardId: string) {
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'DELETE'
});
if (ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
if (selectedCard?.id === cardId) selectedCard = null;
toast.success('Card deleted');
}
}
// ─── Card updated from modal ─────────────────────
function handleCardUpdate(updatedCard: any) {
// Handle unarchive: add card back to column's active cards
if (updatedCard.archived === false) {
const wasArchived = archivedCards.find((c: any) => c.id === updatedCard.id);
if (wasArchived) {
archivedCards = archivedCards.filter((c: any) => c.id !== updatedCard.id);
const col = columns.find((c: any) => c.id === updatedCard.columnId);
if (col) {
col.cards = [...col.cards, updatedCard];
}
return;
}
}
// Handle archive: remove card from column's active cards
if (updatedCard.archived === true) {
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
col.cards = col.cards.filter((c: any) => c.id !== updatedCard.id);
archivedCards = [...archivedCards, updatedCard];
break;
}
}
return;
}
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
col.cards[idx] = { ...col.cards[idx], ...updatedCard };
break;
}
}
}
</script>
<svelte:head>
<title>{data.board.title}</title>
</svelte:head>
{#if showSettings}
<BoardSettingsModal
board={data.board}
categories={data.categories}
canDelete={data.canDelete}
onclose={() => (showSettings = false)}
onsave={(updated) => {
data.board.title = updated.title;
data.board.visibility = updated.visibility;
data.board.description = updated.description;
data.board.background = updated.background;
data.board.categoryId = updated.categoryId;
showSettings = false;
}}
/>
{/if}
{#if selectedCard}
<CardModal
card={selectedCard}
boardId={data.board.id}
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id ?? archivedCards.find((c: any) => c.id === selectedCard.id)?.columnId}
canEdit={data.canEdit}
boardMembers={data.board.members}
currentUserId={data.user?.id || ''}
cardPrefix={data.board.cardPrefix}
onclose={() => (selectedCard = null)}
ondelete={(cardId) => {
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
if (col) deleteCard(col.id, cardId);
}}
onupdate={handleCardUpdate}
oncopied={(newCard) => {
const col = columns.find((c: any) => c.id === newCard.columnId);
if (col) col.cards = [...col.cards, newCard];
toast.success('Card copied');
}}
/>
{/if}
<div class="sr-only" aria-live="polite" id="dnd-announcer"></div>
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Board header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200 dark:bg-gray-900/80 dark:border-gray-700 flex-wrap">
<a href="/boards" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition" aria-label="Back to boards">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<span class="px-3 py-1 text-xs rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Board
</span>
<a
href="/boards/{data.board.id}/calendar"
class="px-3 py-1 text-xs rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Calendar
</a>
</div>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span>
<a
href="/boards/{data.board.id}/members"
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition"
>
{data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
</a>
{#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0}
<div class="ml-auto flex items-center gap-1.5">
<span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span>
<div class="flex -space-x-1.5">
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
<Avatar name={viewer.name} avatarUrl={viewer.avatarUrl} size="sm" class="border-2 border-white dark:border-gray-900" title={viewer.name} />
{/each}
</div>
</div>
{/if}
<button
onclick={() => toggleArchivedCards()}
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
class:bg-gray-200={showArchivedCards}
title="Toggle archived cards"
aria-label="Toggle archived cards"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 3a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1V4a1 1 0 00-1-1H2z" />
<path fill-rule="evenodd" d="M2 7.5h16l-.811 7.71a2 2 0 01-1.99 1.79H4.802a2 2 0 01-1.99-1.79L2 7.5zM7 11a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
Archived
</button>
<a
href="/api/boards/{data.board.id}/export"
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
title="Export board"
aria-label="Export board"
download
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Export
</a>
<button
onclick={() => (showActivity = !showActivity)}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
class:bg-gray-200={showActivity}
title="Toggle activity feed"
aria-label="Toggle activity feed"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
Activity
</button>
{#if data.canEdit}
<button
onclick={() => (showSettings = true)}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
title="Board settings"
aria-label="Board settings"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Settings
</button>
{/if}
</div>
<!-- Filters bar -->
<div class="px-4 py-2 bg-white/60 border-b border-gray-100 dark:bg-gray-900/60 dark:border-gray-800">
<BoardFilters
{columns}
members={data.board.members}
onfilter={(f) => (activeFilters = f)}
/>
</div>
<!-- Main area: columns + optional activity sidebar -->
<div class="flex-1 flex overflow-hidden">
<!-- Columns container -->
<div class="flex-1 overflow-x-auto p-4">
<div
class="flex gap-4 h-full items-start"
use:dndzone={{
items: columns,
flipDurationMs,
type: 'columns',
dragDisabled: !data.canEdit
}}
onconsider={handleColumnSort}
onfinalize={handleColumnSort}
>
{#each columns as column (column.id)}
{@const colSort = columnSorts[column.id] ?? null}
{@const visibleCards = sortCards(filterCards(column.cards), colSort)}
<div
class="flex-shrink-0 w-64 sm:w-72 rounded-xl flex flex-col max-h-full"
style="background: var(--color-column-bg); border: 1px solid var(--color-column-border); color: var(--color-column-text); backdrop-filter: var(--column-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }}
>
<!-- Column header -->
<div class="flex items-center justify-between px-3 py-2.5 cursor-grab active:cursor-grabbing">
{#if data.canEdit && editingColumnId !== column.id}
<div class="flex-shrink-0 text-gray-300 dark:text-gray-600 mr-1" aria-hidden="true">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />
</svg>
</div>
{/if}
{#if editingColumnId === column.id}
<form
onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }}
class="flex-1 flex gap-1"
>
<input
bind:value={editingColumnTitle}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm font-semibold"
autofocus
onblur={() => renameColumn(column.id)}
/>
</form>
{:else}
<button
class="text-sm font-semibold text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100 text-left flex-1"
ondblclick={() => {
if (data.canEdit) {
editingColumnId = column.id;
editingColumnTitle = column.title;
}
}}
>
{column.title}
<span class="ml-1 text-xs text-gray-400 dark:text-gray-500 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span>
</button>
{/if}
<ColumnSortMenu
currentSort={colSort}
onsort={(sort) => (columnSorts[column.id] = sort)}
/>
{#if data.canEdit}
<button
onclick={() => deleteColumn(column.id)}
class="text-gray-400 dark:text-gray-500 hover:text-[var(--color-danger)] transition p-1"
title="Delete column"
aria-label="Delete column"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<!-- Cards zone -->
<div
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
use:dndzone={{
items: visibleCards,
flipDurationMs,
type: 'cards',
dragDisabled: !data.canEdit || hasActiveFilters || selectMode || !!colSort
}}
onconsider={(e) => handleCardSort(column.id, e)}
onfinalize={(e) => handleCardSort(column.id, e)}
>
{#each visibleCards as card (card.id)}
{@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)}
<div
class="group/card relative w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer {selectedCardIds.has(card.id) ? 'ring-2 ring-[var(--color-primary)]' : ''}"
style="background: var(--color-card-bg); border: 1px solid var(--color-card-border); color: var(--color-card-text); border-radius: var(--color-card-radius); backdrop-filter: var(--card-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }}
onclick={() => {
if (selectMode) {
const next = new Set(selectedCardIds);
if (next.has(card.id)) next.delete(card.id);
else next.add(card.id);
selectedCardIds = next;
} else {
selectedCard = card;
}
}}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectedCard = card; } }}
>
{#if data.canEdit}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1.5 right-1.5 z-10 {selectedCardIds.has(card.id) ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100'} transition-opacity"
onclick={(e) => {
e.stopPropagation();
const next = new Set(selectedCardIds);
if (next.has(card.id)) next.delete(card.id);
else next.add(card.id);
selectedCardIds = next;
}}
>
<div class="w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer {selectedCardIds.has(card.id) ? 'bg-[var(--color-primary)] border-[var(--color-primary)]' : 'border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-700 hover:border-[var(--color-primary)]'} transition">
{#if selectedCardIds.has(card.id)}
<svg class="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{/if}
</div>
</div>
{/if}
{#if card.labels && card.labels.length > 0}
<div class="flex flex-wrap gap-1 mb-1.5">
{#each card.labels as label}
<span
class="rounded px-1.5 py-0.5 text-[10px] text-white leading-none"
style="background-color: {label.tag.color}"
>
{label.tag.name}
</span>
{/each}
</div>
{/if}
{#if data.board.cardPrefix && card.cardNumber}
<span class="text-[10px] font-mono text-gray-400 dark:text-gray-500">{data.board.cardPrefix}-{card.cardNumber}</span>
{/if}
<span class="text-sm text-gray-800 dark:text-gray-200">{card.title}</span>
{#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap">
{#if card.dueDate}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}">
{formatDueDate(card.dueDate)}
</span>
{/if}
{#if progress}
<span class="inline-flex items-center gap-1 {progress.done === progress.total ? 'text-[var(--color-success)]' : ''}">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
{progress.done}/{progress.total}
</span>
{/if}
{#if card._count?.comments > 0}
<span class="inline-flex items-center gap-0.5">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v6a2 2 0 01-2 2H7.414l-3.207 3.207A1 1 0 012 15.5V5z" clip-rule="evenodd" />
</svg>
{card._count.comments}
</span>
{/if}
{#if card._count?.attachments > 0}
<span class="inline-flex items-center gap-0.5">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" clip-rule="evenodd" />
</svg>
{card._count.attachments}
</span>
{/if}
</div>
{/if}
{#if card.assignees && card.assignees.length > 0}
<div class="flex -space-x-1 mt-2">
{#each card.assignees.slice(0, 3) as assignee}
<Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" class="border border-white" title={assignee.user.name} />
{/each}
{#if card.assignees.length > 3}
<div
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white dark:bg-gray-600 dark:text-gray-300 dark:border-gray-700"
>
+{card.assignees.length - 3}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<!-- Archived cards for this column -->
{#if showArchivedCards}
{@const archivedInCol = getArchivedCardsForColumn(column.id)}
{#if archivedInCol.length > 0}
<div class="px-2 pb-2">
<div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1 px-1">Archived</div>
{#each archivedInCol as card}
<button
onclick={() => (selectedCard = card)}
class="w-full text-left mb-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 p-2.5 border border-gray-200 dark:border-gray-700 opacity-60 hover:opacity-80 transition cursor-pointer"
>
<span class="text-sm text-gray-500 dark:text-gray-400">{card.title}</span>
</button>
{/each}
</div>
{/if}
{/if}
<!-- Add card button -->
{#if data.canEdit}
{#if addingCardColumnId === column.id}
<div class="px-2 pb-2">
<form
onsubmit={(e) => { e.preventDefault(); addCard(column.id); }}
>
<textarea
bind:value={newCardTitle}
placeholder="Enter a title for this card..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 p-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
rows="2"
autofocus
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addCard(column.id);
}
if (e.key === 'Escape') {
addingCardColumnId = null;
newCardTitle = '';
}
}}
></textarea>
<div class="flex gap-2 mt-1">
<button
type="submit"
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
>
Add card
</button>
<button
type="button"
onclick={() => { addingCardColumnId = null; newCardTitle = ''; }}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition text-sm"
>
Cancel
</button>
</div>
</form>
</div>
{:else}
<button
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }}
class="mx-2 mb-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-200/50 dark:text-gray-400 dark:hover:bg-gray-600/50 transition text-left"
>
+ Add a card
</button>
{/if}
{/if}
</div>
{/each}
<!-- Add column -->
{#if data.canEdit}
<div class="flex-shrink-0 w-72">
{#if showAddColumn}
<div class="rounded-xl p-3" style="background: var(--color-column-bg);">
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
<input
bind:value={addingColumnTitle}
placeholder="Enter list title..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
autofocus
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
/>
<div class="flex gap-2 mt-2">
<button
type="submit"
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
>
Add list
</button>
<button
type="button"
onclick={() => { showAddColumn = false; addingColumnTitle = ''; }}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition text-sm"
>
Cancel
</button>
</div>
</form>
</div>
{:else}
<button
onclick={() => (showAddColumn = true)}
class="w-full rounded-xl bg-white/60 hover:bg-white/80 dark:bg-gray-800/60 dark:hover:bg-gray-800/80 border border-dashed border-gray-300 dark:border-gray-600 px-4 py-3 text-sm text-gray-500 dark:text-gray-400 transition text-left"
>
+ Add another list
</button>
{/if}
</div>
{/if}
</div>
</div>
<!-- Activity sidebar -->
{#if showActivity}
<div class="fixed inset-0 z-40 bg-white dark:bg-gray-900 overflow-y-auto p-4 sm:relative sm:inset-auto sm:z-auto sm:w-80 sm:flex-shrink-0 sm:border-l sm:border-gray-200 sm:dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Activity</h2>
<button onclick={() => (showActivity = false)} class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition p-1" aria-label="Close activity">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ActivityFeed boardId={data.board.id} />
</div>
{/if}
</div>
</div>
{#if selectMode && selectedCardIds.size > 0}
<BulkToolbar
boardId={data.board.id}
{selectedCardIds}
{columns}
onaction={() => {
selectedCardIds = new Set();
window.location.reload();
}}
oncancel={() => {
selectedCardIds = new Set();
}}
/>
{/if}