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
+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()}
+21 -22
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(
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
{ method: 'POST', body: formData }
);
if (res.ok) {
const data = await res.json();
localAttachments = [data.attachment, ...localAttachments];
}
} finally {
uploading = false;
input.value = '';
const { ok, data } = await api(
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
{ method: 'POST', body: formData }
);
if (ok) {
localAttachments = [data.attachment, ...localAttachments];
}
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>
+11 -14
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,16 +21,12 @@
}
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();
results = data.results;
open = results.length > 0;
}
} finally {
loading = false;
const { ok, data } = await api(`/api/search?q=${encodeURIComponent(q)}&limit=10`);
if (ok) {
results = data.results;
open = results.length > 0;
}
loading = false;
}
function navigateToCard(result: any) {
@@ -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>