From 7559102479c02ff8058848526e505a5b4352881e Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:09:17 -0500 Subject: [PATCH] =?UTF-8?q?Phase=208:=20Polish=20&=20UX=20=E2=80=94=20dark?= =?UTF-8?q?=20mode,=20toasts,=20keyboard=20shortcuts,=20responsive,=20acce?= =?UTF-8?q?ssibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dark mode with theme toggle, localStorage/cookie persistence, anti-FOUC script - Add toast notification system with auto-dismiss and color-coded types - Add api() fetch wrapper with auto JSON, X-Socket-ID, and error toasts - Add keyboard shortcuts (/ search, b boards, ? help, n new card, Esc close) - Add error page with status code and navigation - Add focus trap utility and apply to CardModal with full ARIA dialog support - Add hamburger menu for mobile, responsive columns, fullscreen card modal on mobile - Add aria-labels to icon-only buttons and DnD live region - Migrate all ~30 fetch calls to api() wrapper with success toasts - Add dark: Tailwind variants to all 20+ pages and components Co-Authored-By: Claude Opus 4.6 --- src/app.css | 27 ++++ src/app.d.ts | 1 + src/app.html | 7 + src/hooks.server.ts | 3 + src/lib/components/ActivityFeed.svelte | 21 +-- src/lib/components/AssigneePicker.svelte | 30 ++-- src/lib/components/AttachmentList.svelte | 43 +++--- src/lib/components/BoardFilters.svelte | 14 +- src/lib/components/CardModal.svelte | 49 ++++--- src/lib/components/ChecklistSection.svelte | 53 ++++--- src/lib/components/CommentList.svelte | 37 +++-- src/lib/components/DueDatePicker.svelte | 22 +-- src/lib/components/KeyboardShortcuts.svelte | 89 ++++++++++++ src/lib/components/MarkdownEditor.svelte | 20 +-- src/lib/components/NotificationBell.svelte | 30 ++-- src/lib/components/SearchBar.svelte | 25 ++-- src/lib/components/Skeleton.svelte | 5 + src/lib/components/TagPicker.svelte | 43 +++--- src/lib/components/Toast.svelte | 55 ++++++++ src/lib/stores/theme.ts | 28 ++++ src/lib/stores/toasts.ts | 35 +++++ src/lib/utils/api.ts | 47 +++++++ src/lib/utils/focus-trap.ts | 42 ++++++ src/routes/+error.svelte | 38 +++++ src/routes/+layout.server.ts | 3 +- src/routes/+layout.svelte | 128 ++++++++++++++++- src/routes/+page.svelte | 6 +- src/routes/admin/+page.svelte | 38 ++--- src/routes/auth/login/+page.svelte | 14 +- src/routes/auth/register/+page.svelte | 18 +-- src/routes/boards/+page.svelte | 95 ++++++------- src/routes/boards/[boardId]/+page.svelte | 131 +++++++++--------- .../boards/[boardId]/members/+page.svelte | 27 ++-- 33 files changed, 850 insertions(+), 374 deletions(-) create mode 100644 src/lib/components/KeyboardShortcuts.svelte create mode 100644 src/lib/components/Skeleton.svelte create mode 100644 src/lib/components/Toast.svelte create mode 100644 src/lib/stores/theme.ts create mode 100644 src/lib/stores/toasts.ts create mode 100644 src/lib/utils/api.ts create mode 100644 src/lib/utils/focus-trap.ts create mode 100644 src/routes/+error.svelte diff --git a/src/app.css b/src/app.css index 6f578a3..faed7cf 100644 --- a/src/app.css +++ b/src/app.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; +@custom-variant dark (&:where(.dark, .dark *)); + @theme { --color-board-bg: #f1f2f4; --color-column-bg: #ebecf0; @@ -72,3 +74,28 @@ max-width: 100%; border-radius: 4px; } + +/* Dark mode overrides */ +html.dark { + --color-board-bg: #111827; + --color-column-bg: #1f2937; + --color-card-bg: #374151; + --color-primary: #3b82f6; + --color-primary-hover: #2563eb; + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-success: #22c55e; + --color-warning: #eab308; +} + +.dark .prose code { + background: #374151; + color: #e5e7eb; +} +.dark .prose blockquote { + border-left-color: #4b5563; + color: #9ca3af; +} +.dark .prose a { + color: var(--color-primary); +} diff --git a/src/app.d.ts b/src/app.d.ts index fb77b45..4381197 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -23,6 +23,7 @@ declare global { session: { id: string; } | null; + theme?: string; } // interface PageData {} // interface PageState {} diff --git a/src/app.html b/src/app.html index c8050c1..6045f27 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,13 @@ + %sveltekit.head% diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0fb4c15..96b7e7b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -42,5 +42,8 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.session = null; } + // 3. Read theme preference + event.locals.theme = event.cookies.get('theme') || undefined; + return resolve(event); }; diff --git a/src/lib/components/ActivityFeed.svelte b/src/lib/components/ActivityFeed.svelte index 6ccb4ab..eda39a3 100644 --- a/src/lib/components/ActivityFeed.svelte +++ b/src/lib/components/ActivityFeed.svelte @@ -1,6 +1,7 @@
-

Assignees

+

Assignees

{#each currentAssignees as assignee} -
+
{assignee.user.name[0].toUpperCase()}
- {assignee.user.name} + {assignee.user.name} {#if canEdit} {#if showDropdown}
(showDropdown = false)}>
-
+
{#each boardMembers as member}
{:else} -

No attachments

+

No attachments

{/if}
diff --git a/src/lib/components/BoardFilters.svelte b/src/lib/components/BoardFilters.svelte index 990d326..26bd4d1 100644 --- a/src/lib/components/BoardFilters.svelte +++ b/src/lib/components/BoardFilters.svelte @@ -102,7 +102,7 @@
{#if hasFilters} - + {/if} {#if expanded} {#if allTags().length > 0}
- Labels: + Labels: {#each allTags() as tag} @@ -153,11 +153,11 @@
- Due: + Due: {#each [{ val: 'overdue', label: 'Overdue' }, { val: 'due-soon', label: 'Due soon' }, { val: 'no-date', label: 'No date' }] as opt} diff --git a/src/lib/components/CardModal.svelte b/src/lib/components/CardModal.svelte index c30bc7f..b4bbd8a 100644 --- a/src/lib/components/CardModal.svelte +++ b/src/lib/components/CardModal.svelte @@ -1,5 +1,6 @@
-

Checklists

+

Checklists

{#each checklists as cl} {@const p = progress(cl)}
- {cl.title} + {cl.title}
- + {p.done}/{p.total} {#if canEdit} @@ -125,7 +122,7 @@ {#if p.total > 0} -
+
toggleItem(cl.id, item)} - class="rounded border-gray-300 text-[var(--color-primary)]" + class="rounded border-gray-300 dark:border-gray-600 text-[var(--color-primary)]" /> {:else} {/if} - + {item.title} {#if canEdit} @@ -178,12 +175,12 @@ @@ -198,7 +195,7 @@ @@ -219,7 +216,7 @@ {:else} diff --git a/src/lib/components/CommentList.svelte b/src/lib/components/CommentList.svelte index c026b91..159dfac 100644 --- a/src/lib/components/CommentList.svelte +++ b/src/lib/components/CommentList.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js'; import { formatDistanceToNow } from 'date-fns'; + import { api } from '$lib/utils/api.js'; type Comment = { id: string; @@ -44,13 +45,11 @@ async function addComment() { if (!newContent.trim()) return; submitting = true; - const res = await fetch(basePath, { + const { ok, data } = await api(basePath, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: newContent.trim() }) }); - if (res.ok) { - const data = await res.json(); + if (ok) { localComments = [data.comment, ...localComments]; newContent = ''; } @@ -59,21 +58,19 @@ async function updateComment(commentId: string) { if (!editContent.trim()) return; - const res = await fetch(`${basePath}/${commentId}`, { + const { ok, data } = await api(`${basePath}/${commentId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editContent.trim() }) }); - if (res.ok) { - const data = await res.json(); + if (ok) { localComments = localComments.map((c) => (c.id === commentId ? data.comment : c)); editingId = null; } } async function deleteComment(commentId: string) { - const res = await fetch(`${basePath}/${commentId}`, { method: 'DELETE' }); - if (res.ok) { + const { ok } = await api(`${basePath}/${commentId}`, { method: 'DELETE' }); + if (ok) { localComments = localComments.filter((c) => c.id !== commentId); } } @@ -85,7 +82,7 @@
-

Comments

+

Comments

{#if canEdit} @@ -94,7 +91,7 @@ bind:value={newContent} rows="3" placeholder="Write a comment... (Markdown supported)" - class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" + class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" >
{:else} -
+
{#if ready} {@html renderMarkdown(comment.content)} {:else} @@ -156,7 +153,7 @@ {#if comment.userId === currentUserId} @@ -164,7 +161,7 @@ {#if comment.userId === currentUserId || canEdit} @@ -177,6 +174,6 @@
{#if localComments.length === 0} -

No comments yet

+

No comments yet

{/if}
diff --git a/src/lib/components/DueDatePicker.svelte b/src/lib/components/DueDatePicker.svelte index 12cc640..76f3264 100644 --- a/src/lib/components/DueDatePicker.svelte +++ b/src/lib/components/DueDatePicker.svelte @@ -1,5 +1,6 @@
-

Due Date

+

Due Date

{#if picking && canEdit}
setDate(e.currentTarget.value)} - class="rounded border border-gray-300 px-2 py-1 text-sm" + class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm" disabled={saving} /> @@ -66,8 +66,9 @@ {#if canEdit}
diff --git a/src/lib/components/KeyboardShortcuts.svelte b/src/lib/components/KeyboardShortcuts.svelte new file mode 100644 index 0000000..2338add --- /dev/null +++ b/src/lib/components/KeyboardShortcuts.svelte @@ -0,0 +1,89 @@ + + + + +{#if showHelp} + +
{ if (e.target === e.currentTarget) showHelp = false; }} + > +
+
+

Keyboard Shortcuts

+ +
+
+ {#each shortcuts as s} +
+ {s.desc} + {s.key} +
+ {/each} +
+
+
+{/if} diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte index fd6a876..0252fde 100644 --- a/src/lib/components/MarkdownEditor.svelte +++ b/src/lib/components/MarkdownEditor.svelte @@ -42,31 +42,31 @@
{#if previewing} -
+
{#if ready} {@html renderMarkdown(draft)} {:else} -

{draft}

+

{draft}

{/if}
{:else} {/if} @@ -79,7 +79,7 @@ @@ -88,7 +88,7 @@ {:else}
{ if (!readonly) editing = true; }} > {#if value} @@ -96,13 +96,13 @@ {#if ready} {@html renderMarkdown(value)} {:else} -

{value}

+

{value}

{/if}
{:else if !readonly} -

Add a more detailed description...

+

Add a more detailed description...

{:else} -

No description

+

No description

{/if}
{/if} diff --git a/src/lib/components/NotificationBell.svelte b/src/lib/components/NotificationBell.svelte index e9db29d..3629124 100644 --- a/src/lib/components/NotificationBell.svelte +++ b/src/lib/components/NotificationBell.svelte @@ -1,6 +1,7 @@ + +
diff --git a/src/lib/components/TagPicker.svelte b/src/lib/components/TagPicker.svelte index 2e6555f..07a27fc 100644 --- a/src/lib/components/TagPicker.svelte +++ b/src/lib/components/TagPicker.svelte @@ -1,4 +1,6 @@
-

Labels

+

Labels

{#each currentLabels as label} @@ -127,15 +122,15 @@ class="fixed inset-0 z-10" onclick={() => (showDropdown = false)} >
-
+
{#if loading} -

Loading...

+

Loading...

{:else}
{#each allTags as tag}
-

Create tag

+

Create tag

{#each presetColors as color} +
+ {/each} +
diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts new file mode 100644 index 0000000..059edd4 --- /dev/null +++ b/src/lib/stores/theme.ts @@ -0,0 +1,28 @@ +import { browser } from '$app/environment'; + +let currentTheme = $state<'light' | 'dark'>('light'); + +export function getTheme(): 'light' | 'dark' { + return currentTheme; +} + +export function setTheme(theme: 'light' | 'dark') { + currentTheme = theme; + if (browser) { + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('theme', theme); + document.cookie = `theme=${theme};path=/;max-age=31536000;SameSite=Lax`; + } +} + +export function toggleTheme() { + setTheme(currentTheme === 'dark' ? 'light' : 'dark'); +} + +export function initTheme(serverTheme?: string) { + const theme = serverTheme || (browser ? localStorage.getItem('theme') : null) || 'light'; + currentTheme = theme === 'dark' ? 'dark' : 'light'; + if (browser) { + document.documentElement.classList.toggle('dark', currentTheme === 'dark'); + } +} diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts new file mode 100644 index 0000000..2153618 --- /dev/null +++ b/src/lib/stores/toasts.ts @@ -0,0 +1,35 @@ +let nextId = 0; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export type Toast = { + id: number; + type: ToastType; + message: string; +}; + +let toasts = $state([]); + +export function getToasts(): Toast[] { + return toasts; +} + +export function addToast(type: ToastType, message: string, duration = 4000): number { + const id = nextId++; + toasts.push({ id, type, message }); + if (duration > 0) { + setTimeout(() => removeToast(id), duration); + } + return id; +} + +export function removeToast(id: number) { + toasts = toasts.filter((t) => t.id !== id); +} + +export const toast = { + success: (msg: string, duration?: number) => addToast('success', msg, duration), + error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 6000), + warning: (msg: string, duration?: number) => addToast('warning', msg, duration), + info: (msg: string, duration?: number) => addToast('info', msg, duration) +}; diff --git a/src/lib/utils/api.ts b/src/lib/utils/api.ts new file mode 100644 index 0000000..6df6bce --- /dev/null +++ b/src/lib/utils/api.ts @@ -0,0 +1,47 @@ +import { toast } from '$lib/stores/toasts.js'; +import { getSocketId } from '$lib/stores/socket.js'; + +export type ApiResult = { + ok: boolean; + data: T; + status: number; +}; + +export async function api( + url: string, + opts: RequestInit = {} +): Promise> { + const headers = new Headers(opts.headers); + + // Auto-set JSON content type for non-FormData bodies + if (opts.body && !(opts.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + // Pass socket ID for self-dedup + const sid = getSocketId(); + if (sid) { + headers.set('X-Socket-ID', sid); + } + + try { + const res = await fetch(url, { ...opts, headers }); + let data: any = null; + + const contentType = res.headers.get('content-type'); + if (contentType?.includes('application/json')) { + data = await res.json(); + } + + if (!res.ok) { + const msg = data?.error || data?.message || `Request failed (${res.status})`; + toast.error(msg); + return { ok: false, data, status: res.status }; + } + + return { ok: true, data, status: res.status }; + } catch { + toast.error('Network error. Please try again.'); + return { ok: false, data: null as any, status: 0 }; + } +} diff --git a/src/lib/utils/focus-trap.ts b/src/lib/utils/focus-trap.ts new file mode 100644 index 0000000..5ef5e55 --- /dev/null +++ b/src/lib/utils/focus-trap.ts @@ -0,0 +1,42 @@ +const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function trapFocus(node: HTMLElement) { + // Focus first focusable element + const focusFirst = () => { + const first = node.querySelector(FOCUSABLE); + first?.focus(); + }; + + // Small delay to allow DOM to settle + requestAnimationFrame(focusFirst); + + function handleKeydown(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + + const focusable = Array.from(node.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first || !node.contains(document.activeElement)) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last || !node.contains(document.activeElement)) { + e.preventDefault(); + first.focus(); + } + } + } + + node.addEventListener('keydown', handleKeydown); + + return { + destroy() { + node.removeEventListener('keydown', handleKeydown); + } + }; +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..64ef41a --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,38 @@ + + + + Error {$page.status} + + +
+
+

{$page.status}

+

+ {#if $page.status === 404} + The page you're looking for doesn't exist. + {:else if $page.status === 403} + You don't have permission to access this page. + {:else if $page.status === 500} + Something went wrong on our end. + {:else} + {$page.error?.message || 'An unexpected error occurred.'} + {/if} +

+
+ + Go home + + +
+
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 90f9fb2..4b94f2b 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types.js'; export const load: LayoutServerLoad = async ({ locals }) => { return { user: locals.user, - tenant: locals.tenant + tenant: locals.tenant, + theme: locals.theme }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 407f86f..3de3d46 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,12 +3,19 @@ import type { Snippet } from 'svelte'; import { onMount } from 'svelte'; import { connectSocket, disconnectSocket } from '$lib/stores/socket.js'; + import { initTheme, toggleTheme, getTheme } from '$lib/stores/theme.js'; import NotificationBell from '$lib/components/NotificationBell.svelte'; import SearchBar from '$lib/components/SearchBar.svelte'; + import Toast from '$lib/components/Toast.svelte'; + import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte'; - let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props(); + let { children, data }: { children: Snippet; data: { user: any; tenant: any; theme?: string } } = $props(); + + let mobileMenuOpen = $state(false); + const theme = $derived(getTheme()); onMount(() => { + initTheme(data.theme); if (data.user) { connectSocket(); return () => disconnectSocket(); @@ -16,7 +23,7 @@ }); -
+
{@render children()}
+ + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 24ca2d0..2b43821 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,8 +7,8 @@
-

Pounce on your tasks

-

+

Pounce on your tasks

+

An open source kanban board for your team. Create boards, organize tasks, and track progress in real-time.

@@ -20,7 +20,7 @@ Browse Boards diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 44a3d6e..890ad64 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -30,42 +30,42 @@
-

Workspace Admin

+

Workspace Admin

-

Users

-
+

Users

+
- + - + {#each users as user (user.id)} - - + - + - + {/each} @@ -75,27 +75,27 @@
-

Boards

-
+

Boards

+
Name Email Role Joined
+
{user.name} {#if user.globalRole === 'SITE_ADMIN'} SITE ADMIN {/if} {user.email}{user.email} {formatDate(user.createdAt)}{formatDate(user.createdAt)}
- + - + {#each data.boards as board (board.id)} - - + + - + + {/if} diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index 7f90b68..5d80bc6 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -39,35 +39,35 @@
-

Log in

+

Log in

{#if error} -
+
{error}
{/if}
- +
- +
@@ -81,7 +81,7 @@ -

+

Don't have an account? Register

diff --git a/src/routes/auth/register/+page.svelte b/src/routes/auth/register/+page.svelte index 55262f3..50c1913 100644 --- a/src/routes/auth/register/+page.svelte +++ b/src/routes/auth/register/+page.svelte @@ -40,48 +40,48 @@
-

Create an account

+

Create an account

{#if error} -
+
{error}
{/if}
- +
- +
- +
@@ -95,7 +95,7 @@ -

+

Already have an account? Log in

diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index b72c030..25a53c1 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -2,6 +2,8 @@ import { invalidateAll } from '$app/navigation'; import { page } from '$app/stores'; import { replaceState } from '$app/navigation'; + import { api } from '$lib/utils/api.js'; + import { toast } from '$lib/stores/toasts.js'; let { data } = $props(); @@ -39,32 +41,27 @@ creating = true; error = ''; - try { - const res = await fetch('/api/boards', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: newTitle, - visibility: newVisibility, - ...(newCategoryId ? { categoryId: newCategoryId } : {}), - ...(newTemplateId ? { templateId: newTemplateId } : {}) - }) - }); - const result = await res.json(); - if (!res.ok) { - error = result.error || 'Failed to create board'; - return; - } - newTitle = ''; - newCategoryId = ''; - newTemplateId = ''; - showCreate = false; - invalidateAll(); - } catch { - error = 'Network error'; - } finally { + const { ok, data } = await api('/api/boards', { + method: 'POST', + body: JSON.stringify({ + title: newTitle, + visibility: newVisibility, + ...(newCategoryId ? { categoryId: newCategoryId } : {}), + ...(newTemplateId ? { templateId: newTemplateId } : {}) + }) + }); + if (!ok) { + error = data?.error || 'Failed to create board'; creating = false; + return; } + toast.success('Board created'); + newTitle = ''; + newCategoryId = ''; + newTemplateId = ''; + showCreate = false; + creating = false; + invalidateAll(); } @@ -74,12 +71,12 @@
-

Boards

+

Boards

{#if data.user}
{#if showCreate} -
+
{#if error} -
{error}
+
{error}
{/if}
- +
- + {#each data.categories as cat} @@ -142,11 +139,11 @@ {/if} {#if data.templates.length > 0}
- + renameColumn(column.id)} /> {:else} {/if} {#if data.canEdit}
{/if} - {card.title} + {card.title} {#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress} -
+
{#if card.dueDate} {formatDueDate(card.dueDate)} @@ -596,7 +591,7 @@ {/each} {#if card.assignees.length > 3}
+{card.assignees.length - 3}
@@ -612,10 +607,10 @@ {@const archivedInCol = getArchivedCardsForColumn(column.id)} {#if archivedInCol.length > 0}
-
Archived
+
Archived
{#each archivedInCol as card} -
- {card.title} +
+ {card.title}
{/each}
@@ -632,7 +627,7 @@
Title Visibility Members
{board.title}
{board.title} - + {board.visibility === 'PUBLIC' ? 'Public' : 'Private'} {board._count.members}{board._count.members} - No boards yetNo boards yet