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';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-board-bg: #f1f2f4;
--color-column-bg: #ebecf0;
@@ -72,3 +74,28 @@
max-width: 100%;
border-radius: 4px;
}
/* Dark mode overrides */
html.dark {
--color-board-bg: #111827;
--color-column-bg: #1f2937;
--color-card-bg: #374151;
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-danger: #ef4444;
--color-danger-hover: #dc2626;
--color-success: #22c55e;
--color-warning: #eab308;
}
.dark .prose code {
background: #374151;
color: #e5e7eb;
}
.dark .prose blockquote {
border-left-color: #4b5563;
color: #9ca3af;
}
.dark .prose a {
color: var(--color-primary);
}
+1
View File
@@ -23,6 +23,7 @@ declare global {
session: {
id: string;
} | null;
theme?: string;
}
// interface PageData {}
// interface PageState {}
+7
View File
@@ -4,6 +4,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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%
</head>
<body data-sveltekit-preload-data="hover">
+3
View File
@@ -42,5 +42,8 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null;
}
// 3. Read theme preference
event.locals.theme = event.cookies.get('theme') || undefined;
return resolve(event);
};
+11 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
type Props = {
boardId: string;
@@ -71,9 +72,9 @@
url.searchParams.set('limit', '20');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url.toString());
if (!res.ok) return;
return res.json();
const { ok, data } = await api(url.toString());
if (!ok) return;
return data;
}
async function loadMore() {
@@ -117,12 +118,12 @@
<div class="space-y-3">
{#if loading}
<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 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-3/4"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
</div>
{: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}
{#each activities as activity (activity.id)}
<div class="flex gap-2 text-sm">
@@ -133,11 +134,11 @@
{activity.user.name[0].toUpperCase()}
</div>
<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>
{' '}{formatAction(activity)}
</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>
{/each}
@@ -146,7 +147,7 @@
<button
onclick={loadMore}
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'}
</button>
+14 -16
View File
@@ -1,4 +1,6 @@
<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 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) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
if (isAssigned(member.userId)) {
const res = await fetch(base, {
const { ok } = await api(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId })
});
if (res.ok) {
if (ok) {
onupdate(currentAssignees.filter((a) => a.userId !== member.userId));
}
} else {
const res = await fetch(base, {
const { ok, data } = await api(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId })
});
if (res.ok) {
const data = await res.json();
if (ok) {
onupdate([...currentAssignees, data.assignee]);
}
}
@@ -46,26 +45,25 @@
async function removeAssignee(userId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
const res = await fetch(base, {
const { ok } = await api(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (res.ok) {
if (ok) {
onupdate(currentAssignees.filter((a) => a.userId !== userId));
}
}
</script>
<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">
{#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">
{assignee.user.name[0].toUpperCase()}
</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}
<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">
@@ -79,19 +77,19 @@
<div class="relative">
<button
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>
{#if showDropdown}
<!-- 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="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">
{#each boardMembers as member}
<button
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">
{member.user.name[0].toUpperCase()}
+15 -16
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
type Attachment = {
id: string;
filename: string;
@@ -55,35 +57,31 @@
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(
const { ok, data } = await api(
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
{ method: 'POST', body: formData }
);
if (res.ok) {
const data = await res.json();
if (ok) {
localAttachments = [data.attachment, ...localAttachments];
}
} finally {
uploading = false;
input.value = '';
}
}
async function deleteAttachment(attId: string) {
const res = await fetch(`/api/attachments/${attId}`, { method: 'DELETE' });
if (res.ok) {
const { ok } = await api(`/api/attachments/${attId}`, { method: 'DELETE' });
if (ok) {
localAttachments = localAttachments.filter((a) => a.id !== attId);
}
}
</script>
<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}
<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">
<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>
@@ -96,22 +94,23 @@
{#if localAttachments.length > 0}
<div class="space-y-1.5">
{#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">
<span class="text-xs font-mono text-gray-400 w-6 text-center uppercase">
<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 dark:text-gray-500 w-6 text-center uppercase">
{getIcon(att.mimetype)}
</span>
<a
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
>
{att.filename}
</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}
<button
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">
<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}
</div>
{: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}
</div>
+7 -7
View File
@@ -102,7 +102,7 @@
<div class="flex items-center gap-2 flex-wrap">
<button
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">
<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>
{#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 expanded}
<!-- Labels -->
{#if allTags().length > 0}
<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}
<button
onclick={() => toggleLabel(tag.id)}
@@ -139,11 +139,11 @@
<!-- Assignees -->
{#if members.length > 0}
<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}
<button
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}
</button>
@@ -153,11 +153,11 @@
<!-- Due date -->
<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}
<button
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}
</button>
+24 -25
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { trapFocus } from '$lib/utils/focus-trap.js';
import MarkdownEditor from './MarkdownEditor.svelte';
import DueDatePicker from './DueDatePicker.svelte';
import TagPicker from './TagPicker.svelte';
@@ -8,6 +9,7 @@
import CommentList from './CommentList.svelte';
import AttachmentList from './AttachmentList.svelte';
import ActivityFeed from './ActivityFeed.svelte';
import { api } from '$lib/utils/api.js';
type Props = {
card: any;
@@ -32,9 +34,8 @@
let loadingFull = $state(true);
onMount(async () => {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`);
if (res.ok) {
const data = await res.json();
const { ok, data } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`);
if (ok) {
fullCard = data.card;
}
loadingFull = false;
@@ -43,12 +44,11 @@
async function saveTitle() {
if (!title.trim()) return;
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
if (ok) {
card.title = title;
onupdate(card);
}
@@ -57,12 +57,11 @@
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: value })
});
if (res.ok) {
if (ok) {
card.description = value;
if (fullCard) fullCard.description = value;
onupdate(card);
@@ -106,12 +105,11 @@
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: true })
});
if (res.ok) {
if (ok) {
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"
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 -->
<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">
{#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<input
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
onblur={saveTitle}
/>
</form>
{:else}
<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)}
>
{card.title}
</h2>
{/if}
</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">
<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>
@@ -169,7 +168,7 @@
<!-- Description -->
<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
value={card.description || ''}
readonly={!canEdit}
@@ -180,9 +179,9 @@
<!-- Checklists (lazy-loaded) -->
{#if loadingFull}
<div class="animate-pulse space-y-2">
<div class="h-4 bg-gray-200 rounded w-24"></div>
<div class="h-2 bg-gray-100 rounded w-full"></div>
<div class="h-3 bg-gray-100 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
<div class="h-2 bg-gray-100 dark:bg-gray-800 rounded w-full"></div>
<div class="h-3 bg-gray-100 dark:bg-gray-800 rounded w-3/4"></div>
</div>
{:else if fullCard}
<ChecklistSection
@@ -213,7 +212,7 @@
<!-- Activity Log -->
<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} />
</div>
{/if}
@@ -243,17 +242,17 @@
<!-- Actions -->
{#if canEdit}
<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">
<button
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
</button>
<button
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
</button>
+25 -28
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
type ChecklistItem = { id: string; title: string; completed: boolean; position: string };
type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] };
@@ -27,13 +29,11 @@
async function addChecklist() {
if (!newChecklistTitle.trim()) return;
const res = await fetch(basePath, {
const { ok, data } = await api(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newChecklistTitle.trim() })
});
if (res.ok) {
const data = await res.json();
if (ok) {
onupdate([...checklists, data.checklist]);
newChecklistTitle = '';
addingChecklist = false;
@@ -41,8 +41,8 @@
}
async function deleteChecklist(clId: string) {
const res = await fetch(`${basePath}/${clId}`, { method: 'DELETE' });
if (res.ok) {
const { ok } = await api(`${basePath}/${clId}`, { method: 'DELETE' });
if (ok) {
onupdate(checklists.filter((c) => c.id !== clId));
}
}
@@ -50,13 +50,11 @@
async function addItem(clId: string) {
const title = newItemTitles[clId]?.trim();
if (!title) return;
const res = await fetch(`${basePath}/${clId}/items`, {
const { ok, data } = await api(`${basePath}/${clId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
const data = await res.json();
if (ok) {
onupdate(
checklists.map((c) =>
c.id === clId ? { ...c, items: [...c.items, data.item] } : c
@@ -67,12 +65,11 @@
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !item.completed })
});
if (res.ok) {
if (ok) {
onupdate(
checklists.map((c) =>
c.id === clId
@@ -89,8 +86,8 @@
}
async function deleteItem(clId: string, itemId: string) {
const res = await fetch(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' });
if (res.ok) {
const { ok } = await api(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' });
if (ok) {
onupdate(
checklists.map((c) =>
c.id === clId ? { ...c, items: c.items.filter((i) => i.id !== itemId) } : c
@@ -101,21 +98,21 @@
</script>
<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}
{@const p = progress(cl)}
<div class="mb-4 last:mb-0">
<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">
<span class="text-xs text-gray-400">
<span class="text-xs text-gray-400 dark:text-gray-500">
{p.done}/{p.total}
</span>
{#if canEdit}
<button
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
</button>
@@ -125,7 +122,7 @@
<!-- Progress bar -->
{#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
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}%"
@@ -142,17 +139,17 @@
type="checkbox"
checked={item.completed}
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}
<input
type="checkbox"
checked={item.completed}
disabled
class="rounded border-gray-300"
class="rounded border-gray-300 dark:border-gray-600"
/>
{/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}
</span>
{#if canEdit}
@@ -178,12 +175,12 @@
<input
bind:value={newItemTitles[cl.id]}
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
type="submit"
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
</button>
@@ -198,7 +195,7 @@
<input
bind:value={newChecklistTitle}
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
/>
<button
@@ -211,7 +208,7 @@
<button
type="button"
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
</button>
@@ -219,7 +216,7 @@
{:else}
<button
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
</button>
+17 -20
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
import { formatDistanceToNow } from 'date-fns';
import { api } from '$lib/utils/api.js';
type Comment = {
id: string;
@@ -44,13 +45,11 @@
async function addComment() {
if (!newContent.trim()) return;
submitting = true;
const res = await fetch(basePath, {
const { ok, data } = await api(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent.trim() })
});
if (res.ok) {
const data = await res.json();
if (ok) {
localComments = [data.comment, ...localComments];
newContent = '';
}
@@ -59,21 +58,19 @@
async function updateComment(commentId: string) {
if (!editContent.trim()) return;
const res = await fetch(`${basePath}/${commentId}`, {
const { ok, data } = await api(`${basePath}/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContent.trim() })
});
if (res.ok) {
const data = await res.json();
if (ok) {
localComments = localComments.map((c) => (c.id === commentId ? data.comment : c));
editingId = null;
}
}
async function deleteComment(commentId: string) {
const res = await fetch(`${basePath}/${commentId}`, { method: 'DELETE' });
if (res.ok) {
const { ok } = await api(`${basePath}/${commentId}`, { method: 'DELETE' });
if (ok) {
localComments = localComments.filter((c) => c.id !== commentId);
}
}
@@ -85,7 +82,7 @@
</script>
<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 -->
{#if canEdit}
@@ -94,7 +91,7 @@
bind:value={newContent}
rows="3"
placeholder="Write a comment... (Markdown supported)"
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
></textarea>
<div class="flex justify-end mt-1">
<button
@@ -117,8 +114,8 @@
</div>
<div class="flex-1 min-w-0">
<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-xs text-gray-400">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{comment.user.name}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
@@ -127,7 +124,7 @@
<textarea
bind:value={editContent}
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>
<div class="flex gap-2 mt-1">
<button
@@ -138,14 +135,14 @@
</button>
<button
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
</button>
</div>
</div>
{:else}
<div class="prose text-sm text-gray-700">
<div class="prose text-sm text-gray-700 dark:text-gray-300">
{#if ready}
{@html renderMarkdown(comment.content)}
{:else}
@@ -156,7 +153,7 @@
{#if comment.userId === currentUserId}
<button
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
</button>
@@ -164,7 +161,7 @@
{#if comment.userId === currentUserId || canEdit}
<button
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
</button>
@@ -177,6 +174,6 @@
</div>
{#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}
</div>
+12 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { api } from '$lib/utils/api.js';
type Props = {
value: string | null;
@@ -23,12 +24,11 @@
async function setDate(dateStr: string) {
saving = true;
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dueDate })
});
if (res.ok) {
if (ok) {
onupdate(dueDate);
}
picking = false;
@@ -41,19 +41,19 @@
</script>
<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}
<div class="flex items-center gap-2">
<input
type="date"
value={dateValue}
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}
/>
<button
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
</button>
@@ -66,8 +66,9 @@
{#if canEdit}
<button
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"
aria-label="Change due date"
>
<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" />
@@ -75,8 +76,9 @@
</button>
<button
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"
aria-label="Remove due date"
>
<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" />
@@ -87,11 +89,11 @@
{:else if canEdit}
<button
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
</button>
{: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}
</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 class="flex gap-2 mb-2">
<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)}
>
Write
</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)}
>
Preview
</button>
</div>
{#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}
{@html renderMarkdown(draft)}
{:else}
<p class="text-gray-400">{draft}</p>
<p class="text-gray-400 dark:text-gray-500">{draft}</p>
{/if}
</div>
{:else}
<textarea
bind:value={draft}
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)"
></textarea>
{/if}
@@ -79,7 +79,7 @@
</button>
<button
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
</button>
@@ -88,7 +88,7 @@
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<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; }}
>
{#if value}
@@ -96,13 +96,13 @@
{#if ready}
{@html renderMarkdown(value)}
{: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}
</div>
{: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}
<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}
</div>
{/if}
+15 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSocket } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
let notifications = $state<any[]>([]);
let unreadCount = $state(0);
@@ -19,23 +20,21 @@
}
async function fetchNotifications() {
const res = await fetch('/api/notifications?limit=10');
if (!res.ok) return;
const data = await res.json();
const { ok, data } = await api('/api/notifications?limit=10');
if (!ok) return;
notifications = data.notifications;
unreadCount = data.unreadCount;
}
async function markAllRead() {
await fetch('/api/notifications/read-all', { method: 'POST' });
await api('/api/notifications/read-all', { method: 'POST' });
unreadCount = 0;
notifications = notifications.map((n) => ({ ...n, read: true }));
}
async function markRead(id: string) {
await fetch(`/api/notifications/${id}`, {
await api(`/api/notifications/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true })
});
const n = notifications.find((x) => x.id === id);
@@ -78,6 +77,7 @@
onclick={() => { open = !open; }}
class="relative rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
title="Notifications"
aria-label="Notifications"
>
<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" />
@@ -90,9 +90,9 @@
</button>
{#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="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span class="text-sm font-semibold text-gray-700">Notifications</span>
<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 dark:border-gray-700">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Notifications</span>
{#if unreadCount > 0}
<button
onclick={markAllRead}
@@ -105,15 +105,15 @@
<div class="max-h-80 overflow-y-auto">
{#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}
<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}
{#each notifications as n (n.id)}
<a
href={n.link || '#'}
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-shrink-0 mt-0.5">
@@ -132,11 +132,11 @@
{/if}
</div>
<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}
<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}
<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>
{#if !n.read}
<div class="w-2 h-2 rounded-full bg-[var(--color-primary)] flex-shrink-0 mt-1.5"></div>
+8 -11
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/utils/api.js';
let query = $state('');
let results = $state<any[]>([]);
@@ -20,17 +21,13 @@
}
async function doSearch(q: string) {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=10`);
if (res.ok) {
const data = await res.json();
const { ok, data } = await api(`/api/search?q=${encodeURIComponent(q)}&limit=10`);
if (ok) {
results = data.results;
open = results.length > 0;
}
} finally {
loading = false;
}
}
function navigateToCard(result: any) {
open = false;
@@ -68,7 +65,7 @@
oninput={() => debounceSearch(query)}
onkeydown={handleKeydown}
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}
<div class="absolute right-2.5 top-1/2 -translate-y-1/2">
@@ -78,14 +75,14 @@
</div>
{#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}
<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)}
>
<div class="text-sm font-medium text-gray-800 truncate">{result.title}</div>
<div class="text-xs text-gray-500 mt-0.5 truncate">
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">{result.title}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{result.boardTitle} &middot; {result.columnTitle}
</div>
</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">
import { api } from '$lib/utils/api.js';
type Label = { id: string; tagId: string; tag: { id: string; name: string; color: string } };
type Tag = { id: string; name: string; color: string };
@@ -24,9 +26,8 @@
async function fetchTags() {
loading = true;
const res = await fetch('/api/tags');
if (res.ok) {
const data = await res.json();
const { ok, data } = await api('/api/tags');
if (ok) {
allTags = data.tags;
}
loading = false;
@@ -44,23 +45,20 @@
async function toggleTag(tag: Tag) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
if (isApplied(tag.id)) {
const res = await fetch(base, {
const { ok } = await api(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id })
});
if (res.ok) {
if (ok) {
const updated = currentLabels.filter((l) => l.tagId !== tag.id);
onupdate(updated);
}
} else {
const res = await fetch(base, {
const { ok, data } = await api(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id })
});
if (res.ok) {
const data = await res.json();
if (ok) {
onupdate([...currentLabels, data.label]);
}
}
@@ -69,13 +67,11 @@
async function createTag() {
if (!newTagName.trim()) return;
creating = true;
const res = await fetch('/api/tags', {
const { ok, data } = await api('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
});
if (res.ok) {
const data = await res.json();
if (ok) {
allTags = [...allTags, data.tag];
newTagName = '';
}
@@ -84,19 +80,18 @@
async function removeLabel(tagId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
const res = await fetch(base, {
const { ok } = await api(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId })
});
if (res.ok) {
if (ok) {
onupdate(currentLabels.filter((l) => l.tagId !== tagId));
}
}
</script>
<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">
{#each currentLabels as label}
<span
@@ -117,7 +112,7 @@
<div class="relative">
<button
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>
@@ -127,15 +122,15 @@
class="fixed inset-0 z-10"
onclick={() => (showDropdown = false)}
></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}
<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}
<div class="max-h-40 overflow-y-auto space-y-1">
{#each allTags as tag}
<button
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="flex-1 text-left">{tag.name}</span>
@@ -148,7 +143,7 @@
{/each}
</div>
<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">
{#each presetColors as color}
<button
@@ -162,7 +157,7 @@
<input
bind:value={newTagName}
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
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 }) => {
return {
user: locals.user,
tenant: locals.tenant
tenant: locals.tenant,
theme: locals.theme
};
};
+123 -3
View File
@@ -3,12 +3,19 @@
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
import { initTheme, toggleTheme, getTheme } from '$lib/stores/theme.js';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import Toast from '$lib/components/Toast.svelte';
import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte';
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
let { children, data }: { children: Snippet; data: { user: any; tenant: any; theme?: string } } = $props();
let mobileMenuOpen = $state(false);
const theme = $derived(getTheme());
onMount(() => {
initTheme(data.theme);
if (data.user) {
connectSocket();
return () => disconnectSocket();
@@ -16,7 +23,7 @@
});
</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">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 items-center justify-between">
@@ -31,10 +38,29 @@
</a>
{#if data.user}
<div class="hidden sm:block">
<SearchBar />
</div>
{/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.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
<a
@@ -70,9 +96,103 @@
</a>
{/if}
</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>
<!-- 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>
{@render children()}
</div>
<Toast />
<KeyboardShortcuts />
+3 -3
View File
@@ -7,8 +7,8 @@
</svelte:head>
<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>
<p class="text-lg text-gray-600 mb-8 text-center max-w-xl">
<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 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.
</p>
<div class="flex gap-3">
@@ -20,7 +20,7 @@
</a>
<a
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
</a>
+19 -19
View File
@@ -30,42 +30,42 @@
</svelte:head>
<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 -->
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Users</h2>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<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 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm">
<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">Email</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Joined</th>
</tr>
</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)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{user.name}
{#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>
{/if}
</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">
<select
value={user.tenantRole}
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="MEMBER">Member</option>
</select>
</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>
{/each}
</tbody>
@@ -75,27 +75,27 @@
<!-- Boards -->
<section>
<h2 class="text-lg font-semibold text-gray-800 mb-3">Boards</h2>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<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 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm">
<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">Visibility</th>
<th class="px-4 py-3">Members</th>
<th class="px-4 py-3"></th>
</tr>
</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)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{board.title}</td>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{board.title}</td>
<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'}
</span>
</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">
<a
href="/boards/{board.id}"
@@ -108,7 +108,7 @@
{/each}
{#if data.boards.length === 0}
<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>
{/if}
</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="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}
<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}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<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
id="email"
type="email"
bind:value={email}
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"
/>
</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
id="password"
type="password"
bind:value={password}
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"
/>
</div>
@@ -81,7 +81,7 @@
</button>
</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?
<a href="/auth/register" class="text-[var(--color-primary)] hover:underline">Register</a>
</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="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}
<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}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<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
id="name"
type="text"
bind:value={name}
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"
/>
</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
id="email"
type="email"
bind:value={email}
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"
/>
</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
id="password"
type="password"
bind:value={password}
required
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"
/>
</div>
@@ -95,7 +95,7 @@
</button>
</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?
<a href="/auth/login" class="text-[var(--color-primary)] hover:underline">Log in</a>
</p>
+33 -36
View File
@@ -2,6 +2,8 @@
import { invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { replaceState } from '$app/navigation';
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props();
@@ -39,10 +41,8 @@
creating = true;
error = '';
try {
const res = await fetch('/api/boards', {
const { ok, data } = await api('/api/boards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTitle,
visibility: newVisibility,
@@ -50,21 +50,18 @@
...(newTemplateId ? { templateId: newTemplateId } : {})
})
});
const result = await res.json();
if (!res.ok) {
error = result.error || 'Failed to create board';
if (!ok) {
error = data?.error || 'Failed to create board';
creating = false;
return;
}
toast.success('Board created');
newTitle = '';
newCategoryId = '';
newTemplateId = '';
showCreate = false;
invalidateAll();
} catch {
error = 'Network error';
} finally {
creating = false;
}
invalidateAll();
}
</script>
@@ -74,12 +71,12 @@
<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">
<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">
{#if data.user}
<button
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">
<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>
{#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}
<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}
<form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap">
<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
id="board-title"
type="text"
bind:value={newTitle}
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"
/>
</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
id="board-vis"
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="PUBLIC">Public</option>
@@ -127,11 +124,11 @@
</div>
{#if data.categories.length > 0}
<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
id="board-cat"
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>
{#each data.categories as cat}
@@ -142,11 +139,11 @@
{/if}
{#if data.templates.length > 0}
<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
id="board-tpl"
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>
{#each data.templates as tpl}
@@ -165,7 +162,7 @@
<button
type="button"
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
</button>
@@ -176,16 +173,16 @@
<!-- Category filter -->
{#if data.categories.length > 0}
<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
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 = '')}
>
All
</button>
{#each data.categories as cat}
<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)}
>
{cat.name}
@@ -202,9 +199,9 @@
<rect x="14" y="10" width="7" height="7" rx="1" />
<rect x="3" y="13" width="7" height="8" rx="1" />
</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}
<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}
</div>
{:else}
@@ -212,26 +209,26 @@
{#each filteredBoards as board}
<a
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">
<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}
</h2>
<div class="flex items-center gap-1 flex-shrink-0">
{#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
</span>
{/if}
{#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}
</span>
{/if}
</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.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
</div>
@@ -239,14 +236,14 @@
<div class="mt-3 flex -space-x-1">
{#each board.members.slice(0, 5) as member}
<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}
>
{member.user.name[0].toUpperCase()}
</div>
{/each}
{#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}
</div>
{/if}
+63 -68
View File
@@ -5,6 +5,8 @@
import { generatePosition } from '$lib/utils/fractional-index.js';
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte';
@@ -75,9 +77,8 @@
async function toggleArchivedCards() {
showArchivedCards = !showArchivedCards;
if (showArchivedCards && archivedCards.length === 0) {
const res = await fetch(`/api/boards/${data.board.id}/archived-cards`);
if (res.ok) {
const result = await res.json();
const { ok, data: result } = await api(`/api/boards/${data.board.id}/archived-cards`);
if (ok) {
archivedCards = result.cards;
}
}
@@ -87,12 +88,6 @@
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 ────────────────────
function isSelf(d: any) {
return d?.socketId && d.socketId === getSocketId();
@@ -134,9 +129,9 @@
const cardId = d.cardId || d.card?.id;
if (!colId || !cardId) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
if (!res.ok) return;
const { card: freshCard } = await res.json();
const { ok, data: result } = await api(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
if (!ok) return;
const freshCard = result.card;
// The card might have moved columns — remove from old location first
for (const col of columns) {
@@ -247,9 +242,8 @@
if (col.id === e.detail.info.id) {
const newPos = generatePosition(before, after);
col.position = newPos;
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
api(`/api/boards/${data.board.id}/columns/${col.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ position: newPos })
});
}
@@ -273,9 +267,8 @@
const newPos = generatePosition(before, after);
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',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ columnId, position: newPos })
});
}
@@ -284,56 +277,53 @@
// ─── Add Column ──────────────────────────────────
async function addColumn() {
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',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: addingColumnTitle })
});
if (res.ok) {
const { column } = await res.json();
columns = [...columns, { ...column, cards: [] }];
if (ok) {
columns = [...columns, { ...result.column, cards: [] }];
addingColumnTitle = '';
showAddColumn = false;
toast.success('Column created');
}
}
// ─── Add Card ────────────────────────────────────
async function addCard(columnId: string) {
if (!newCardTitle.trim()) return;
const 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',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: newCardTitle })
});
if (res.ok) {
const { card } = await res.json();
if (ok) {
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 = '';
addingCardColumnId = null;
toast.success('Card added');
}
}
// ─── Delete Column ───────────────────────────────
async function deleteColumn(columnId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'DELETE',
headers: { ...socketHeaders() }
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'DELETE'
});
if (res.ok) {
if (ok) {
columns = columns.filter((c: any) => c.id !== columnId);
toast.success('Column deleted');
}
}
// ─── Rename Column ───────────────────────────────
async function renameColumn(columnId: string) {
if (!editingColumnTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
body: JSON.stringify({ title: editingColumnTitle })
});
if (res.ok) {
if (ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.title = editingColumnTitle;
}
@@ -342,14 +332,14 @@
// ─── Delete Card ─────────────────────────────────
async function deleteCard(columnId: string, cardId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'DELETE',
headers: { ...socketHeaders() }
const { ok } = await api(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'DELETE'
});
if (res.ok) {
if (ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
if (selectedCard?.id === cardId) selectedCard = null;
toast.success('Card deleted');
}
}
@@ -386,32 +376,34 @@
/>
{/if}
<div class="sr-only" aria-live="polite" id="dnd-announcer"></div>
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Board header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200">
<a href="/boards" class="text-gray-400 hover:text-gray-600 transition" aria-label="Back to boards">
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200 dark:bg-gray-900/80 dark:border-gray-700 flex-wrap">
<a href="/boards" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition" aria-label="Back to boards">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900">{data.board.title}</h1>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
<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 dark:bg-gray-800 dark:text-gray-400">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span>
<a
href="/boards/{data.board.id}/members"
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200 hover:text-gray-700 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'}
</a>
{#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0}
<div class="ml-auto flex items-center gap-1.5">
<span class="text-xs text-gray-400">Also here:</span>
<span class="text-xs text-gray-400 dark:text-gray-500">Also here:</span>
<div class="flex -space-x-1.5">
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
<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}
>
{viewer.name[0].toUpperCase()}
@@ -423,9 +415,10 @@
<button
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}
title="Toggle archived cards"
aria-label="Toggle archived cards"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 3a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1V4a1 1 0 00-1-1H2z" />
@@ -436,9 +429,10 @@
<button
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}
title="Toggle activity feed"
aria-label="Toggle activity feed"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
@@ -448,7 +442,7 @@
</div>
<!-- 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
{columns}
members={data.board.members}
@@ -474,7 +468,7 @@
{#each columns as column (column.id)}
{@const visibleCards = filterCards(column.cards)}
<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 }}
>
<!-- Column header -->
@@ -486,14 +480,14 @@
>
<input
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
onblur={() => renameColumn(column.id)}
/>
</form>
{:else}
<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={() => {
if (data.canEdit) {
editingColumnId = column.id;
@@ -502,14 +496,15 @@
}}
>
{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>
{/if}
{#if data.canEdit}
<button
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"
aria-label="Delete column"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
@@ -534,7 +529,7 @@
{@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)}
<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 }}
onclick={() => (selectedCard = card)}
>
@@ -550,9 +545,9 @@
{/each}
</div>
{/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}
<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}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}">
{formatDueDate(card.dueDate)}
@@ -596,7 +591,7 @@
{/each}
{#if card.assignees.length > 3}
<div
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white"
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white dark:bg-gray-600 dark:text-gray-300 dark:border-gray-700"
>
+{card.assignees.length - 3}
</div>
@@ -612,10 +607,10 @@
{@const archivedInCol = getArchivedCardsForColumn(column.id)}
{#if archivedInCol.length > 0}
<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}
<div class="mb-1.5 rounded-lg bg-gray-100 p-2.5 border border-gray-200 opacity-60">
<span class="text-sm text-gray-500">{card.title}</span>
<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 dark:text-gray-400">{card.title}</span>
</div>
{/each}
</div>
@@ -632,7 +627,7 @@
<textarea
bind:value={newCardTitle}
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"
autofocus
onkeydown={(e) => {
@@ -656,7 +651,7 @@
<button
type="button"
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
</button>
@@ -666,7 +661,7 @@
{:else}
<button
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }}
class="mx-2 mb-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-200/50 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
</button>
@@ -684,7 +679,7 @@
<input
bind:value={addingColumnTitle}
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
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
/>
@@ -698,7 +693,7 @@
<button
type="button"
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
</button>
@@ -708,7 +703,7 @@
{:else}
<button
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
</button>
@@ -720,10 +715,10 @@
<!-- Activity sidebar -->
{#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">
<h2 class="text-sm font-semibold text-gray-700">Activity</h2>
<button onclick={() => (showActivity = false)} class="text-gray-400 hover:text-gray-600 transition p-1">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Activity</h2>
<button onclick={() => (showActivity = false)} class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition p-1" aria-label="Close activity">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
@@ -79,7 +79,7 @@
<div class="mb-6">
<a
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">
<path
@@ -90,13 +90,13 @@
</svg>
Back to board
</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>
<!-- Add member form -->
{#if data.canManage}
<div class="mb-6 rounded-xl border border-gray-200 bg-white p-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Add member</h2>
<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 dark:text-gray-300 mb-3">Add member</h2>
<form
onsubmit={(e) => {
e.preventDefault();
@@ -108,11 +108,11 @@
bind:value={addEmail}
type="email"
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
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="VIEWER">Viewer</option>
@@ -126,13 +126,13 @@
</button>
</form>
{#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}
</div>
{/if}
<!-- 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)}
<div class="flex items-center gap-3 px-4 py-3">
<div
@@ -141,14 +141,14 @@
{member.user.name[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{member.user.name}</p>
<p class="text-xs text-gray-500 truncate">{member.user.email}</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 dark:text-gray-400 truncate">{member.user.email}</p>
</div>
{#if data.canManage}
<select
value={member.role}
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="EDITOR">Editor</option>
@@ -156,8 +156,9 @@
</select>
<button
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"
aria-label="Remove member"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path
@@ -175,7 +176,7 @@
</div>
{/each}
{#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}
</div>
</div>