0e77c50411
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
919 lines
36 KiB
Svelte
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}
|