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:
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · {result.columnTitle}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user