Private
Public Access
1
0

Phase 8: Polish & UX — dark mode, toasts, keyboard shortcuts, responsive, accessibility

- 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 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-26 01:09:17 -05:00
parent 6ed0349507
commit 7559102479
33 changed files with 850 additions and 374 deletions
+27
View File
@@ -1,5 +1,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--color-board-bg: #f1f2f4; --color-board-bg: #f1f2f4;
--color-column-bg: #ebecf0; --color-column-bg: #ebecf0;
@@ -72,3 +74,28 @@
max-width: 100%; max-width: 100%;
border-radius: 4px; 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);
}
+1
View File
@@ -23,6 +23,7 @@ declare global {
session: { session: {
id: string; id: string;
} | null; } | null;
theme?: string;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
+7
View File
@@ -4,6 +4,13 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<script>
(function() {
var t = document.cookie.match(/(?:^|; )theme=([^;]*)/);
var theme = t ? t[1] : localStorage.getItem('theme');
if (theme === 'dark') document.documentElement.classList.add('dark');
})();
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+3
View File
@@ -42,5 +42,8 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null; event.locals.session = null;
} }
// 3. Read theme preference
event.locals.theme = event.cookies.get('theme') || undefined;
return resolve(event); return resolve(event);
}; };
+11 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js'; import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
type Props = { type Props = {
boardId: string; boardId: string;
@@ -71,9 +72,9 @@
url.searchParams.set('limit', '20'); url.searchParams.set('limit', '20');
if (cursor) url.searchParams.set('cursor', cursor); if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url.toString()); const { ok, data } = await api(url.toString());
if (!res.ok) return; if (!ok) return;
return res.json(); return data;
} }
async function loadMore() { async function loadMore() {
@@ -117,12 +118,12 @@
<div class="space-y-3"> <div class="space-y-3">
{#if loading} {#if loading}
<div class="animate-pulse space-y-2"> <div class="animate-pulse space-y-2">
<div class="h-3 bg-gray-200 rounded w-3/4"></div> <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div> <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div> <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
</div> </div>
{:else if activities.length === 0} {:else if activities.length === 0}
<p class="text-sm text-gray-400 text-center py-4">No activity yet</p> <p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">No activity yet</p>
{:else} {:else}
{#each activities as activity (activity.id)} {#each activities as activity (activity.id)}
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
@@ -133,11 +134,11 @@
{activity.user.name[0].toUpperCase()} {activity.user.name[0].toUpperCase()}
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-gray-700 leading-snug"> <p class="text-gray-700 dark:text-gray-300 leading-snug">
<span class="font-medium">{activity.user.name}</span> <span class="font-medium">{activity.user.name}</span>
{' '}{formatAction(activity)} {' '}{formatAction(activity)}
</p> </p>
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(activity.createdAt)}</p> <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{timeAgo(activity.createdAt)}</p>
</div> </div>
</div> </div>
{/each} {/each}
@@ -146,7 +147,7 @@
<button <button
onclick={loadMore} onclick={loadMore}
disabled={loadingMore} disabled={loadingMore}
class="w-full text-center text-sm text-gray-500 hover:text-gray-700 py-2 transition" class="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 py-2 transition"
> >
{loadingMore ? 'Loading...' : 'Load more'} {loadingMore ? 'Loading...' : 'Load more'}
</button> </button>
+14 -16
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js';
type Assignee = { id: string; userId: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } }; type Assignee = { id: string; userId: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
type Member = { id: string; userId: string; role: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } }; type Member = { id: string; userId: string; role: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
@@ -23,22 +25,19 @@
async function toggleAssignee(member: Member) { async function toggleAssignee(member: Member) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`; const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
if (isAssigned(member.userId)) { if (isAssigned(member.userId)) {
const res = await fetch(base, { const { ok } = await api(base, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId }) body: JSON.stringify({ userId: member.userId })
}); });
if (res.ok) { if (ok) {
onupdate(currentAssignees.filter((a) => a.userId !== member.userId)); onupdate(currentAssignees.filter((a) => a.userId !== member.userId));
} }
} else { } else {
const res = await fetch(base, { const { ok, data } = await api(base, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId }) body: JSON.stringify({ userId: member.userId })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
onupdate([...currentAssignees, data.assignee]); onupdate([...currentAssignees, data.assignee]);
} }
} }
@@ -46,26 +45,25 @@
async function removeAssignee(userId: string) { async function removeAssignee(userId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`; const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
const res = await fetch(base, { const { ok } = await api(base, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }) body: JSON.stringify({ userId })
}); });
if (res.ok) { if (ok) {
onupdate(currentAssignees.filter((a) => a.userId !== userId)); onupdate(currentAssignees.filter((a) => a.userId !== userId));
} }
} }
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Assignees</h3>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each currentAssignees as assignee} {#each currentAssignees as assignee}
<div class="flex items-center gap-1 rounded-full bg-gray-100 pl-1 pr-2 py-0.5"> <div class="flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-1 pr-2 py-0.5">
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center"> <div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
{assignee.user.name[0].toUpperCase()} {assignee.user.name[0].toUpperCase()}
</div> </div>
<span class="text-xs text-gray-700">{assignee.user.name}</span> <span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span>
{#if canEdit} {#if canEdit}
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5"> <button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5">
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
@@ -79,19 +77,19 @@
<div class="relative"> <div class="relative">
<button <button
onclick={() => (showDropdown = !showDropdown)} onclick={() => (showDropdown = !showDropdown)}
class="inline-flex items-center rounded-full bg-gray-100 w-6 h-6 text-xs text-gray-500 hover:bg-gray-200 justify-center" class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 w-6 h-6 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 justify-center"
> >
+ +
</button> </button>
{#if showDropdown} {#if showDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-10" onclick={() => (showDropdown = false)}></div> <div class="fixed inset-0 z-10" onclick={() => (showDropdown = false)}></div>
<div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white shadow-lg border border-gray-200 p-1"> <div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-1">
<div class="max-h-48 overflow-y-auto"> <div class="max-h-48 overflow-y-auto">
{#each boardMembers as member} {#each boardMembers as member}
<button <button
onclick={() => toggleAssignee(member)} onclick={() => toggleAssignee(member)}
class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 {isAssigned(member.userId) ? 'bg-blue-50' : ''}" class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 {isAssigned(member.userId) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}"
> >
<div class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center"> <div class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center">
{member.user.name[0].toUpperCase()} {member.user.name[0].toUpperCase()}
+21 -22
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js';
type Attachment = { type Attachment = {
id: string; id: string;
filename: string; filename: string;
@@ -55,35 +57,31 @@
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
try { const { ok, data } = await api(
const res = await fetch( `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`, { method: 'POST', body: formData }
{ method: 'POST', body: formData } );
); if (ok) {
if (res.ok) { localAttachments = [data.attachment, ...localAttachments];
const data = await res.json();
localAttachments = [data.attachment, ...localAttachments];
}
} finally {
uploading = false;
input.value = '';
} }
uploading = false;
input.value = '';
} }
async function deleteAttachment(attId: string) { async function deleteAttachment(attId: string) {
const res = await fetch(`/api/attachments/${attId}`, { method: 'DELETE' }); const { ok } = await api(`/api/attachments/${attId}`, { method: 'DELETE' });
if (res.ok) { if (ok) {
localAttachments = localAttachments.filter((a) => a.id !== attId); localAttachments = localAttachments.filter((a) => a.id !== attId);
} }
} }
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Attachments</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Attachments</h3>
{#if canEdit} {#if canEdit}
<div class="mb-3"> <div class="mb-3">
<label class="inline-flex items-center gap-1.5 rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-200 cursor-pointer transition"> <label class="inline-flex items-center gap-1.5 rounded bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer transition">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-4 h-4" 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" /> <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> </svg>
@@ -96,22 +94,23 @@
{#if localAttachments.length > 0} {#if localAttachments.length > 0}
<div class="space-y-1.5"> <div class="space-y-1.5">
{#each localAttachments as att} {#each localAttachments as att}
<div class="flex items-center gap-2 rounded border border-gray-100 px-2 py-1.5 hover:bg-gray-50"> <div class="flex items-center gap-2 rounded border border-gray-100 dark:border-gray-700 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800">
<span class="text-xs font-mono text-gray-400 w-6 text-center uppercase"> <span class="text-xs font-mono text-gray-400 dark:text-gray-500 w-6 text-center uppercase">
{getIcon(att.mimetype)} {getIcon(att.mimetype)}
</span> </span>
<a <a
href="/api/attachments/{att.id}" href="/api/attachments/{att.id}"
class="flex-1 text-sm text-gray-700 hover:text-[var(--color-primary)] truncate" class="flex-1 text-sm text-gray-700 dark:text-gray-300 hover:text-[var(--color-primary)] truncate"
download download
> >
{att.filename} {att.filename}
</a> </a>
<span class="text-xs text-gray-400 flex-shrink-0">{formatSize(att.size)}</span> <span class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">{formatSize(att.size)}</span>
{#if canEdit} {#if canEdit}
<button <button
onclick={() => deleteAttachment(att.id)} onclick={() => deleteAttachment(att.id)}
class="text-gray-300 hover:text-red-500 transition" class="text-gray-300 hover:text-red-500 dark:text-gray-600 transition"
aria-label="Delete attachment"
> >
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3.5 h-3.5" 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" /> <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" />
@@ -122,6 +121,6 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-xs text-gray-400 italic">No attachments</p> <p class="text-xs text-gray-400 dark:text-gray-500 italic">No attachments</p>
{/if} {/if}
</div> </div>
+7 -7
View File
@@ -102,7 +102,7 @@
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<button <button
onclick={() => (expanded = !expanded)} onclick={() => (expanded = !expanded)}
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition {hasFilters ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}" class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition {hasFilters ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
> >
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clip-rule="evenodd" />
@@ -116,14 +116,14 @@
</button> </button>
{#if hasFilters} {#if hasFilters}
<button onclick={clearAll} class="text-xs text-gray-400 hover:text-gray-600 transition">Clear</button> <button onclick={clearAll} class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition">Clear</button>
{/if} {/if}
{#if expanded} {#if expanded}
<!-- Labels --> <!-- Labels -->
{#if allTags().length > 0} {#if allTags().length > 0}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Labels:</span> <span class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide">Labels:</span>
{#each allTags() as tag} {#each allTags() as tag}
<button <button
onclick={() => toggleLabel(tag.id)} onclick={() => toggleLabel(tag.id)}
@@ -139,11 +139,11 @@
<!-- Assignees --> <!-- Assignees -->
{#if members.length > 0} {#if members.length > 0}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Assignee:</span> <span class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide">Assignee:</span>
{#each members as member} {#each members as member}
<button <button
onclick={() => toggleAssignee(member.user.id)} onclick={() => toggleAssignee(member.user.id)}
class="rounded-full px-2 py-0.5 text-[10px] transition {selectedAssignees.includes(member.user.id) ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}" class="rounded-full px-2 py-0.5 text-[10px] transition {selectedAssignees.includes(member.user.id) ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
> >
{member.user.name} {member.user.name}
</button> </button>
@@ -153,11 +153,11 @@
<!-- Due date --> <!-- Due date -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Due:</span> <span class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide">Due:</span>
{#each [{ val: 'overdue', label: 'Overdue' }, { val: 'due-soon', label: 'Due soon' }, { val: 'no-date', label: 'No date' }] as opt} {#each [{ val: 'overdue', label: 'Overdue' }, { val: 'due-soon', label: 'Due soon' }, { val: 'no-date', label: 'No date' }] as opt}
<button <button
onclick={() => setDue(opt.val)} onclick={() => setDue(opt.val)}
class="rounded-full px-2 py-0.5 text-[10px] transition {selectedDue === opt.val ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}" class="rounded-full px-2 py-0.5 text-[10px] transition {selectedDue === opt.val ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
> >
{opt.label} {opt.label}
</button> </button>
+24 -25
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { trapFocus } from '$lib/utils/focus-trap.js';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import DueDatePicker from './DueDatePicker.svelte'; import DueDatePicker from './DueDatePicker.svelte';
import TagPicker from './TagPicker.svelte'; import TagPicker from './TagPicker.svelte';
@@ -8,6 +9,7 @@
import CommentList from './CommentList.svelte'; import CommentList from './CommentList.svelte';
import AttachmentList from './AttachmentList.svelte'; import AttachmentList from './AttachmentList.svelte';
import ActivityFeed from './ActivityFeed.svelte'; import ActivityFeed from './ActivityFeed.svelte';
import { api } from '$lib/utils/api.js';
type Props = { type Props = {
card: any; card: any;
@@ -32,9 +34,8 @@
let loadingFull = $state(true); let loadingFull = $state(true);
onMount(async () => { onMount(async () => {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`); const { ok, data } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`);
if (res.ok) { if (ok) {
const data = await res.json();
fullCard = data.card; fullCard = data.card;
} }
loadingFull = false; loadingFull = false;
@@ -43,12 +44,11 @@
async function saveTitle() { async function saveTitle() {
if (!title.trim()) return; if (!title.trim()) return;
saving = true; saving = true;
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, { const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }) body: JSON.stringify({ title })
}); });
if (res.ok) { if (ok) {
card.title = title; card.title = title;
onupdate(card); onupdate(card);
} }
@@ -57,12 +57,11 @@
} }
async function saveDescription(value: string) { async function saveDescription(value: string) {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, { const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: value }) body: JSON.stringify({ description: value })
}); });
if (res.ok) { if (ok) {
card.description = value; card.description = value;
if (fullCard) fullCard.description = value; if (fullCard) fullCard.description = value;
onupdate(card); onupdate(card);
@@ -106,12 +105,11 @@
} }
async function archiveCard() { async function archiveCard() {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, { const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: true }) body: JSON.stringify({ archived: true })
}); });
if (res.ok) { if (ok) {
ondelete(card.id); ondelete(card.id);
} }
} }
@@ -124,29 +122,30 @@
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto" class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto"
onclick={handleBackdropClick} onclick={handleBackdropClick}
> >
<div class="w-full max-w-4xl bg-white rounded-xl shadow-2xl"> <div class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl max-sm:min-h-screen max-sm:rounded-none" role="dialog" aria-modal="true" aria-labelledby="card-modal-title" use:trapFocus>
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between p-4 border-b border-gray-100"> <div class="flex items-start justify-between p-4 border-b border-gray-100 dark:border-gray-700">
<div class="flex-1"> <div class="flex-1">
{#if editingTitle && canEdit} {#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}> <form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<input <input
bind:value={title} bind:value={title}
class="text-lg font-semibold w-full rounded border border-gray-300 px-2 py-1" class="text-lg font-semibold w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1"
autofocus autofocus
onblur={saveTitle} onblur={saveTitle}
/> />
</form> </form>
{:else} {:else}
<h2 <h2
class="text-lg font-semibold text-gray-900 {canEdit ? 'cursor-pointer hover:bg-gray-50 rounded px-2 py-1 -mx-2 -my-1' : ''}" id="card-modal-title"
class="text-lg font-semibold text-gray-900 dark:text-gray-100 {canEdit ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded px-2 py-1 -mx-2 -my-1' : ''}"
ondblclick={() => canEdit && (editingTitle = true)} ondblclick={() => canEdit && (editingTitle = true)}
> >
{card.title} {card.title}
</h2> </h2>
{/if} {/if}
</div> </div>
<button onclick={onclose} class="text-gray-400 hover:text-gray-600 transition p-1 ml-2"> <button onclick={onclose} class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition p-1 ml-2" aria-label="Close">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-5 h-5" 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" /> <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> </svg>
@@ -169,7 +168,7 @@
<!-- Description --> <!-- Description -->
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Description</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Description</h3>
<MarkdownEditor <MarkdownEditor
value={card.description || ''} value={card.description || ''}
readonly={!canEdit} readonly={!canEdit}
@@ -180,9 +179,9 @@
<!-- Checklists (lazy-loaded) --> <!-- Checklists (lazy-loaded) -->
{#if loadingFull} {#if loadingFull}
<div class="animate-pulse space-y-2"> <div class="animate-pulse space-y-2">
<div class="h-4 bg-gray-200 rounded w-24"></div> <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
<div class="h-2 bg-gray-100 rounded w-full"></div> <div class="h-2 bg-gray-100 dark:bg-gray-800 rounded w-full"></div>
<div class="h-3 bg-gray-100 rounded w-3/4"></div> <div class="h-3 bg-gray-100 dark:bg-gray-800 rounded w-3/4"></div>
</div> </div>
{:else if fullCard} {:else if fullCard}
<ChecklistSection <ChecklistSection
@@ -213,7 +212,7 @@
<!-- Activity Log --> <!-- Activity Log -->
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Activity</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Activity</h3>
<ActivityFeed {boardId} cardId={card.id} {columnId} /> <ActivityFeed {boardId} cardId={card.id} {columnId} />
</div> </div>
{/if} {/if}
@@ -243,17 +242,17 @@
<!-- Actions --> <!-- Actions -->
{#if canEdit} {#if canEdit}
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Actions</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Actions</h3>
<div class="space-y-1"> <div class="space-y-1">
<button <button
onclick={archiveCard} onclick={archiveCard}
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 transition" class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition"
> >
Archive Archive
</button> </button>
<button <button
onclick={() => ondelete(card.id)} onclick={() => ondelete(card.id)}
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition" class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition"
> >
Delete Delete
</button> </button>
+25 -28
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js';
type ChecklistItem = { id: string; title: string; completed: boolean; position: string }; type ChecklistItem = { id: string; title: string; completed: boolean; position: string };
type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] }; type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] };
@@ -27,13 +29,11 @@
async function addChecklist() { async function addChecklist() {
if (!newChecklistTitle.trim()) return; if (!newChecklistTitle.trim()) return;
const res = await fetch(basePath, { const { ok, data } = await api(basePath, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newChecklistTitle.trim() }) body: JSON.stringify({ title: newChecklistTitle.trim() })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
onupdate([...checklists, data.checklist]); onupdate([...checklists, data.checklist]);
newChecklistTitle = ''; newChecklistTitle = '';
addingChecklist = false; addingChecklist = false;
@@ -41,8 +41,8 @@
} }
async function deleteChecklist(clId: string) { async function deleteChecklist(clId: string) {
const res = await fetch(`${basePath}/${clId}`, { method: 'DELETE' }); const { ok } = await api(`${basePath}/${clId}`, { method: 'DELETE' });
if (res.ok) { if (ok) {
onupdate(checklists.filter((c) => c.id !== clId)); onupdate(checklists.filter((c) => c.id !== clId));
} }
} }
@@ -50,13 +50,11 @@
async function addItem(clId: string) { async function addItem(clId: string) {
const title = newItemTitles[clId]?.trim(); const title = newItemTitles[clId]?.trim();
if (!title) return; if (!title) return;
const res = await fetch(`${basePath}/${clId}/items`, { const { ok, data } = await api(`${basePath}/${clId}/items`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }) body: JSON.stringify({ title })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
onupdate( onupdate(
checklists.map((c) => checklists.map((c) =>
c.id === clId ? { ...c, items: [...c.items, data.item] } : c c.id === clId ? { ...c, items: [...c.items, data.item] } : c
@@ -67,12 +65,11 @@
} }
async function toggleItem(clId: string, item: ChecklistItem) { async function toggleItem(clId: string, item: ChecklistItem) {
const res = await fetch(`${basePath}/${clId}/items/${item.id}`, { const { ok } = await api(`${basePath}/${clId}/items/${item.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !item.completed }) body: JSON.stringify({ completed: !item.completed })
}); });
if (res.ok) { if (ok) {
onupdate( onupdate(
checklists.map((c) => checklists.map((c) =>
c.id === clId c.id === clId
@@ -89,8 +86,8 @@
} }
async function deleteItem(clId: string, itemId: string) { async function deleteItem(clId: string, itemId: string) {
const res = await fetch(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' }); const { ok } = await api(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' });
if (res.ok) { if (ok) {
onupdate( onupdate(
checklists.map((c) => checklists.map((c) =>
c.id === clId ? { ...c, items: c.items.filter((i) => i.id !== itemId) } : c c.id === clId ? { ...c, items: c.items.filter((i) => i.id !== itemId) } : c
@@ -101,21 +98,21 @@
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Checklists</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Checklists</h3>
{#each checklists as cl} {#each checklists as cl}
{@const p = progress(cl)} {@const p = progress(cl)}
<div class="mb-4 last:mb-0"> <div class="mb-4 last:mb-0">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">{cl.title}</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{cl.title}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400 dark:text-gray-500">
{p.done}/{p.total} {p.done}/{p.total}
</span> </span>
{#if canEdit} {#if canEdit}
<button <button
onclick={() => deleteChecklist(cl.id)} onclick={() => deleteChecklist(cl.id)}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-500"
> >
Delete Delete
</button> </button>
@@ -125,7 +122,7 @@
<!-- Progress bar --> <!-- Progress bar -->
{#if p.total > 0} {#if p.total > 0}
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2"> <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mb-2">
<div <div
class="h-1.5 rounded-full transition-all {p.done === p.total ? 'bg-[var(--color-success)]' : 'bg-[var(--color-primary)]'}" class="h-1.5 rounded-full transition-all {p.done === p.total ? 'bg-[var(--color-success)]' : 'bg-[var(--color-primary)]'}"
style="width: {(p.done / p.total) * 100}%" style="width: {(p.done / p.total) * 100}%"
@@ -142,17 +139,17 @@
type="checkbox" type="checkbox"
checked={item.completed} checked={item.completed}
onchange={() => toggleItem(cl.id, item)} onchange={() => 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} {:else}
<input <input
type="checkbox" type="checkbox"
checked={item.completed} checked={item.completed}
disabled disabled
class="rounded border-gray-300" class="rounded border-gray-300 dark:border-gray-600"
/> />
{/if} {/if}
<span class="flex-1 text-sm {item.completed ? 'text-gray-400 line-through' : 'text-gray-700'}"> <span class="flex-1 text-sm {item.completed ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}">
{item.title} {item.title}
</span> </span>
{#if canEdit} {#if canEdit}
@@ -178,12 +175,12 @@
<input <input
bind:value={newItemTitles[cl.id]} bind:value={newItemTitles[cl.id]}
placeholder="Add an item..." placeholder="Add an item..."
class="flex-1 rounded border border-gray-200 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none" class="flex-1 rounded border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/> />
<button <button
type="submit" type="submit"
disabled={!newItemTitles[cl.id]?.trim()} disabled={!newItemTitles[cl.id]?.trim()}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 disabled:opacity-50" class="rounded bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
> >
Add Add
</button> </button>
@@ -198,7 +195,7 @@
<input <input
bind:value={newChecklistTitle} bind:value={newChecklistTitle}
placeholder="Checklist title..." placeholder="Checklist title..."
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm" 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"
autofocus autofocus
/> />
<button <button
@@ -211,7 +208,7 @@
<button <button
type="button" type="button"
onclick={() => { addingChecklist = false; newChecklistTitle = ''; }} onclick={() => { addingChecklist = false; newChecklistTitle = ''; }}
class="text-xs text-gray-500 hover:text-gray-700" class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
> >
Cancel Cancel
</button> </button>
@@ -219,7 +216,7 @@
{:else} {:else}
<button <button
onclick={() => (addingChecklist = true)} onclick={() => (addingChecklist = true)}
class="text-xs text-gray-400 hover:text-gray-600 mt-1" class="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 mt-1"
> >
+ Add checklist + Add checklist
</button> </button>
+17 -20
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js'; import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { api } from '$lib/utils/api.js';
type Comment = { type Comment = {
id: string; id: string;
@@ -44,13 +45,11 @@
async function addComment() { async function addComment() {
if (!newContent.trim()) return; if (!newContent.trim()) return;
submitting = true; submitting = true;
const res = await fetch(basePath, { const { ok, data } = await api(basePath, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent.trim() }) body: JSON.stringify({ content: newContent.trim() })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
localComments = [data.comment, ...localComments]; localComments = [data.comment, ...localComments];
newContent = ''; newContent = '';
} }
@@ -59,21 +58,19 @@
async function updateComment(commentId: string) { async function updateComment(commentId: string) {
if (!editContent.trim()) return; if (!editContent.trim()) return;
const res = await fetch(`${basePath}/${commentId}`, { const { ok, data } = await api(`${basePath}/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContent.trim() }) body: JSON.stringify({ content: editContent.trim() })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
localComments = localComments.map((c) => (c.id === commentId ? data.comment : c)); localComments = localComments.map((c) => (c.id === commentId ? data.comment : c));
editingId = null; editingId = null;
} }
} }
async function deleteComment(commentId: string) { async function deleteComment(commentId: string) {
const res = await fetch(`${basePath}/${commentId}`, { method: 'DELETE' }); const { ok } = await api(`${basePath}/${commentId}`, { method: 'DELETE' });
if (res.ok) { if (ok) {
localComments = localComments.filter((c) => c.id !== commentId); localComments = localComments.filter((c) => c.id !== commentId);
} }
} }
@@ -85,7 +82,7 @@
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Comments</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Comments</h3>
<!-- Add comment --> <!-- Add comment -->
{#if canEdit} {#if canEdit}
@@ -94,7 +91,7 @@
bind:value={newContent} bind:value={newContent}
rows="3" rows="3"
placeholder="Write a comment... (Markdown supported)" 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)]"
></textarea> ></textarea>
<div class="flex justify-end mt-1"> <div class="flex justify-end mt-1">
<button <button
@@ -117,8 +114,8 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5"> <div class="flex items-center gap-2 mb-0.5">
<span class="text-sm font-medium text-gray-800">{comment.user.name}</span> <span class="text-sm font-medium text-gray-800 dark:text-gray-200">{comment.user.name}</span>
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400 dark:text-gray-500">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span> </span>
</div> </div>
@@ -127,7 +124,7 @@
<textarea <textarea
bind:value={editContent} bind:value={editContent}
rows="3" rows="3"
class="w-full rounded border border-gray-300 p-2 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none" class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 p-2 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none"
></textarea> ></textarea>
<div class="flex gap-2 mt-1"> <div class="flex gap-2 mt-1">
<button <button
@@ -138,14 +135,14 @@
</button> </button>
<button <button
onclick={() => (editingId = null)} onclick={() => (editingId = null)}
class="text-xs text-gray-500 hover:text-gray-700" class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
> >
Cancel Cancel
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<div class="prose text-sm text-gray-700"> <div class="prose text-sm text-gray-700 dark:text-gray-300">
{#if ready} {#if ready}
{@html renderMarkdown(comment.content)} {@html renderMarkdown(comment.content)}
{:else} {:else}
@@ -156,7 +153,7 @@
{#if comment.userId === currentUserId} {#if comment.userId === currentUserId}
<button <button
onclick={() => startEdit(comment)} onclick={() => startEdit(comment)}
class="text-xs text-gray-400 hover:text-gray-600" class="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
> >
Edit Edit
</button> </button>
@@ -164,7 +161,7 @@
{#if comment.userId === currentUserId || canEdit} {#if comment.userId === currentUserId || canEdit}
<button <button
onclick={() => deleteComment(comment.id)} onclick={() => deleteComment(comment.id)}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-500"
> >
Delete Delete
</button> </button>
@@ -177,6 +174,6 @@
</div> </div>
{#if localComments.length === 0} {#if localComments.length === 0}
<p class="text-xs text-gray-400 italic">No comments yet</p> <p class="text-xs text-gray-400 dark:text-gray-500 italic">No comments yet</p>
{/if} {/if}
</div> </div>
+12 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js'; import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { api } from '$lib/utils/api.js';
type Props = { type Props = {
value: string | null; value: string | null;
@@ -23,12 +24,11 @@
async function setDate(dateStr: string) { async function setDate(dateStr: string) {
saving = true; saving = true;
const dueDate = dateStr || null; const dueDate = dateStr || null;
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}`, { const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dueDate }) body: JSON.stringify({ dueDate })
}); });
if (res.ok) { if (ok) {
onupdate(dueDate); onupdate(dueDate);
} }
picking = false; picking = false;
@@ -41,19 +41,19 @@
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Due Date</h3>
{#if picking && canEdit} {#if picking && canEdit}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
type="date" type="date"
value={dateValue} value={dateValue}
onchange={(e) => setDate(e.currentTarget.value)} onchange={(e) => 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} disabled={saving}
/> />
<button <button
onclick={() => (picking = false)} onclick={() => (picking = false)}
class="text-xs text-gray-500 hover:text-gray-700" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
> >
Cancel Cancel
</button> </button>
@@ -66,8 +66,9 @@
{#if canEdit} {#if canEdit}
<button <button
onclick={() => (picking = true)} onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600" class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Change date" title="Change date"
aria-label="Change due date"
> >
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" /> <path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
@@ -75,8 +76,9 @@
</button> </button>
<button <button
onclick={clearDate} onclick={clearDate}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 hover:text-red-500 dark:text-gray-500"
title="Remove date" title="Remove date"
aria-label="Remove due date"
> >
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3.5 h-3.5" 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" /> <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" />
@@ -87,11 +89,11 @@
{:else if canEdit} {:else if canEdit}
<button <button
onclick={() => (picking = true)} onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600" class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
> >
+ Set due date + Set due date
</button> </button>
{:else} {:else}
<p class="text-xs text-gray-400 italic">No due date</p> <p class="text-xs text-gray-400 dark:text-gray-500 italic">No due date</p>
{/if} {/if}
</div> </div>
@@ -0,0 +1,89 @@
<script lang="ts">
import { goto } from '$app/navigation';
type Props = {
onNewCard?: () => void;
};
let { onNewCard }: Props = $props();
let showHelp = $state(false);
function isInput(el: EventTarget | null): boolean {
if (!el || !(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function handleKeydown(e: KeyboardEvent) {
if (isInput(e.target)) return;
switch (e.key) {
case '/':
e.preventDefault();
const searchInput = document.querySelector<HTMLInputElement>('input[placeholder*="Search"]');
searchInput?.focus();
break;
case 'b':
goto('/boards');
break;
case '?':
e.preventDefault();
showHelp = !showHelp;
break;
case 'Escape':
if (showHelp) {
showHelp = false;
e.stopPropagation();
}
break;
case 'n':
if (onNewCard) {
e.preventDefault();
onNewCard();
}
break;
}
}
const shortcuts = [
{ key: '/', desc: 'Focus search' },
{ key: 'b', desc: 'Go to boards' },
{ key: '?', desc: 'Toggle this help' },
{ key: 'Esc', desc: 'Close dialogs' },
{ key: 'n', desc: 'New card (on board)' }
];
</script>
<svelte:window onkeydown={handleKeydown} />
{#if showHelp}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 p-4"
onclick={(e) => { if (e.target === e.currentTarget) showHelp = false; }}
>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Keyboard Shortcuts</h2>
<button
onclick={() => (showHelp = false)}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition p-1"
aria-label="Close"
>
<svg class="w-5 h-5" 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>
<div class="space-y-2">
{#each shortcuts as s}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-300">{s.desc}</span>
<kbd class="rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-mono text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600">{s.key}</kbd>
</div>
{/each}
</div>
</div>
</div>
{/if}
+10 -10
View File
@@ -42,31 +42,31 @@
<div> <div>
<div class="flex gap-2 mb-2"> <div class="flex gap-2 mb-2">
<button <button
class="text-xs px-2 py-1 rounded {!previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}" class="text-xs px-2 py-1 rounded {!previewing ? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
onclick={() => (previewing = false)} onclick={() => (previewing = false)}
> >
Write Write
</button> </button>
<button <button
class="text-xs px-2 py-1 rounded {previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}" class="text-xs px-2 py-1 rounded {previewing ? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
onclick={() => (previewing = true)} onclick={() => (previewing = true)}
> >
Preview Preview
</button> </button>
</div> </div>
{#if previewing} {#if previewing}
<div class="prose rounded-lg border border-gray-200 p-3 min-h-[120px] text-sm"> <div class="prose rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[120px] text-sm">
{#if ready} {#if ready}
{@html renderMarkdown(draft)} {@html renderMarkdown(draft)}
{:else} {:else}
<p class="text-gray-400">{draft}</p> <p class="text-gray-400 dark:text-gray-500">{draft}</p>
{/if} {/if}
</div> </div>
{:else} {:else}
<textarea <textarea
bind:value={draft} bind:value={draft}
rows="6" rows="6"
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)]"
placeholder="Add a more detailed description... (Markdown supported)" placeholder="Add a more detailed description... (Markdown supported)"
></textarea> ></textarea>
{/if} {/if}
@@ -79,7 +79,7 @@
</button> </button>
<button <button
onclick={cancel} onclick={cancel}
class="text-sm text-gray-500 hover:text-gray-700" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
> >
Cancel Cancel
</button> </button>
@@ -88,7 +88,7 @@
{:else} {:else}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="{!readonly ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]" class="{!readonly ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded' : ''} p-2 -mx-2 min-h-[40px]"
onclick={() => { if (!readonly) editing = true; }} onclick={() => { if (!readonly) editing = true; }}
> >
{#if value} {#if value}
@@ -96,13 +96,13 @@
{#if ready} {#if ready}
{@html renderMarkdown(value)} {@html renderMarkdown(value)}
{:else} {:else}
<p class="text-gray-700 whitespace-pre-wrap">{value}</p> <p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{value}</p>
{/if} {/if}
</div> </div>
{:else if !readonly} {:else if !readonly}
<p class="text-sm text-gray-400">Add a more detailed description...</p> <p class="text-sm text-gray-400 dark:text-gray-500">Add a more detailed description...</p>
{:else} {:else}
<p class="text-sm text-gray-400 italic">No description</p> <p class="text-sm text-gray-400 dark:text-gray-500 italic">No description</p>
{/if} {/if}
</div> </div>
{/if} {/if}
+15 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSocket } from '$lib/stores/socket.js'; import { getSocket } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
let notifications = $state<any[]>([]); let notifications = $state<any[]>([]);
let unreadCount = $state(0); let unreadCount = $state(0);
@@ -19,23 +20,21 @@
} }
async function fetchNotifications() { async function fetchNotifications() {
const res = await fetch('/api/notifications?limit=10'); const { ok, data } = await api('/api/notifications?limit=10');
if (!res.ok) return; if (!ok) return;
const data = await res.json();
notifications = data.notifications; notifications = data.notifications;
unreadCount = data.unreadCount; unreadCount = data.unreadCount;
} }
async function markAllRead() { async function markAllRead() {
await fetch('/api/notifications/read-all', { method: 'POST' }); await api('/api/notifications/read-all', { method: 'POST' });
unreadCount = 0; unreadCount = 0;
notifications = notifications.map((n) => ({ ...n, read: true })); notifications = notifications.map((n) => ({ ...n, read: true }));
} }
async function markRead(id: string) { async function markRead(id: string) {
await fetch(`/api/notifications/${id}`, { await api(`/api/notifications/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }) body: JSON.stringify({ read: true })
}); });
const n = notifications.find((x) => x.id === id); const n = notifications.find((x) => x.id === id);
@@ -78,6 +77,7 @@
onclick={() => { open = !open; }} onclick={() => { open = !open; }}
class="relative rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition" class="relative rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
title="Notifications" title="Notifications"
aria-label="Notifications"
> >
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" /> <path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
@@ -90,9 +90,9 @@
</button> </button>
{#if open} {#if open}
<div class="absolute right-0 top-full mt-1 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-50 overflow-hidden"> <div class="absolute right-0 top-full mt-1 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100"> <div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-700">
<span class="text-sm font-semibold text-gray-700">Notifications</span> <span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Notifications</span>
{#if unreadCount > 0} {#if unreadCount > 0}
<button <button
onclick={markAllRead} onclick={markAllRead}
@@ -105,15 +105,15 @@
<div class="max-h-80 overflow-y-auto"> <div class="max-h-80 overflow-y-auto">
{#if loading} {#if loading}
<div class="p-4 text-center text-sm text-gray-400">Loading...</div> <div class="p-4 text-center text-sm text-gray-400 dark:text-gray-500">Loading...</div>
{:else if notifications.length === 0} {:else if notifications.length === 0}
<div class="p-4 text-center text-sm text-gray-400">No notifications</div> <div class="p-4 text-center text-sm text-gray-400 dark:text-gray-500">No notifications</div>
{:else} {:else}
{#each notifications as n (n.id)} {#each notifications as n (n.id)}
<a <a
href={n.link || '#'} href={n.link || '#'}
onclick={() => { if (!n.read) markRead(n.id); open = false; }} onclick={() => { if (!n.read) markRead(n.id); open = false; }}
class="block px-3 py-2.5 hover:bg-gray-50 transition border-b border-gray-50 last:border-0 {n.read ? '' : 'bg-blue-50/50'}" class="block px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 transition border-b border-gray-50 dark:border-gray-700 last:border-0 {n.read ? '' : 'bg-blue-50/50 dark:bg-blue-900/20'}"
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="flex-shrink-0 mt-0.5"> <div class="flex-shrink-0 mt-0.5">
@@ -132,11 +132,11 @@
{/if} {/if}
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm text-gray-700 leading-snug">{n.title}</p> <p class="text-sm text-gray-700 dark:text-gray-300 leading-snug">{n.title}</p>
{#if n.body} {#if n.body}
<p class="text-xs text-gray-500 mt-0.5 truncate">{n.body}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">{n.body}</p>
{/if} {/if}
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(n.createdAt)}</p> <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{timeAgo(n.createdAt)}</p>
</div> </div>
{#if !n.read} {#if !n.read}
<div class="w-2 h-2 rounded-full bg-[var(--color-primary)] flex-shrink-0 mt-1.5"></div> <div class="w-2 h-2 rounded-full bg-[var(--color-primary)] flex-shrink-0 mt-1.5"></div>
+11 -14
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/utils/api.js';
let query = $state(''); let query = $state('');
let results = $state<any[]>([]); let results = $state<any[]>([]);
@@ -20,16 +21,12 @@
} }
async function doSearch(q: string) { async function doSearch(q: string) {
try { const { ok, data } = await api(`/api/search?q=${encodeURIComponent(q)}&limit=10`);
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=10`); if (ok) {
if (res.ok) { results = data.results;
const data = await res.json(); open = results.length > 0;
results = data.results;
open = results.length > 0;
}
} finally {
loading = false;
} }
loading = false;
} }
function navigateToCard(result: any) { function navigateToCard(result: any) {
@@ -68,7 +65,7 @@
oninput={() => debounceSearch(query)} oninput={() => debounceSearch(query)}
onkeydown={handleKeydown} onkeydown={handleKeydown}
onfocus={() => { if (results.length > 0) open = true; }} onfocus={() => { if (results.length > 0) open = true; }}
class="w-56 rounded-lg bg-white/20 pl-8 pr-3 py-1.5 text-sm text-white placeholder-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/40 transition" class="w-full sm:w-56 rounded-lg bg-white/20 pl-8 pr-3 py-1.5 text-sm text-white placeholder-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/40 transition"
/> />
{#if loading} {#if loading}
<div class="absolute right-2.5 top-1/2 -translate-y-1/2"> <div class="absolute right-2.5 top-1/2 -translate-y-1/2">
@@ -78,14 +75,14 @@
</div> </div>
{#if open} {#if open}
<div class="absolute top-full mt-1 left-0 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-96 overflow-y-auto"> <div class="absolute top-full mt-1 left-0 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 max-h-96 overflow-y-auto">
{#each results as result} {#each results as result}
<button <button
class="w-full text-left px-3 py-2.5 hover:bg-gray-50 transition border-b border-gray-100 last:border-0" class="w-full text-left px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 transition border-b border-gray-100 dark:border-gray-700 last:border-0"
onclick={() => navigateToCard(result)} onclick={() => navigateToCard(result)}
> >
<div class="text-sm font-medium text-gray-800 truncate">{result.title}</div> <div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">{result.title}</div>
<div class="text-xs text-gray-500 mt-0.5 truncate"> <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{result.boardTitle} &middot; {result.columnTitle} {result.boardTitle} &middot; {result.columnTitle}
</div> </div>
</button> </button>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
let { class: className = 'h-4 w-24' }: { class?: string } = $props();
</script>
<div class="animate-pulse rounded bg-gray-200 dark:bg-gray-700 {className}"></div>
+19 -24
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/utils/api.js';
type Label = { id: string; tagId: string; tag: { id: string; name: string; color: string } }; type Label = { id: string; tagId: string; tag: { id: string; name: string; color: string } };
type Tag = { id: string; name: string; color: string }; type Tag = { id: string; name: string; color: string };
@@ -24,9 +26,8 @@
async function fetchTags() { async function fetchTags() {
loading = true; loading = true;
const res = await fetch('/api/tags'); const { ok, data } = await api('/api/tags');
if (res.ok) { if (ok) {
const data = await res.json();
allTags = data.tags; allTags = data.tags;
} }
loading = false; loading = false;
@@ -44,23 +45,20 @@
async function toggleTag(tag: Tag) { async function toggleTag(tag: Tag) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`; const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
if (isApplied(tag.id)) { if (isApplied(tag.id)) {
const res = await fetch(base, { const { ok } = await api(base, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id }) body: JSON.stringify({ tagId: tag.id })
}); });
if (res.ok) { if (ok) {
const updated = currentLabels.filter((l) => l.tagId !== tag.id); const updated = currentLabels.filter((l) => l.tagId !== tag.id);
onupdate(updated); onupdate(updated);
} }
} else { } else {
const res = await fetch(base, { const { ok, data } = await api(base, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id }) body: JSON.stringify({ tagId: tag.id })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
onupdate([...currentLabels, data.label]); onupdate([...currentLabels, data.label]);
} }
} }
@@ -69,13 +67,11 @@
async function createTag() { async function createTag() {
if (!newTagName.trim()) return; if (!newTagName.trim()) return;
creating = true; creating = true;
const res = await fetch('/api/tags', { const { ok, data } = await api('/api/tags', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor }) body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
}); });
if (res.ok) { if (ok) {
const data = await res.json();
allTags = [...allTags, data.tag]; allTags = [...allTags, data.tag];
newTagName = ''; newTagName = '';
} }
@@ -84,19 +80,18 @@
async function removeLabel(tagId: string) { async function removeLabel(tagId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`; const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
const res = await fetch(base, { const { ok } = await api(base, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId }) body: JSON.stringify({ tagId })
}); });
if (res.ok) { if (ok) {
onupdate(currentLabels.filter((l) => l.tagId !== tagId)); onupdate(currentLabels.filter((l) => l.tagId !== tagId));
} }
} }
</script> </script>
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Labels</h3>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each currentLabels as label} {#each currentLabels as label}
<span <span
@@ -117,7 +112,7 @@
<div class="relative"> <div class="relative">
<button <button
onclick={openDropdown} onclick={openDropdown}
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200" class="inline-flex items-center rounded bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600"
> >
+ +
</button> </button>
@@ -127,15 +122,15 @@
class="fixed inset-0 z-10" class="fixed inset-0 z-10"
onclick={() => (showDropdown = false)} onclick={() => (showDropdown = false)}
></div> ></div>
<div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white shadow-lg border border-gray-200 p-2"> <div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-2">
{#if loading} {#if loading}
<p class="text-xs text-gray-400 p-2">Loading...</p> <p class="text-xs text-gray-400 dark:text-gray-500 p-2">Loading...</p>
{:else} {:else}
<div class="max-h-40 overflow-y-auto space-y-1"> <div class="max-h-40 overflow-y-auto space-y-1">
{#each allTags as tag} {#each allTags as tag}
<button <button
onclick={() => toggleTag(tag)} onclick={() => toggleTag(tag)}
class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 {isApplied(tag.id) ? 'bg-blue-50' : ''}" class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 {isApplied(tag.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}"
> >
<span class="w-4 h-4 rounded" style="background-color: {tag.color}"></span> <span class="w-4 h-4 rounded" style="background-color: {tag.color}"></span>
<span class="flex-1 text-left">{tag.name}</span> <span class="flex-1 text-left">{tag.name}</span>
@@ -148,7 +143,7 @@
{/each} {/each}
</div> </div>
<div class="border-t border-gray-100 mt-2 pt-2"> <div class="border-t border-gray-100 mt-2 pt-2">
<p class="text-xs text-gray-500 mb-1">Create tag</p> <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Create tag</p>
<div class="flex gap-1 mb-1.5"> <div class="flex gap-1 mb-1.5">
{#each presetColors as color} {#each presetColors as color}
<button <button
@@ -162,7 +157,7 @@
<input <input
bind:value={newTagName} bind:value={newTagName}
placeholder="Tag name" placeholder="Tag name"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-xs" class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs"
/> />
<button <button
type="submit" type="submit"
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { getToasts, removeToast, type ToastType } from '$lib/stores/toasts.js';
const toasts = $derived(getToasts());
function typeClasses(type: ToastType): string {
switch (type) {
case 'success':
return 'bg-green-600 text-white';
case 'error':
return 'bg-red-600 text-white';
case 'warning':
return 'bg-yellow-500 text-white';
case 'info':
return 'bg-blue-600 text-white';
}
}
function typeIcon(type: ToastType): string {
switch (type) {
case 'success':
return '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';
case 'error':
return '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';
case 'warning':
return 'M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z';
case 'info':
return 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z';
}
}
</script>
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
{#each toasts as t (t.id)}
<div
class="pointer-events-auto flex items-center gap-2 rounded-lg px-4 py-3 shadow-lg text-sm font-medium min-w-[280px] max-w-sm {typeClasses(t.type)}"
transition:fly={{ x: 100, duration: 300 }}
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d={typeIcon(t.type)} clip-rule="evenodd" />
</svg>
<span class="flex-1">{t.message}</span>
<button
onclick={() => removeToast(t.id)}
class="flex-shrink-0 opacity-70 hover:opacity-100 transition"
aria-label="Dismiss"
>
<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>
{/each}
</div>
+28
View File
@@ -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');
}
}
+35
View File
@@ -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<Toast[]>([]);
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)
};
+47
View File
@@ -0,0 +1,47 @@
import { toast } from '$lib/stores/toasts.js';
import { getSocketId } from '$lib/stores/socket.js';
export type ApiResult<T = any> = {
ok: boolean;
data: T;
status: number;
};
export async function api<T = any>(
url: string,
opts: RequestInit = {}
): Promise<ApiResult<T>> {
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 };
}
}
+42
View File
@@ -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<HTMLElement>(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<HTMLElement>(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);
}
};
}
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<svelte:head>
<title>Error {$page.status}</title>
</svelte:head>
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-300 dark:text-gray-600 mb-4">{$page.status}</h1>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-8">
{#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}
</p>
<div class="flex gap-3 justify-center">
<a
href="/"
class="rounded-lg bg-[var(--color-primary)] px-6 py-3 text-white font-medium hover:bg-[var(--color-primary-hover)] transition"
>
Go home
</a>
<button
onclick={() => history.back()}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-6 py-3 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
Go back
</button>
</div>
</div>
</div>
+2 -1
View File
@@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals }) => {
return { return {
user: locals.user, user: locals.user,
tenant: locals.tenant tenant: locals.tenant,
theme: locals.theme
}; };
}; };
+124 -4
View File
@@ -3,12 +3,19 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js'; 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 NotificationBell from '$lib/components/NotificationBell.svelte';
import SearchBar from '$lib/components/SearchBar.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(() => { onMount(() => {
initTheme(data.theme);
if (data.user) { if (data.user) {
connectSocket(); connectSocket();
return () => disconnectSocket(); return () => disconnectSocket();
@@ -16,7 +23,7 @@
}); });
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50 dark:bg-gray-950">
<nav class="bg-[var(--color-primary)] shadow-sm"> <nav class="bg-[var(--color-primary)] shadow-sm">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 items-center justify-between"> <div class="flex h-14 items-center justify-between">
@@ -31,10 +38,29 @@
</a> </a>
{#if data.user} {#if data.user}
<SearchBar /> <div class="hidden sm:block">
<SearchBar />
</div>
{/if} {/if}
<div class="flex items-center gap-3"> <!-- Desktop nav -->
<div class="hidden sm:flex items-center gap-3">
<button
onclick={toggleTheme}
class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
aria-label="Toggle dark mode"
title="Toggle dark mode"
>
{#if theme === 'dark'}
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
{/if}
</button>
{#if data.user} {#if data.user}
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'} {#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
<a <a
@@ -70,9 +96,103 @@
</a> </a>
{/if} {/if}
</div> </div>
<!-- Mobile hamburger -->
<div class="flex items-center gap-2 sm:hidden">
<button
onclick={toggleTheme}
class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
aria-label="Toggle dark mode"
>
{#if theme === 'dark'}
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
{/if}
</button>
{#if data.user}
<NotificationBell />
{/if}
<button
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
aria-label="Open menu"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
{#if mobileMenuOpen}
<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" />
{:else}
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
{/if}
</svg>
</button>
</div>
</div> </div>
</div> </div>
<!-- Mobile dropdown -->
{#if mobileMenuOpen}
<div class="sm:hidden border-t border-white/20 px-4 py-3 space-y-2">
{#if data.user}
<div class="w-full">
<SearchBar />
</div>
<div class="flex flex-col gap-1">
<span class="text-white/80 text-sm py-1">{data.user.name}</span>
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
<a
href="/admin"
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
onclick={() => (mobileMenuOpen = false)}
>
Admin
</a>
{/if}
<a
href="/boards"
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
onclick={() => (mobileMenuOpen = false)}
>
Boards
</a>
<button
onclick={async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/';
}}
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition"
>
Logout
</button>
</div>
{:else}
<div class="flex flex-col gap-1">
<a
href="/auth/login"
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
onclick={() => (mobileMenuOpen = false)}
>
Login
</a>
<a
href="/auth/register"
class="rounded bg-white px-3 py-1.5 text-sm text-[var(--color-primary)] font-medium hover:bg-white/90 transition text-center"
onclick={() => (mobileMenuOpen = false)}
>
Register
</a>
</div>
{/if}
</div>
{/if}
</nav> </nav>
{@render children()} {@render children()}
</div> </div>
<Toast />
<KeyboardShortcuts />
+3 -3
View File
@@ -7,8 +7,8 @@
</svelte:head> </svelte:head>
<div class="flex flex-col items-center justify-center px-4 py-20"> <div class="flex flex-col items-center justify-center px-4 py-20">
<h1 class="text-4xl font-bold text-gray-900 mb-4">Pounce on your tasks</h1> <h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">Pounce on your tasks</h1>
<p class="text-lg text-gray-600 mb-8 text-center max-w-xl"> <p class="text-lg text-gray-600 dark:text-gray-400 mb-8 text-center max-w-xl">
An open source kanban board for your team. Create boards, organize tasks, and track progress in real-time. An open source kanban board for your team. Create boards, organize tasks, and track progress in real-time.
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
@@ -20,7 +20,7 @@
</a> </a>
<a <a
href="/boards" href="/boards"
class="rounded-lg border border-gray-300 px-6 py-3 text-gray-700 font-medium hover:bg-gray-100 transition" class="rounded-lg border border-gray-300 dark:border-gray-600 px-6 py-3 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition"
> >
Browse Boards Browse Boards
</a> </a>
+19 -19
View File
@@ -30,42 +30,42 @@
</svelte:head> </svelte:head>
<div class="mx-auto max-w-5xl px-4 py-8"> <div class="mx-auto max-w-5xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 mb-6">Workspace Admin</h1> <h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Workspace Admin</h1>
<!-- Users --> <!-- Users -->
<section class="mb-8"> <section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Users</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Users</h2>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider"> <tr class="border-b border-gray-100 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-3">Name</th> <th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Email</th> <th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Role</th> <th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Joined</th> <th class="px-4 py-3">Joined</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each users as user (user.id)} {#each users as user (user.id)}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900"> <td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{user.name} {user.name}
{#if user.globalRole === 'SITE_ADMIN'} {#if user.globalRole === 'SITE_ADMIN'}
<span class="ml-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700">SITE ADMIN</span> <span class="ml-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700">SITE ADMIN</span>
{/if} {/if}
</td> </td>
<td class="px-4 py-3 text-gray-600">{user.email}</td> <td class="px-4 py-3 text-gray-600 dark:text-gray-400">{user.email}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<select <select
value={user.tenantRole} value={user.tenantRole}
onchange={(e) => changeRole(user.id, e.currentTarget.value)} onchange={(e) => changeRole(user.id, e.currentTarget.value)}
class="rounded border border-gray-300 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
> >
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="MEMBER">Member</option> <option value="MEMBER">Member</option>
</select> </select>
</td> </td>
<td class="px-4 py-3 text-gray-500">{formatDate(user.createdAt)}</td> <td class="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDate(user.createdAt)}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -75,27 +75,27 @@
<!-- Boards --> <!-- Boards -->
<section> <section>
<h2 class="text-lg font-semibold text-gray-800 mb-3">Boards</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Boards</h2>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider"> <tr class="border-b border-gray-100 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-3">Title</th> <th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Visibility</th> <th class="px-4 py-3">Visibility</th>
<th class="px-4 py-3">Members</th> <th class="px-4 py-3">Members</th>
<th class="px-4 py-3"></th> <th class="px-4 py-3"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-50"> <tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each data.boards as board (board.id)} {#each data.boards as board (board.id)}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900">{board.title}</td> <td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{board.title}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"> <span class="rounded bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-xs text-gray-600 dark:text-gray-400">
{board.visibility === 'PUBLIC' ? 'Public' : 'Private'} {board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-gray-600">{board._count.members}</td> <td class="px-4 py-3 text-gray-600 dark:text-gray-400">{board._count.members}</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<a <a
href="/boards/{board.id}" href="/boards/{board.id}"
@@ -108,7 +108,7 @@
{/each} {/each}
{#if data.boards.length === 0} {#if data.boards.length === 0}
<tr> <tr>
<td colspan="4" class="px-4 py-8 text-center text-gray-500">No boards yet</td> <td colspan="4" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">No boards yet</td>
</tr> </tr>
{/if} {/if}
</tbody> </tbody>
+7 -7
View File
@@ -39,35 +39,35 @@
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4"> <div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Log in</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6 text-center">Log in</h1>
{#if error} {#if error}
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700"> <div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{error} {error}
</div> </div>
{/if} {/if}
<form onsubmit={handleSubmit} class="space-y-4"> <form onsubmit={handleSubmit} class="space-y-4">
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label> <label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input <input
id="email" id="email"
type="email" type="email"
bind:value={email} bind:value={email}
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label> <label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input <input
id="password" id="password"
type="password" type="password"
bind:value={password} bind:value={password}
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="Your password" placeholder="Your password"
/> />
</div> </div>
@@ -81,7 +81,7 @@
</button> </button>
</form> </form>
<p class="mt-4 text-center text-sm text-gray-500"> <p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Don't have an account? Don't have an account?
<a href="/auth/register" class="text-[var(--color-primary)] hover:underline">Register</a> <a href="/auth/register" class="text-[var(--color-primary)] hover:underline">Register</a>
</p> </p>
+9 -9
View File
@@ -40,48 +40,48 @@
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4"> <div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Create an account</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6 text-center">Create an account</h1>
{#if error} {#if error}
<div class="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700"> <div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{error} {error}
</div> </div>
{/if} {/if}
<form onsubmit={handleSubmit} class="space-y-4"> <form onsubmit={handleSubmit} class="space-y-4">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name</label> <label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input <input
id="name" id="name"
type="text" type="text"
bind:value={name} bind:value={name}
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="Your name" placeholder="Your name"
/> />
</div> </div>
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label> <label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input <input
id="email" id="email"
type="email" type="email"
bind:value={email} bind:value={email}
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label> <label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input <input
id="password" id="password"
type="password" type="password"
bind:value={password} bind:value={password}
required required
minlength="8" minlength="8"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="At least 8 characters" placeholder="At least 8 characters"
/> />
</div> </div>
@@ -95,7 +95,7 @@
</button> </button>
</form> </form>
<p class="mt-4 text-center text-sm text-gray-500"> <p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Already have an account? Already have an account?
<a href="/auth/login" class="text-[var(--color-primary)] hover:underline">Log in</a> <a href="/auth/login" class="text-[var(--color-primary)] hover:underline">Log in</a>
</p> </p>
+46 -49
View File
@@ -2,6 +2,8 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props(); let { data } = $props();
@@ -39,32 +41,27 @@
creating = true; creating = true;
error = ''; error = '';
try { const { ok, data } = await api('/api/boards', {
const res = await fetch('/api/boards', { method: 'POST',
method: 'POST', body: JSON.stringify({
headers: { 'Content-Type': 'application/json' }, title: newTitle,
body: JSON.stringify({ visibility: newVisibility,
title: newTitle, ...(newCategoryId ? { categoryId: newCategoryId } : {}),
visibility: newVisibility, ...(newTemplateId ? { templateId: newTemplateId } : {})
...(newCategoryId ? { categoryId: newCategoryId } : {}), })
...(newTemplateId ? { templateId: newTemplateId } : {}) });
}) if (!ok) {
}); error = data?.error || 'Failed to create board';
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 {
creating = false; creating = false;
return;
} }
toast.success('Board created');
newTitle = '';
newCategoryId = '';
newTemplateId = '';
showCreate = false;
creating = false;
invalidateAll();
} }
</script> </script>
@@ -74,12 +71,12 @@
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Boards</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Boards</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if data.user} {#if data.user}
<button <button
onclick={toggleArchived} onclick={toggleArchived}
class="rounded-lg px-3 py-2 text-sm transition flex items-center gap-1.5 {showArchived ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}" class="rounded-lg px-3 py-2 text-sm transition flex items-center gap-1.5 {showArchived ? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <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 d="M2 3a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1V4a1 1 0 00-1-1H2z" />
@@ -98,28 +95,28 @@
</div> </div>
{#if showCreate} {#if showCreate}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> <div class="mb-6 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4 shadow-sm">
{#if error} {#if error}
<div class="mb-3 rounded bg-red-50 border border-red-200 p-2 text-sm text-red-700">{error}</div> <div class="mb-3 rounded bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-2 text-sm text-red-700 dark:text-red-400">{error}</div>
{/if} {/if}
<form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap"> <form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap">
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<label for="board-title" class="block text-sm font-medium text-gray-700 mb-1">Board title</label> <label for="board-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Board title</label>
<input <input
id="board-title" id="board-title"
type="text" type="text"
bind:value={newTitle} bind:value={newTitle}
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="My new board" placeholder="My new board"
/> />
</div> </div>
<div> <div>
<label for="board-vis" class="block text-sm font-medium text-gray-700 mb-1">Visibility</label> <label for="board-vis" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Visibility</label>
<select <select
id="board-vis" id="board-vis"
bind:value={newVisibility} bind:value={newVisibility}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm" class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100"
> >
<option value="PRIVATE">Private</option> <option value="PRIVATE">Private</option>
<option value="PUBLIC">Public</option> <option value="PUBLIC">Public</option>
@@ -127,11 +124,11 @@
</div> </div>
{#if data.categories.length > 0} {#if data.categories.length > 0}
<div> <div>
<label for="board-cat" class="block text-sm font-medium text-gray-700 mb-1">Category</label> <label for="board-cat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select <select
id="board-cat" id="board-cat"
bind:value={newCategoryId} bind:value={newCategoryId}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm" class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100"
> >
<option value="">None</option> <option value="">None</option>
{#each data.categories as cat} {#each data.categories as cat}
@@ -142,11 +139,11 @@
{/if} {/if}
{#if data.templates.length > 0} {#if data.templates.length > 0}
<div> <div>
<label for="board-tpl" class="block text-sm font-medium text-gray-700 mb-1">Template</label> <label for="board-tpl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Template</label>
<select <select
id="board-tpl" id="board-tpl"
bind:value={newTemplateId} bind:value={newTemplateId}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm" class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100"
> >
<option value="">No template</option> <option value="">No template</option>
{#each data.templates as tpl} {#each data.templates as tpl}
@@ -165,7 +162,7 @@
<button <button
type="button" type="button"
onclick={() => (showCreate = false)} onclick={() => (showCreate = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 transition" class="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
> >
Cancel Cancel
</button> </button>
@@ -176,16 +173,16 @@
<!-- Category filter --> <!-- Category filter -->
{#if data.categories.length > 0} {#if data.categories.length > 0}
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<span class="text-sm text-gray-500">Filter:</span> <span class="text-sm text-gray-500 dark:text-gray-400">Filter:</span>
<button <button
class="rounded-full px-3 py-1 text-xs {filterCategoryId === '' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition" class="rounded-full px-3 py-1 text-xs {filterCategoryId === '' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'} transition"
onclick={() => (filterCategoryId = '')} onclick={() => (filterCategoryId = '')}
> >
All All
</button> </button>
{#each data.categories as cat} {#each data.categories as cat}
<button <button
class="rounded-full px-3 py-1 text-xs {filterCategoryId === cat.id ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition" class="rounded-full px-3 py-1 text-xs {filterCategoryId === cat.id ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'} transition"
onclick={() => (filterCategoryId = cat.id)} onclick={() => (filterCategoryId = cat.id)}
> >
{cat.name} {cat.name}
@@ -202,9 +199,9 @@
<rect x="14" y="10" width="7" height="7" rx="1" /> <rect x="14" y="10" width="7" height="7" rx="1" />
<rect x="3" y="13" width="7" height="8" rx="1" /> <rect x="3" y="13" width="7" height="8" rx="1" />
</svg> </svg>
<p class="text-gray-500">{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}</p> <p class="text-gray-500 dark:text-gray-400">{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}</p>
{#if data.user && !filterCategoryId} {#if data.user && !filterCategoryId}
<p class="text-gray-400 text-sm mt-1">Create your first board to get started.</p> <p class="text-gray-400 dark:text-gray-500 text-sm mt-1">Create your first board to get started.</p>
{/if} {/if}
</div> </div>
{:else} {:else}
@@ -212,26 +209,26 @@
{#each filteredBoards as board} {#each filteredBoards as board}
<a <a
href="/boards/{board.id}" href="/boards/{board.id}"
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition {board.archived ? 'opacity-50' : ''}" class="group block rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition {board.archived ? 'opacity-50' : ''}"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition"> <h2 class="font-semibold text-gray-900 dark:text-gray-100 group-hover:text-[var(--color-primary)] transition">
{board.title} {board.title}
</h2> </h2>
<div class="flex items-center gap-1 flex-shrink-0"> <div class="flex items-center gap-1 flex-shrink-0">
{#if board.archived} {#if board.archived}
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500"> <span class="rounded-full bg-gray-200 dark:bg-gray-700 px-2 py-0.5 text-[10px] text-gray-500 dark:text-gray-400">
Archived Archived
</span> </span>
{/if} {/if}
{#if board.category} {#if board.category}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500"> <span class="rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500 dark:text-gray-400">
{board.category.name} {board.category.name}
</span> </span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> <div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span>{board._count.columns} columns</span> <span>{board._count.columns} columns</span>
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span> <span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
</div> </div>
@@ -239,14 +236,14 @@
<div class="mt-3 flex -space-x-1"> <div class="mt-3 flex -space-x-1">
{#each board.members.slice(0, 5) as member} {#each board.members.slice(0, 5) as member}
<div <div
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white" class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white dark:border-gray-900"
title={member.user.name} title={member.user.name}
> >
{member.user.name[0].toUpperCase()} {member.user.name[0].toUpperCase()}
</div> </div>
{/each} {/each}
{#if board.members.length > 5} {#if board.members.length > 5}
<div class="w-6 h-6 rounded-full bg-gray-300 text-gray-600 text-xs flex items-center justify-center border-2 border-white"> <div class="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-xs flex items-center justify-center border-2 border-white dark:border-gray-900">
+{board.members.length - 5} +{board.members.length - 5}
</div> </div>
{/if} {/if}
+63 -68
View File
@@ -5,6 +5,8 @@
import { generatePosition } from '$lib/utils/fractional-index.js'; import { generatePosition } from '$lib/utils/fractional-index.js';
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js'; import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.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.js';
import CardModal from '$lib/components/CardModal.svelte'; import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte'; import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte'; import BoardFilters from '$lib/components/BoardFilters.svelte';
@@ -75,9 +77,8 @@
async function toggleArchivedCards() { async function toggleArchivedCards() {
showArchivedCards = !showArchivedCards; showArchivedCards = !showArchivedCards;
if (showArchivedCards && archivedCards.length === 0) { if (showArchivedCards && archivedCards.length === 0) {
const res = await fetch(`/api/boards/${data.board.id}/archived-cards`); const { ok, data: result } = await api(`/api/boards/${data.board.id}/archived-cards`);
if (res.ok) { if (ok) {
const result = await res.json();
archivedCards = result.cards; archivedCards = result.cards;
} }
} }
@@ -87,12 +88,6 @@
return archivedCards.filter((c: any) => c.columnId === columnId); return archivedCards.filter((c: any) => c.columnId === columnId);
} }
// ─── Socket.IO header for self-dedup ─────────────
function socketHeaders(): Record<string, string> {
const sid = getSocketId();
return sid ? { 'X-Socket-ID': sid } : {};
}
// ─── Real-Time Event Handlers ──────────────────── // ─── Real-Time Event Handlers ────────────────────
function isSelf(d: any) { function isSelf(d: any) {
return d?.socketId && d.socketId === getSocketId(); return d?.socketId && d.socketId === getSocketId();
@@ -134,9 +129,9 @@
const cardId = d.cardId || d.card?.id; const cardId = d.cardId || d.card?.id;
if (!colId || !cardId) return; if (!colId || !cardId) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`); const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
if (!res.ok) return; if (!ok) return;
const { card: freshCard } = await res.json(); const freshCard = result.card;
// The card might have moved columns — remove from old location first // The card might have moved columns — remove from old location first
for (const col of columns) { for (const col of columns) {
@@ -247,9 +242,8 @@
if (col.id === e.detail.info.id) { if (col.id === e.detail.info.id) {
const newPos = generatePosition(before, after); const newPos = generatePosition(before, after);
col.position = newPos; col.position = newPos;
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, { api(`/api/boards/${data.board.id}/columns/${col.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ position: newPos }) body: JSON.stringify({ position: newPos })
}); });
} }
@@ -273,9 +267,8 @@
const newPos = generatePosition(before, after); const newPos = generatePosition(before, after);
col.cards[cardIdx].position = newPos; col.cards[cardIdx].position = newPos;
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, { api(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ columnId, position: newPos }) body: JSON.stringify({ columnId, position: newPos })
}); });
} }
@@ -284,56 +277,53 @@
// ─── Add Column ────────────────────────────────── // ─── Add Column ──────────────────────────────────
async function addColumn() { async function addColumn() {
if (!addingColumnTitle.trim()) return; if (!addingColumnTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns`, { const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: addingColumnTitle }) body: JSON.stringify({ title: addingColumnTitle })
}); });
if (res.ok) { if (ok) {
const { column } = await res.json(); columns = [...columns, { ...result.column, cards: [] }];
columns = [...columns, { ...column, cards: [] }];
addingColumnTitle = ''; addingColumnTitle = '';
showAddColumn = false; showAddColumn = false;
toast.success('Column created');
} }
} }
// ─── Add Card ──────────────────────────────────── // ─── Add Card ────────────────────────────────────
async function addCard(columnId: string) { async function addCard(columnId: string) {
if (!newCardTitle.trim()) return; if (!newCardTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, { const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: newCardTitle }) body: JSON.stringify({ title: newCardTitle })
}); });
if (res.ok) { if (ok) {
const { card } = await res.json();
const col = columns.find((c: any) => c.id === columnId); const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = [...col.cards, card]; if (col) col.cards = [...col.cards, result.card];
newCardTitle = ''; newCardTitle = '';
addingCardColumnId = null; addingCardColumnId = null;
toast.success('Card added');
} }
} }
// ─── Delete Column ─────────────────────────────── // ─── Delete Column ───────────────────────────────
async function deleteColumn(columnId: string) { async function deleteColumn(columnId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, { const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'DELETE', method: 'DELETE'
headers: { ...socketHeaders() }
}); });
if (res.ok) { if (ok) {
columns = columns.filter((c: any) => c.id !== columnId); columns = columns.filter((c: any) => c.id !== columnId);
toast.success('Column deleted');
} }
} }
// ─── Rename Column ─────────────────────────────── // ─── Rename Column ───────────────────────────────
async function renameColumn(columnId: string) { async function renameColumn(columnId: string) {
if (!editingColumnTitle.trim()) return; if (!editingColumnTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, { const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: editingColumnTitle }) body: JSON.stringify({ title: editingColumnTitle })
}); });
if (res.ok) { if (ok) {
const col = columns.find((c: any) => c.id === columnId); const col = columns.find((c: any) => c.id === columnId);
if (col) col.title = editingColumnTitle; if (col) col.title = editingColumnTitle;
} }
@@ -342,14 +332,14 @@
// ─── Delete Card ───────────────────────────────── // ─── Delete Card ─────────────────────────────────
async function deleteCard(columnId: string, cardId: string) { async function deleteCard(columnId: string, cardId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, { const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'DELETE', method: 'DELETE'
headers: { ...socketHeaders() }
}); });
if (res.ok) { if (ok) {
const col = columns.find((c: any) => c.id === columnId); const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId); if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
if (selectedCard?.id === cardId) selectedCard = null; if (selectedCard?.id === cardId) selectedCard = null;
toast.success('Card deleted');
} }
} }
@@ -386,32 +376,34 @@
/> />
{/if} {/if}
<div class="sr-only" aria-live="polite" id="dnd-announcer"></div>
<div class="flex flex-col h-[calc(100vh-3.5rem)]"> <div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Board header --> <!-- Board header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200"> <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 transition" aria-label="Back to boards"> <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"> <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" /> <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> </svg>
</a> </a>
<h1 class="text-lg font-bold text-gray-900">{data.board.title}</h1> <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500"> <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'} {data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span> </span>
<a <a
href="/boards/{data.board.id}/members" 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 transition" 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'} {data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
</a> </a>
{#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0} {#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0}
<div class="ml-auto flex items-center gap-1.5"> <div class="ml-auto flex items-center gap-1.5">
<span class="text-xs text-gray-400">Also here:</span> <span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span>
<div class="flex -space-x-1.5"> <div class="flex -space-x-1.5">
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer} {#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
<div <div
class="w-6 h-6 rounded-full bg-emerald-500 text-white text-[11px] flex items-center justify-center border-2 border-white" class="w-6 h-6 rounded-full bg-emerald-500 text-white text-[11px] flex items-center justify-center border-2 border-white dark:border-gray-900"
title={viewer.name} title={viewer.name}
> >
{viewer.name[0].toUpperCase()} {viewer.name[0].toUpperCase()}
@@ -423,9 +415,10 @@
<button <button
onclick={() => toggleArchivedCards()} onclick={() => toggleArchivedCards()}
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition flex items-center gap-1" 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} class:bg-gray-200={showArchivedCards}
title="Toggle archived cards" title="Toggle archived cards"
aria-label="Toggle archived cards"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <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 d="M2 3a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1V4a1 1 0 00-1-1H2z" />
@@ -436,9 +429,10 @@
<button <button
onclick={() => (showActivity = !showActivity)} onclick={() => (showActivity = !showActivity)}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition flex items-center gap-1" 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} class:bg-gray-200={showActivity}
title="Toggle activity feed" title="Toggle activity feed"
aria-label="Toggle activity feed"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <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" /> <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" />
@@ -448,7 +442,7 @@
</div> </div>
<!-- Filters bar --> <!-- Filters bar -->
<div class="px-4 py-2 bg-white/60 border-b border-gray-100"> <div class="px-4 py-2 bg-white/60 border-b border-gray-100 dark:bg-gray-900/60 dark:border-gray-800">
<BoardFilters <BoardFilters
{columns} {columns}
members={data.board.members} members={data.board.members}
@@ -474,7 +468,7 @@
{#each columns as column (column.id)} {#each columns as column (column.id)}
{@const visibleCards = filterCards(column.cards)} {@const visibleCards = filterCards(column.cards)}
<div <div
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full" class="flex-shrink-0 w-64 sm:w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
> >
<!-- Column header --> <!-- Column header -->
@@ -486,14 +480,14 @@
> >
<input <input
bind:value={editingColumnTitle} bind:value={editingColumnTitle}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm font-semibold" 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 autofocus
onblur={() => renameColumn(column.id)} onblur={() => renameColumn(column.id)}
/> />
</form> </form>
{:else} {:else}
<button <button
class="text-sm font-semibold text-gray-700 hover:text-gray-900 text-left flex-1" 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={() => { ondblclick={() => {
if (data.canEdit) { if (data.canEdit) {
editingColumnId = column.id; editingColumnId = column.id;
@@ -502,14 +496,15 @@
}} }}
> >
{column.title} {column.title}
<span class="ml-1 text-xs text-gray-400 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span> <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> </button>
{/if} {/if}
{#if data.canEdit} {#if data.canEdit}
<button <button
onclick={() => deleteColumn(column.id)} onclick={() => deleteColumn(column.id)}
class="text-gray-400 hover:text-[var(--color-danger)] transition p-1" class="text-gray-400 dark:text-gray-500 hover:text-[var(--color-danger)] transition p-1"
title="Delete column" title="Delete column"
aria-label="Delete column"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <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" /> <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" />
@@ -534,7 +529,7 @@
{@const progress = getChecklistProgress(card)} {@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)} {@const dueUrgency = getDueUrgency(card.dueDate)}
<button <button
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 transition cursor-pointer" class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 dark:border-gray-600 dark:hover:border-gray-500 transition cursor-pointer"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
onclick={() => (selectedCard = card)} onclick={() => (selectedCard = card)}
> >
@@ -550,9 +545,9 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<span class="text-sm text-gray-800">{card.title}</span> <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} {#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 flex-wrap"> <div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap">
{#if card.dueDate} {#if card.dueDate}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}"> <span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}">
{formatDueDate(card.dueDate)} {formatDueDate(card.dueDate)}
@@ -596,7 +591,7 @@
{/each} {/each}
{#if card.assignees.length > 3} {#if card.assignees.length > 3}
<div <div
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white" 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} +{card.assignees.length - 3}
</div> </div>
@@ -612,10 +607,10 @@
{@const archivedInCol = getArchivedCardsForColumn(column.id)} {@const archivedInCol = getArchivedCardsForColumn(column.id)}
{#if archivedInCol.length > 0} {#if archivedInCol.length > 0}
<div class="px-2 pb-2"> <div class="px-2 pb-2">
<div class="text-[10px] text-gray-400 uppercase tracking-wide mb-1 px-1">Archived</div> <div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1 px-1">Archived</div>
{#each archivedInCol as card} {#each archivedInCol as card}
<div class="mb-1.5 rounded-lg bg-gray-100 p-2.5 border border-gray-200 opacity-60"> <div class="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">
<span class="text-sm text-gray-500">{card.title}</span> <span class="text-sm text-gray-500 dark:text-gray-400">{card.title}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -632,7 +627,7 @@
<textarea <textarea
bind:value={newCardTitle} bind:value={newCardTitle}
placeholder="Enter a title for this card..." placeholder="Enter a title for this card..."
class="w-full rounded-lg border border-gray-300 p-2 text-sm resize-none 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-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
rows="2" rows="2"
autofocus autofocus
onkeydown={(e) => { onkeydown={(e) => {
@@ -656,7 +651,7 @@
<button <button
type="button" type="button"
onclick={() => { addingCardColumnId = null; newCardTitle = ''; }} onclick={() => { addingCardColumnId = null; newCardTitle = ''; }}
class="text-gray-500 hover:text-gray-700 transition text-sm" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition text-sm"
> >
Cancel Cancel
</button> </button>
@@ -666,7 +661,7 @@
{:else} {:else}
<button <button
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }} 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 transition text-left" 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 + Add a card
</button> </button>
@@ -684,7 +679,7 @@
<input <input
bind:value={addingColumnTitle} bind:value={addingColumnTitle}
placeholder="Enter list title..." placeholder="Enter list title..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm 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 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
autofocus autofocus
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }} onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
/> />
@@ -698,7 +693,7 @@
<button <button
type="button" type="button"
onclick={() => { showAddColumn = false; addingColumnTitle = ''; }} onclick={() => { showAddColumn = false; addingColumnTitle = ''; }}
class="text-gray-500 hover:text-gray-700 transition text-sm" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition text-sm"
> >
Cancel Cancel
</button> </button>
@@ -708,7 +703,7 @@
{:else} {:else}
<button <button
onclick={() => (showAddColumn = true)} onclick={() => (showAddColumn = true)}
class="w-full rounded-xl bg-white/60 hover:bg-white/80 border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 transition text-left" 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 + Add another list
</button> </button>
@@ -720,10 +715,10 @@
<!-- Activity sidebar --> <!-- Activity sidebar -->
{#if showActivity} {#if showActivity}
<div class="w-80 flex-shrink-0 border-l border-gray-200 bg-white overflow-y-auto p-4"> <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"> <div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-700">Activity</h2> <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 transition p-1"> <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"> <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" /> <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> </svg>
@@ -79,7 +79,7 @@
<div class="mb-6"> <div class="mb-6">
<a <a
href="/boards/{data.board.id}" href="/boards/{data.board.id}"
class="text-sm text-gray-500 hover:text-gray-700 transition flex items-center gap-1" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition flex items-center gap-1"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path <path
@@ -90,13 +90,13 @@
</svg> </svg>
Back to board Back to board
</a> </a>
<h1 class="text-xl font-bold text-gray-900 mt-2">Members of {data.board.title}</h1> <h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mt-2">Members of {data.board.title}</h1>
</div> </div>
<!-- Add member form --> <!-- Add member form -->
{#if data.canManage} {#if data.canManage}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-4"> <div class="mb-6 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Add member</h2> <h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Add member</h2>
<form <form
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -108,11 +108,11 @@
bind:value={addEmail} bind:value={addEmail}
type="email" type="email"
placeholder="Email address" placeholder="Email address"
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" class="flex-1 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)]"
/> />
<select <select
bind:value={addRole} bind:value={addRole}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" class="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)]"
> >
<option value="EDITOR">Editor</option> <option value="EDITOR">Editor</option>
<option value="VIEWER">Viewer</option> <option value="VIEWER">Viewer</option>
@@ -126,13 +126,13 @@
</button> </button>
</form> </form>
{#if addError} {#if addError}
<p class="mt-2 text-sm text-red-600">{addError}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{addError}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Member list --> <!-- Member list -->
<div class="rounded-xl border border-gray-200 bg-white divide-y divide-gray-100"> <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 divide-y divide-gray-100 dark:divide-gray-800">
{#each members as member (member.id)} {#each members as member (member.id)}
<div class="flex items-center gap-3 px-4 py-3"> <div class="flex items-center gap-3 px-4 py-3">
<div <div
@@ -141,14 +141,14 @@
{member.user.name[0].toUpperCase()} {member.user.name[0].toUpperCase()}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{member.user.name}</p> <p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{member.user.name}</p>
<p class="text-xs text-gray-500 truncate">{member.user.email}</p> <p class="text-xs text-gray-500 dark:text-gray-400 truncate">{member.user.email}</p>
</div> </div>
{#if data.canManage} {#if data.canManage}
<select <select
value={member.role} value={member.role}
onchange={(e) => changeRole(member.id, e.currentTarget.value)} onchange={(e) => changeRole(member.id, e.currentTarget.value)}
class="rounded border border-gray-300 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]" class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
> >
<option value="OWNER">Owner</option> <option value="OWNER">Owner</option>
<option value="EDITOR">Editor</option> <option value="EDITOR">Editor</option>
@@ -156,8 +156,9 @@
</select> </select>
<button <button
onclick={() => removeMember(member.id)} onclick={() => removeMember(member.id)}
class="text-gray-400 hover:text-red-600 transition p-1" class="text-gray-400 hover:text-red-600 dark:text-gray-500 transition p-1"
title="Remove member" title="Remove member"
aria-label="Remove member"
> >
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path <path
@@ -175,7 +176,7 @@
</div> </div>
{/each} {/each}
{#if members.length === 0} {#if members.length === 0}
<div class="px-4 py-8 text-center text-sm text-gray-500">No members yet</div> <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No members yet</div>
{/if} {/if}
</div> </div>
</div> </div>