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