Phase 4: Rich card features — labels, checklists, comments, attachments, categories
Add full CRUD + UI for all card sub-resources (tags/labels, assignees, checklists with items, comments with markdown, file attachments). Restructure CardModal into a two-column layout with lazy-loaded data. Upgrade card thumbnails with named label pills, urgency-colored due date badges, checklist progress indicators, and SVG icons. Add board categories with filtering on the listing page. Include markdown rendering with DOMPurify sanitization and .prose CSS styles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+22
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "kanban",
|
"name": "pounce",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kanban",
|
"name": "pounce",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
@@ -1438,6 +1440,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1996,6 +2007,15 @@
|
|||||||
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
|
|||||||
+54
@@ -18,3 +18,57 @@
|
|||||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.prose h1, .prose h2, .prose h3 {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
.prose h1 { font-size: 1.25em; }
|
||||||
|
.prose h2 { font-size: 1.125em; }
|
||||||
|
.prose h3 { font-size: 1em; }
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose ul { list-style-type: disc; }
|
||||||
|
.prose ol { list-style-type: decimal; }
|
||||||
|
.prose code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.prose pre {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.prose a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 3px solid #d1d5db;
|
||||||
|
padding-left: 1em;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 Props = {
|
||||||
|
cardId: string;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
currentAssignees: Assignee[];
|
||||||
|
boardMembers: Member[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onupdate: (assignees: Assignee[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { cardId, boardId, columnId, currentAssignees, boardMembers, canEdit, onupdate }: Props = $props();
|
||||||
|
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
|
||||||
|
function isAssigned(userId: string): boolean {
|
||||||
|
return currentAssignees.some((a) => a.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAssignee(member: Member) {
|
||||||
|
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
|
||||||
|
if (isAssigned(member.userId)) {
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: member.userId })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(currentAssignees.filter((a) => a.userId !== member.userId));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: member.userId })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
onupdate([...currentAssignees, data.assignee]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAssignee(userId: string) {
|
||||||
|
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(currentAssignees.filter((a) => a.userId !== userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each currentAssignees as assignee}
|
||||||
|
<div class="flex items-center gap-1 rounded-full bg-gray-100 pl-1 pr-2 py-0.5">
|
||||||
|
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
|
||||||
|
{assignee.user.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-700">{assignee.user.name}</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5">
|
||||||
|
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={() => (showDropdown = !showDropdown)}
|
||||||
|
class="inline-flex items-center rounded-full bg-gray-100 w-6 h-6 text-xs text-gray-500 hover:bg-gray-200 justify-center"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{#if showDropdown}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 z-10" onclick={() => (showDropdown = false)}></div>
|
||||||
|
<div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white shadow-lg border border-gray-200 p-1">
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
{#each boardMembers as member}
|
||||||
|
<button
|
||||||
|
onclick={() => toggleAssignee(member)}
|
||||||
|
class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 {isAssigned(member.userId) ? 'bg-blue-50' : ''}"
|
||||||
|
>
|
||||||
|
<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()}
|
||||||
|
</div>
|
||||||
|
<span class="flex-1 text-left">{member.user.name}</span>
|
||||||
|
{#if isAssigned(member.userId)}
|
||||||
|
<svg class="w-4 h-4 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Attachment = {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cardId: string;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
attachments: Attachment[];
|
||||||
|
canEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { cardId, boardId, columnId, attachments = [], canEdit }: Props = $props();
|
||||||
|
|
||||||
|
let localAttachments = $state<Attachment[]>(attachments);
|
||||||
|
let uploading = $state(false);
|
||||||
|
let uploadProgress = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localAttachments = attachments;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(mimetype: string): string {
|
||||||
|
if (mimetype.startsWith('image/')) return 'img';
|
||||||
|
if (mimetype.startsWith('video/')) return 'vid';
|
||||||
|
if (mimetype.includes('pdf')) return 'pdf';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert('File too large (max 10MB)');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
uploadProgress = 0;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
localAttachments = [data.attachment, ...localAttachments];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAttachment(attId: string) {
|
||||||
|
const res = await fetch(`/api/attachments/${attId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
localAttachments = localAttachments.filter((a) => a.id !== attId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Attachments</h3>
|
||||||
|
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="inline-flex items-center gap-1.5 rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-200 cursor-pointer transition">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{uploading ? 'Uploading...' : 'Attach file'}
|
||||||
|
<input type="file" class="hidden" onchange={handleUpload} disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if localAttachments.length > 0}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{#each localAttachments as att}
|
||||||
|
<div class="flex items-center gap-2 rounded border border-gray-100 px-2 py-1.5 hover:bg-gray-50">
|
||||||
|
<span class="text-xs font-mono text-gray-400 w-6 text-center uppercase">
|
||||||
|
{getIcon(att.mimetype)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/api/attachments/{att.id}"
|
||||||
|
class="flex-1 text-sm text-gray-700 hover:text-[var(--color-primary)] truncate"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
{att.filename}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-400 flex-shrink-0">{formatSize(att.size)}</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteAttachment(att.id)}
|
||||||
|
class="text-gray-300 hover:text-red-500 transition"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-400 italic">No attachments</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
+175
-108
@@ -1,44 +1,71 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||||
|
import DueDatePicker from './DueDatePicker.svelte';
|
||||||
|
import TagPicker from './TagPicker.svelte';
|
||||||
|
import AssigneePicker from './AssigneePicker.svelte';
|
||||||
|
import ChecklistSection from './ChecklistSection.svelte';
|
||||||
|
import CommentList from './CommentList.svelte';
|
||||||
|
import AttachmentList from './AttachmentList.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
card: any;
|
card: any;
|
||||||
boardId: string;
|
boardId: string;
|
||||||
columnId: string | undefined;
|
columnId: string | undefined;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
boardMembers: any[];
|
||||||
|
currentUserId: string;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
ondelete: (cardId: string) => void;
|
ondelete: (cardId: string) => void;
|
||||||
|
onupdate: (card: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { card, boardId, columnId, canEdit, onclose, ondelete }: Props = $props();
|
let { card, boardId, columnId, canEdit, boardMembers, currentUserId, onclose, ondelete, onupdate }: Props = $props();
|
||||||
|
|
||||||
let editingTitle = $state(false);
|
let editingTitle = $state(false);
|
||||||
let title = $state(card.title);
|
let title = $state(card.title);
|
||||||
let description = $state(card.description || '');
|
|
||||||
let editingDescription = $state(false);
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// Full card data (lazy-loaded)
|
||||||
|
let fullCard = $state<any>(null);
|
||||||
|
let loadingFull = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
fullCard = data.card;
|
||||||
|
}
|
||||||
|
loadingFull = false;
|
||||||
|
});
|
||||||
|
|
||||||
async function saveTitle() {
|
async function saveTitle() {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title })
|
body: JSON.stringify({ title })
|
||||||
});
|
});
|
||||||
card.title = title;
|
if (res.ok) {
|
||||||
|
card.title = title;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDescription() {
|
async function saveDescription(value: string) {
|
||||||
saving = true;
|
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ description })
|
body: JSON.stringify({ description: value })
|
||||||
});
|
});
|
||||||
card.description = description;
|
if (res.ok) {
|
||||||
editingDescription = false;
|
card.description = value;
|
||||||
saving = false;
|
if (fullCard) fullCard.description = value;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
@@ -48,16 +75,55 @@
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') onclose();
|
if (e.key === 'Escape') onclose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateLabels(labels: any[]) {
|
||||||
|
card.labels = labels;
|
||||||
|
if (fullCard) fullCard.labels = labels;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssignees(assignees: any[]) {
|
||||||
|
card.assignees = assignees;
|
||||||
|
if (fullCard) fullCard.assignees = assignees;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDueDate(dueDate: string | null) {
|
||||||
|
card.dueDate = dueDate;
|
||||||
|
if (fullCard) fullCard.dueDate = dueDate;
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChecklists(checklists: any[]) {
|
||||||
|
if (fullCard) fullCard.checklists = checklists;
|
||||||
|
// Update lightweight checklist data on the card for thumbnail progress
|
||||||
|
card.checklists = checklists.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
items: c.items.map((i: any) => ({ completed: i.completed }))
|
||||||
|
}));
|
||||||
|
onupdate(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveCard() {
|
||||||
|
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ archived: true })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
ondelete(card.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- 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="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 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-2xl bg-white rounded-xl shadow-2xl">
|
<div class="w-full max-w-4xl bg-white rounded-xl shadow-2xl">
|
||||||
<!-- 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">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -86,107 +152,108 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body: Two-column layout -->
|
||||||
<div class="p-4 space-y-4">
|
<div class="flex flex-col md:flex-row p-4 gap-4">
|
||||||
<!-- Labels -->
|
<!-- Main content -->
|
||||||
{#if card.labels && card.labels.length > 0}
|
<div class="flex-1 space-y-5 min-w-0">
|
||||||
<div>
|
<!-- Labels -->
|
||||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
|
<TagPicker
|
||||||
<div class="flex flex-wrap gap-1">
|
cardId={card.id}
|
||||||
{#each card.labels as label}
|
{boardId}
|
||||||
<span
|
{columnId}
|
||||||
class="rounded px-2 py-0.5 text-xs text-white"
|
currentLabels={card.labels || []}
|
||||||
style="background-color: {label.tag.color}"
|
{canEdit}
|
||||||
>
|
onupdate={updateLabels}
|
||||||
{label.tag.name}
|
/>
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- 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 uppercase mb-2">Description</h3>
|
||||||
{#if editingDescription && canEdit}
|
<MarkdownEditor
|
||||||
<div>
|
value={card.description || ''}
|
||||||
<textarea
|
readonly={!canEdit}
|
||||||
bind:value={description}
|
onsave={saveDescription}
|
||||||
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)]"
|
</div>
|
||||||
placeholder="Add a more detailed description..."
|
|
||||||
></textarea>
|
<!-- Checklists (lazy-loaded) -->
|
||||||
<div class="flex gap-2 mt-2">
|
{#if loadingFull}
|
||||||
<button
|
<div class="animate-pulse space-y-2">
|
||||||
onclick={saveDescription}
|
<div class="h-4 bg-gray-200 rounded w-24"></div>
|
||||||
disabled={saving}
|
<div class="h-2 bg-gray-100 rounded w-full"></div>
|
||||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
<div class="h-3 bg-gray-100 rounded w-3/4"></div>
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { editingDescription = false; description = card.description || ''; }}
|
|
||||||
class="text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="{canEdit ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
|
|
||||||
onclick={() => canEdit && (editingDescription = true)}
|
|
||||||
role={canEdit ? 'button' : undefined}
|
|
||||||
tabindex={canEdit ? 0 : undefined}
|
|
||||||
onkeydown={(e) => { if (canEdit && (e.key === 'Enter' || e.key === ' ')) editingDescription = true; }}
|
|
||||||
>
|
|
||||||
{#if card.description}
|
|
||||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{card.description}</p>
|
|
||||||
{:else if canEdit}
|
|
||||||
<p class="text-sm text-gray-400">Add a more detailed description...</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-400 italic">No description</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else if fullCard}
|
||||||
|
<ChecklistSection
|
||||||
|
cardId={card.id}
|
||||||
|
{boardId}
|
||||||
|
{columnId}
|
||||||
|
checklists={fullCard.checklists || []}
|
||||||
|
{canEdit}
|
||||||
|
onupdate={updateChecklists}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AttachmentList
|
||||||
|
cardId={card.id}
|
||||||
|
{boardId}
|
||||||
|
{columnId}
|
||||||
|
attachments={fullCard.attachments || []}
|
||||||
|
{canEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommentList
|
||||||
|
cardId={card.id}
|
||||||
|
{boardId}
|
||||||
|
{columnId}
|
||||||
|
comments={fullCard.comments || []}
|
||||||
|
{currentUserId}
|
||||||
|
{canEdit}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Due date -->
|
<!-- Sidebar -->
|
||||||
{#if card.dueDate}
|
<div class="w-full md:w-48 space-y-4 flex-shrink-0">
|
||||||
<div>
|
<DueDatePicker
|
||||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
|
value={card.dueDate || null}
|
||||||
<p class="text-sm text-gray-700">{new Date(card.dueDate).toLocaleDateString()}</p>
|
{canEdit}
|
||||||
</div>
|
{boardId}
|
||||||
{/if}
|
{columnId}
|
||||||
|
cardId={card.id}
|
||||||
|
onupdate={updateDueDate}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Assignees -->
|
<AssigneePicker
|
||||||
{#if card.assignees && card.assignees.length > 0}
|
cardId={card.id}
|
||||||
<div>
|
{boardId}
|
||||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
|
{columnId}
|
||||||
<div class="flex flex-wrap gap-2">
|
currentAssignees={card.assignees || []}
|
||||||
{#each card.assignees as assignee}
|
{boardMembers}
|
||||||
<div class="flex items-center gap-1.5 rounded-full bg-gray-100 pl-1 pr-2.5 py-0.5">
|
{canEdit}
|
||||||
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
|
onupdate={updateAssignees}
|
||||||
{assignee.user.name[0].toUpperCase()}
|
/>
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-700">{assignee.user.name}</span>
|
<!-- Actions -->
|
||||||
</div>
|
{#if canEdit}
|
||||||
{/each}
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Actions</h3>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
onclick={archiveCard}
|
||||||
|
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 transition"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => ondelete(card.id)}
|
||||||
|
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
{#if canEdit}
|
|
||||||
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-100">
|
|
||||||
<button
|
|
||||||
onclick={() => ondelete(card.id)}
|
|
||||||
class="rounded px-3 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
|
|
||||||
>
|
|
||||||
Delete card
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type ChecklistItem = { id: string; title: string; completed: boolean; position: string };
|
||||||
|
type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cardId: string;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
checklists: Checklist[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onupdate: (checklists: Checklist[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { cardId, boardId, columnId, checklists, canEdit, onupdate }: Props = $props();
|
||||||
|
|
||||||
|
let newChecklistTitle = $state('');
|
||||||
|
let addingChecklist = $state(false);
|
||||||
|
let newItemTitles = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/checklists`);
|
||||||
|
|
||||||
|
function progress(cl: Checklist): { done: number; total: number } {
|
||||||
|
const total = cl.items.length;
|
||||||
|
const done = cl.items.filter((i) => i.completed).length;
|
||||||
|
return { done, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addChecklist() {
|
||||||
|
if (!newChecklistTitle.trim()) return;
|
||||||
|
const res = await fetch(basePath, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: newChecklistTitle.trim() })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
onupdate([...checklists, data.checklist]);
|
||||||
|
newChecklistTitle = '';
|
||||||
|
addingChecklist = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChecklist(clId: string) {
|
||||||
|
const res = await fetch(`${basePath}/${clId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(checklists.filter((c) => c.id !== clId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addItem(clId: string) {
|
||||||
|
const title = newItemTitles[clId]?.trim();
|
||||||
|
if (!title) return;
|
||||||
|
const res = await fetch(`${basePath}/${clId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
onupdate(
|
||||||
|
checklists.map((c) =>
|
||||||
|
c.id === clId ? { ...c, items: [...c.items, data.item] } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
newItemTitles[clId] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleItem(clId: string, item: ChecklistItem) {
|
||||||
|
const res = await fetch(`${basePath}/${clId}/items/${item.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ completed: !item.completed })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(
|
||||||
|
checklists.map((c) =>
|
||||||
|
c.id === clId
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
items: c.items.map((i) =>
|
||||||
|
i.id === item.id ? { ...i, completed: !i.completed } : i
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(clId: string, itemId: string) {
|
||||||
|
const res = await fetch(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(
|
||||||
|
checklists.map((c) =>
|
||||||
|
c.id === clId ? { ...c, items: c.items.filter((i) => i.id !== itemId) } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Checklists</h3>
|
||||||
|
|
||||||
|
{#each checklists as cl}
|
||||||
|
{@const p = progress(cl)}
|
||||||
|
<div class="mb-4 last:mb-0">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm font-medium text-gray-700">{cl.title}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{p.done}/{p.total}
|
||||||
|
</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteChecklist(cl.id)}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
{#if p.total > 0}
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full transition-all {p.done === p.total ? 'bg-[var(--color-success)]' : 'bg-[var(--color-primary)]'}"
|
||||||
|
style="width: {(p.done / p.total) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each cl.items as item}
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
{#if canEdit}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.completed}
|
||||||
|
onchange={() => toggleItem(cl.id, item)}
|
||||||
|
class="rounded border-gray-300 text-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.completed}
|
||||||
|
disabled
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 text-sm {item.completed ? 'text-gray-400 line-through' : 'text-gray-700'}">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteItem(cl.id, item.id)}
|
||||||
|
class="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add item -->
|
||||||
|
{#if canEdit}
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => { e.preventDefault(); addItem(cl.id); }}
|
||||||
|
class="flex gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:value={newItemTitles[cl.id]}
|
||||||
|
placeholder="Add an item..."
|
||||||
|
class="flex-1 rounded border border-gray-200 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newItemTitles[cl.id]?.trim()}
|
||||||
|
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canEdit}
|
||||||
|
{#if addingChecklist}
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); addChecklist(); }} class="flex gap-1 mt-2">
|
||||||
|
<input
|
||||||
|
bind:value={newChecklistTitle}
|
||||||
|
placeholder="Checklist title..."
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newChecklistTitle.trim()}
|
||||||
|
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { addingChecklist = false; newChecklistTitle = ''; }}
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (addingChecklist = true)}
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600 mt-1"
|
||||||
|
>
|
||||||
|
+ Add checklist
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
userId: string;
|
||||||
|
user: { id: string; name: string; avatarUrl: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cardId: string;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
comments: Comment[];
|
||||||
|
currentUserId: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { cardId, boardId, columnId, comments = [], currentUserId, canEdit }: Props = $props();
|
||||||
|
|
||||||
|
let localComments = $state<Comment[]>(comments);
|
||||||
|
let newContent = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editContent = $state('');
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initDOMPurify();
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localComments = comments;
|
||||||
|
});
|
||||||
|
|
||||||
|
const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/comments`);
|
||||||
|
|
||||||
|
async function addComment() {
|
||||||
|
if (!newContent.trim()) return;
|
||||||
|
submitting = true;
|
||||||
|
const res = await fetch(basePath, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: newContent.trim() })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
localComments = [data.comment, ...localComments];
|
||||||
|
newContent = '';
|
||||||
|
}
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateComment(commentId: string) {
|
||||||
|
if (!editContent.trim()) return;
|
||||||
|
const res = await fetch(`${basePath}/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: editContent.trim() })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
localComments = localComments.map((c) => (c.id === commentId ? data.comment : c));
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(commentId: string) {
|
||||||
|
const res = await fetch(`${basePath}/${commentId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
localComments = localComments.filter((c) => c.id !== commentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(comment: Comment) {
|
||||||
|
editingId = comment.id;
|
||||||
|
editContent = comment.content;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Comments</h3>
|
||||||
|
|
||||||
|
<!-- Add comment -->
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="mb-4">
|
||||||
|
<textarea
|
||||||
|
bind:value={newContent}
|
||||||
|
rows="3"
|
||||||
|
placeholder="Write a comment... (Markdown supported)"
|
||||||
|
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex justify-end mt-1">
|
||||||
|
<button
|
||||||
|
onclick={addComment}
|
||||||
|
disabled={submitting || !newContent.trim()}
|
||||||
|
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Posting...' : 'Comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Comment list -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each localComments as comment}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
{comment.user.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
|
<span class="text-sm font-medium text-gray-800">{comment.user.name}</span>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if editingId === comment.id}
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
bind:value={editContent}
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded border border-gray-300 p-2 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
onclick={() => updateComment(comment.id)}
|
||||||
|
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (editingId = null)}
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="prose text-sm text-gray-700">
|
||||||
|
{#if ready}
|
||||||
|
{@html renderMarkdown(comment.content)}
|
||||||
|
{:else}
|
||||||
|
<p class="whitespace-pre-wrap">{comment.content}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mt-1">
|
||||||
|
{#if comment.userId === currentUserId}
|
||||||
|
<button
|
||||||
|
onclick={() => startEdit(comment)}
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if comment.userId === currentUserId || canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteComment(comment.id)}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localComments.length === 0}
|
||||||
|
<p class="text-xs text-gray-400 italic">No comments yet</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
cardId: string;
|
||||||
|
onupdate: (dueDate: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { value, canEdit, boardId, columnId, cardId, onupdate }: Props = $props();
|
||||||
|
|
||||||
|
let picking = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const urgency = $derived(getDueUrgency(value));
|
||||||
|
const urgencyClasses = $derived(getUrgencyClasses(urgency));
|
||||||
|
const formatted = $derived(formatDueDate(value));
|
||||||
|
const dateValue = $derived(value ? new Date(value).toISOString().split('T')[0] : '');
|
||||||
|
|
||||||
|
async function setDate(dateStr: string) {
|
||||||
|
saving = true;
|
||||||
|
const dueDate = dateStr || null;
|
||||||
|
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dueDate })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(dueDate);
|
||||||
|
}
|
||||||
|
picking = false;
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDate() {
|
||||||
|
await setDate('');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
|
||||||
|
{#if picking && canEdit}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateValue}
|
||||||
|
onchange={(e) => setDate(e.currentTarget.value)}
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={() => (picking = false)}
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if value}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium {urgencyClasses}">
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => (picking = true)}
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
title="Change date"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={clearDate}
|
||||||
|
class="text-xs text-gray-400 hover:text-red-500"
|
||||||
|
title="Remove date"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => (picking = true)}
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
+ Set due date
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-400 italic">No due date</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
onsave?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { value, readonly = false, onsave }: Props = $props();
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let draft = $state(value);
|
||||||
|
let previewing = $state(false);
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initDOMPurify();
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
onsave?.(draft);
|
||||||
|
editing = false;
|
||||||
|
previewing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
draft = value;
|
||||||
|
editing = false;
|
||||||
|
previewing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep draft in sync when value prop changes externally
|
||||||
|
$effect(() => {
|
||||||
|
if (!editing) draft = value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if editing && !readonly}
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
class="text-xs px-2 py-1 rounded {!previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}"
|
||||||
|
onclick={() => (previewing = false)}
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-xs px-2 py-1 rounded {previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}"
|
||||||
|
onclick={() => (previewing = true)}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if previewing}
|
||||||
|
<div class="prose rounded-lg border border-gray-200 p-3 min-h-[120px] text-sm">
|
||||||
|
{#if ready}
|
||||||
|
{@html renderMarkdown(draft)}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400">{draft}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<textarea
|
||||||
|
bind:value={draft}
|
||||||
|
rows="6"
|
||||||
|
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
placeholder="Add a more detailed description... (Markdown supported)"
|
||||||
|
></textarea>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onclick={save}
|
||||||
|
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={cancel}
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="{!readonly ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
|
||||||
|
onclick={() => { if (!readonly) editing = true; }}
|
||||||
|
>
|
||||||
|
{#if value}
|
||||||
|
<div class="prose text-sm">
|
||||||
|
{#if ready}
|
||||||
|
{@html renderMarkdown(value)}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-700 whitespace-pre-wrap">{value}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !readonly}
|
||||||
|
<p class="text-sm text-gray-400">Add a more detailed description...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-400 italic">No description</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Label = { id: string; tagId: string; tag: { id: string; name: string; color: string } };
|
||||||
|
type Tag = { id: string; name: string; color: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cardId: string;
|
||||||
|
boardId: string;
|
||||||
|
columnId: string | undefined;
|
||||||
|
currentLabels: Label[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onupdate: (labels: Label[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { cardId, boardId, columnId, currentLabels, canEdit, onupdate }: Props = $props();
|
||||||
|
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let allTags = $state<Tag[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let newTagName = $state('');
|
||||||
|
let newTagColor = $state('#0079bf');
|
||||||
|
|
||||||
|
const presetColors = ['#0079bf', '#61bd4f', '#f2d600', '#ff9f1a', '#eb5a46', '#c377e0', '#00c2e0', '#51e898'];
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
loading = true;
|
||||||
|
const res = await fetch('/api/tags');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
allTags = data.tags;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
showDropdown = true;
|
||||||
|
fetchTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApplied(tagId: string): boolean {
|
||||||
|
return currentLabels.some((l) => l.tagId === tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTag(tag: Tag) {
|
||||||
|
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
|
||||||
|
if (isApplied(tag.id)) {
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tagId: tag.id })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = currentLabels.filter((l) => l.tagId !== tag.id);
|
||||||
|
onupdate(updated);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tagId: tag.id })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
onupdate([...currentLabels, data.label]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag() {
|
||||||
|
if (!newTagName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
const res = await fetch('/api/tags', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
allTags = [...allTags, data.tag];
|
||||||
|
newTagName = '';
|
||||||
|
}
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLabel(tagId: string) {
|
||||||
|
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tagId })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onupdate(currentLabels.filter((l) => l.tagId !== tagId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each currentLabels as label}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-white"
|
||||||
|
style="background-color: {label.tag.color}"
|
||||||
|
>
|
||||||
|
{label.tag.name}
|
||||||
|
{#if canEdit}
|
||||||
|
<button onclick={() => removeLabel(label.tagId)} class="hover:opacity-70" title="Remove label">
|
||||||
|
<svg class="w-3 h-3" 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>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={openDropdown}
|
||||||
|
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{#if showDropdown}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-10"
|
||||||
|
onclick={() => (showDropdown = false)}
|
||||||
|
></div>
|
||||||
|
<div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white shadow-lg border border-gray-200 p-2">
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-xs text-gray-400 p-2">Loading...</p>
|
||||||
|
{:else}
|
||||||
|
<div class="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{#each allTags as tag}
|
||||||
|
<button
|
||||||
|
onclick={() => toggleTag(tag)}
|
||||||
|
class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 {isApplied(tag.id) ? 'bg-blue-50' : ''}"
|
||||||
|
>
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: {tag.color}"></span>
|
||||||
|
<span class="flex-1 text-left">{tag.name}</span>
|
||||||
|
{#if isApplied(tag.id)}
|
||||||
|
<svg class="w-4 h-4 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-100 mt-2 pt-2">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Create tag</p>
|
||||||
|
<div class="flex gap-1 mb-1.5">
|
||||||
|
{#each presetColors as color}
|
||||||
|
<button
|
||||||
|
class="w-5 h-5 rounded-full border-2 {newTagColor === color ? 'border-gray-600' : 'border-transparent'}"
|
||||||
|
style="background-color: {color}"
|
||||||
|
onclick={() => (newTagColor = color)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex gap-1">
|
||||||
|
<input
|
||||||
|
bind:value={newTagName}
|
||||||
|
placeholder="Tag name"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating || !newTagName.trim()}
|
||||||
|
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -30,7 +30,10 @@ const boardWithFull = {
|
|||||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||||
},
|
},
|
||||||
labels: { include: { tag: true } },
|
labels: { include: { tag: true } },
|
||||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
checklists: {
|
||||||
|
select: { id: true, items: { select: { completed: true } } }
|
||||||
|
},
|
||||||
|
_count: { select: { comments: true, attachments: true } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export const loginSchema = z.object({
|
|||||||
export const boardSchema = z.object({
|
export const boardSchema = z.object({
|
||||||
title: z.string().min(1, 'Title is required').max(200),
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE')
|
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'),
|
||||||
|
categoryId: z.string().uuid().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const columnSchema = z.object({
|
export const columnSchema = z.object({
|
||||||
@@ -47,3 +48,36 @@ export const changeMemberRoleSchema = z.object({
|
|||||||
export const changeTenantRoleSchema = z.object({
|
export const changeTenantRoleSchema = z.object({
|
||||||
tenantRole: z.enum(['ADMIN', 'MEMBER'])
|
tenantRole: z.enum(['ADMIN', 'MEMBER'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const tagSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(50),
|
||||||
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categorySchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(100)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cardLabelSchema = z.object({
|
||||||
|
tagId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cardAssigneeSchema = z.object({
|
||||||
|
userId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checklistSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checklistItemSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(500)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checklistItemToggleSchema = z.object({
|
||||||
|
completed: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const commentSchema = z.object({
|
||||||
|
content: z.string().min(1, 'Content is required').max(10000)
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { isPast, differenceInHours, formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
export type DueUrgency = 'overdue' | 'due-soon' | 'upcoming' | 'none';
|
||||||
|
|
||||||
|
export function getDueUrgency(dueDate: Date | string | null | undefined): DueUrgency {
|
||||||
|
if (!dueDate) return 'none';
|
||||||
|
const d = typeof dueDate === 'string' ? new Date(dueDate) : dueDate;
|
||||||
|
if (isPast(d)) return 'overdue';
|
||||||
|
if (differenceInHours(d, new Date()) <= 24) return 'due-soon';
|
||||||
|
return 'upcoming';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUrgencyClasses(urgency: DueUrgency): string {
|
||||||
|
switch (urgency) {
|
||||||
|
case 'overdue':
|
||||||
|
return 'bg-red-100 text-red-700';
|
||||||
|
case 'due-soon':
|
||||||
|
return 'bg-yellow-100 text-yellow-700';
|
||||||
|
case 'upcoming':
|
||||||
|
return 'bg-green-100 text-green-700';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDueDate(dueDate: Date | string | null | undefined): string {
|
||||||
|
if (!dueDate) return '';
|
||||||
|
const d = typeof dueDate === 'string' ? new Date(dueDate) : dueDate;
|
||||||
|
return formatDistanceToNow(d, { addSuffix: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
export function renderMarkdown(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const html = marked.parse(text, { async: false }) as string;
|
||||||
|
if (typeof window === 'undefined') return html;
|
||||||
|
const DOMPurify = (globalThis as any).__dompurify;
|
||||||
|
if (DOMPurify) return DOMPurify.sanitize(html);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function initDOMPurify(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
if ((globalThis as any).__dompurify) return Promise.resolve();
|
||||||
|
if (_initPromise) return _initPromise;
|
||||||
|
_initPromise = import('dompurify').then((mod) => {
|
||||||
|
(globalThis as any).__dompurify = mod.default;
|
||||||
|
});
|
||||||
|
return _initPromise;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireAuth } from '$lib/server/guards.js';
|
||||||
|
import { canViewBoard } from '$lib/server/permissions.js';
|
||||||
|
import { readFile, unlink } from 'fs/promises';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const attachment = await withTenant(tenant.id, async (tx) => {
|
||||||
|
const att = await tx.attachment.findFirst({
|
||||||
|
where: { id: params.attachmentId },
|
||||||
|
include: {
|
||||||
|
card: {
|
||||||
|
include: {
|
||||||
|
column: {
|
||||||
|
include: {
|
||||||
|
board: {
|
||||||
|
select: { tenantId: true, visibility: true, members: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!att) throw error(404, 'Attachment not found');
|
||||||
|
if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
|
||||||
|
|
||||||
|
if (!canViewBoard(locals.user, att.card.column.board.visibility, att.card.column.board.members)) {
|
||||||
|
if (!locals.user) throw error(401, 'Not authenticated');
|
||||||
|
throw error(403, 'Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return att;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await readFile(attachment.filepath);
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': attachment.mimetype,
|
||||||
|
'Content-Disposition': `attachment; filename="${attachment.filename}"`,
|
||||||
|
'Content-Length': String(attachment.size)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
const att = await tx.attachment.findFirst({
|
||||||
|
where: { id: params.attachmentId },
|
||||||
|
include: {
|
||||||
|
card: {
|
||||||
|
include: {
|
||||||
|
column: {
|
||||||
|
include: {
|
||||||
|
board: { select: { tenantId: true, members: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!att) throw error(404, 'Attachment not found');
|
||||||
|
if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
|
||||||
|
|
||||||
|
// Check board editor permission
|
||||||
|
const { canEditBoard } = await import('$lib/server/permissions.js');
|
||||||
|
if (!canEditBoard(locals.user, att.card.column.board.members)) {
|
||||||
|
throw error(403, 'Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.attachment.delete({ where: { id: params.attachmentId } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(att.filepath);
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -50,7 +50,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
return json({ error: result.error.issues[0].message }, { status: 400 });
|
return json({ error: result.error.issues[0].message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, visibility } = result.data;
|
const { title, description, visibility, categoryId } = result.data;
|
||||||
|
|
||||||
const board = await withTenant(tenant.id, async (tx) => {
|
const board = await withTenant(tenant.id, async (tx) => {
|
||||||
return tx.board.create({
|
return tx.board.create({
|
||||||
@@ -59,6 +59,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
visibility,
|
visibility,
|
||||||
|
...(categoryId ? { categoryId } : {}),
|
||||||
members: {
|
members: {
|
||||||
create: {
|
create: {
|
||||||
userId: locals.user!.id,
|
userId: locals.user!.id,
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { cardAssigneeSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = cardAssigneeSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const assignee = await withTenant(tenant.id, async (tx) => {
|
||||||
|
const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
// Verify user is a board member
|
||||||
|
const member = await tx.boardMember.findFirst({
|
||||||
|
where: { boardId: params.boardId, userId: result.data.userId }
|
||||||
|
});
|
||||||
|
if (!member) throw error(400, 'User is not a board member');
|
||||||
|
|
||||||
|
return tx.cardAssignee.create({
|
||||||
|
data: { cardId: params.cardId, userId: result.data.userId },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ assignee }, { status: 201 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = cardAssigneeSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const assignee = await tx.cardAssignee.findFirst({
|
||||||
|
where: { cardId: params.cardId, userId: result.data.userId }
|
||||||
|
});
|
||||||
|
if (!assignee) throw error(404, 'Assignee not found');
|
||||||
|
|
||||||
|
await tx.cardAssignee.delete({ where: { id: assignee.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
const UPLOAD_ROOT = '/app/uploads';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
if (!file) throw error(400, 'No file provided');
|
||||||
|
if (file.size > MAX_SIZE) throw error(400, 'File too large (max 10MB)');
|
||||||
|
|
||||||
|
const attachment = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const dir = join(UPLOAD_ROOT, tenant.id, params.cardId);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : '';
|
||||||
|
const storedName = randomUUID() + ext;
|
||||||
|
const filepath = join(dir, storedName);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
|
return tx.attachment.create({
|
||||||
|
data: {
|
||||||
|
cardId: params.cardId,
|
||||||
|
filename: file.name,
|
||||||
|
filepath,
|
||||||
|
mimetype: file.type || 'application/octet-stream',
|
||||||
|
size: file.size
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ attachment }, { status: 201 });
|
||||||
|
};
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { checklistSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = checklistSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const checklist = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const last = await tx.checklist.findFirst({
|
||||||
|
where: { cardId: params.cardId },
|
||||||
|
orderBy: { position: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const position = generatePosition(last?.position, null);
|
||||||
|
|
||||||
|
return tx.checklist.create({
|
||||||
|
data: {
|
||||||
|
cardId: params.cardId,
|
||||||
|
title: result.data.title,
|
||||||
|
position
|
||||||
|
},
|
||||||
|
include: { items: true }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ checklist }, { status: 201 });
|
||||||
|
};
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { checklistSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = checklistSchema.partial().safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const checklist = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const existing = await tx.checklist.findFirst({
|
||||||
|
where: { id: params.checklistId, cardId: params.cardId }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Checklist not found');
|
||||||
|
|
||||||
|
return tx.checklist.update({
|
||||||
|
where: { id: params.checklistId },
|
||||||
|
data: result.data,
|
||||||
|
include: { items: { orderBy: { position: 'asc' } } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ checklist });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const existing = await tx.checklist.findFirst({
|
||||||
|
where: { id: params.checklistId, cardId: params.cardId }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Checklist not found');
|
||||||
|
|
||||||
|
await tx.checklist.delete({ where: { id: params.checklistId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { checklistItemSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = checklistItemSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const item = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const checklist = await tx.checklist.findFirst({
|
||||||
|
where: { id: params.checklistId, cardId: params.cardId }
|
||||||
|
});
|
||||||
|
if (!checklist) throw error(404, 'Checklist not found');
|
||||||
|
|
||||||
|
const last = await tx.checklistItem.findFirst({
|
||||||
|
where: { checklistId: params.checklistId },
|
||||||
|
orderBy: { position: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const position = generatePosition(last?.position, null);
|
||||||
|
|
||||||
|
return tx.checklistItem.create({
|
||||||
|
data: {
|
||||||
|
checklistId: params.checklistId,
|
||||||
|
title: result.data.title,
|
||||||
|
position
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ item }, { status: 201 });
|
||||||
|
};
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const item = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const existing = await tx.checklistItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.itemId,
|
||||||
|
checklist: { id: params.checklistId, cardId: params.cardId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Checklist item not found');
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (typeof body.title === 'string') data.title = body.title;
|
||||||
|
if (typeof body.completed === 'boolean') data.completed = body.completed;
|
||||||
|
if (typeof body.position === 'string') data.position = body.position;
|
||||||
|
|
||||||
|
return tx.checklistItem.update({
|
||||||
|
where: { id: params.itemId },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ item });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const existing = await tx.checklistItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.itemId,
|
||||||
|
checklist: { id: params.checklistId, cardId: params.cardId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Checklist item not found');
|
||||||
|
|
||||||
|
await tx.checklistItem.delete({ where: { id: params.itemId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { commentSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = commentSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const comment = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
return tx.comment.create({
|
||||||
|
data: {
|
||||||
|
cardId: params.cardId,
|
||||||
|
userId: locals.user!.id,
|
||||||
|
content: result.data.content
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, avatarUrl: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ comment }, { status: 201 });
|
||||||
|
};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { commentSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor, requireAuth } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = commentSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const comment = await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.comment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.commentId,
|
||||||
|
cardId: params.cardId,
|
||||||
|
card: { column: { board: { id: params.boardId, tenantId: tenant.id } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Comment not found');
|
||||||
|
if (existing.userId !== locals.user!.id) throw error(403, 'Can only edit own comments');
|
||||||
|
|
||||||
|
return tx.comment.update({
|
||||||
|
where: { id: params.commentId },
|
||||||
|
data: { content: result.data.content },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, avatarUrl: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ comment });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.comment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.commentId,
|
||||||
|
cardId: params.cardId,
|
||||||
|
card: { column: { board: { id: params.boardId, tenantId: tenant.id } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Comment not found');
|
||||||
|
|
||||||
|
// Own comment — always allowed. Otherwise need board editor.
|
||||||
|
if (existing.userId !== locals.user?.id) {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
} else {
|
||||||
|
requireAuth(locals.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.comment.delete({ where: { id: params.commentId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { cardLabelSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = cardLabelSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const label = await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const tag = await tx.tag.findFirst({
|
||||||
|
where: { id: result.data.tagId, tenantId: tenant.id }
|
||||||
|
});
|
||||||
|
if (!tag) throw error(404, 'Tag not found');
|
||||||
|
|
||||||
|
return tx.cardLabel.create({
|
||||||
|
data: { cardId: params.cardId, tagId: result.data.tagId },
|
||||||
|
include: { tag: true }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ label }, { status: 201 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = cardLabelSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||||
|
|
||||||
|
const label = await tx.cardLabel.findFirst({
|
||||||
|
where: { cardId: params.cardId, tagId: result.data.tagId }
|
||||||
|
});
|
||||||
|
if (!label) throw error(404, 'Label not found');
|
||||||
|
|
||||||
|
await tx.cardLabel.delete({ where: { id: label.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { categorySchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireAuth } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const categories = await withTenant(tenant.id, async (tx) => {
|
||||||
|
return tx.category.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ categories });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = categorySchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const category = await withTenant(tenant.id, async (tx) => {
|
||||||
|
return tx.category.create({
|
||||||
|
data: { tenantId: tenant.id, name: result.data.name }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ category }, { status: 201 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { categorySchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireAuth } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = categorySchema.partial().safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const category = await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.category.findFirst({
|
||||||
|
where: { id: params.categoryId, tenantId: tenant.id }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Category not found');
|
||||||
|
|
||||||
|
return tx.category.update({
|
||||||
|
where: { id: params.categoryId },
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ category });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.category.findFirst({
|
||||||
|
where: { id: params.categoryId, tenantId: tenant.id }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Category not found');
|
||||||
|
|
||||||
|
await tx.category.delete({ where: { id: params.categoryId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { tagSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireAuth } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const tags = await withTenant(tenant.id, async (tx) => {
|
||||||
|
return tx.tag.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ tags });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = tagSchema.safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const tag = await withTenant(tenant.id, async (tx) => {
|
||||||
|
return tx.tag.create({
|
||||||
|
data: { tenantId: tenant.id, name: result.data.name, color: result.data.color }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ tag }, { status: 201 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { tagSchema } from '$lib/server/validation.js';
|
||||||
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
|
import { requireAuth } from '$lib/server/guards.js';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const result = tagSchema.partial().safeParse(body);
|
||||||
|
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||||
|
|
||||||
|
const tag = await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.tag.findFirst({
|
||||||
|
where: { id: params.tagId, tenantId: tenant.id }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Tag not found');
|
||||||
|
|
||||||
|
return tx.tag.update({
|
||||||
|
where: { id: params.tagId },
|
||||||
|
data: result.data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ tag });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const tenant = locals.tenant;
|
||||||
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
requireAuth(locals.user);
|
||||||
|
|
||||||
|
await withTenant(tenant.id, async (tx) => {
|
||||||
|
const existing = await tx.tag.findFirst({
|
||||||
|
where: { id: params.tagId, tenantId: tenant.id }
|
||||||
|
});
|
||||||
|
if (!existing) throw error(404, 'Tag not found');
|
||||||
|
|
||||||
|
await tx.tag.delete({ where: { id: params.tagId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -3,9 +3,9 @@ import { withTenant } from '$lib/server/rls.js';
|
|||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) return { boards: [] };
|
if (!tenant) return { boards: [], categories: [] };
|
||||||
|
|
||||||
const boards = await withTenant(tenant.id, async (tx) => {
|
const result = await withTenant(tenant.id, async (tx) => {
|
||||||
const where: any = { tenantId: tenant.id, archived: false };
|
const where: any = { tenantId: tenant.id, archived: false };
|
||||||
|
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -17,17 +17,28 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.board.findMany({
|
const [boards, categories] = await Promise.all([
|
||||||
where,
|
tx.board.findMany({
|
||||||
include: {
|
where,
|
||||||
members: {
|
include: {
|
||||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
members: {
|
||||||
|
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||||
|
},
|
||||||
|
category: true,
|
||||||
|
_count: { select: { columns: true } }
|
||||||
},
|
},
|
||||||
_count: { select: { columns: true } }
|
orderBy: { updatedAt: 'desc' }
|
||||||
},
|
}),
|
||||||
orderBy: { updatedAt: 'desc' }
|
locals.user
|
||||||
});
|
? tx.category.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { boards, categories };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { boards };
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,16 @@
|
|||||||
let showCreate = $state(false);
|
let showCreate = $state(false);
|
||||||
let newTitle = $state('');
|
let newTitle = $state('');
|
||||||
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
||||||
|
let newCategoryId = $state('');
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let filterCategoryId = $state('');
|
||||||
|
|
||||||
|
const filteredBoards = $derived(
|
||||||
|
filterCategoryId
|
||||||
|
? data.boards.filter((b: any) => b.categoryId === filterCategoryId)
|
||||||
|
: data.boards
|
||||||
|
);
|
||||||
|
|
||||||
async function createBoard(e: Event) {
|
async function createBoard(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -19,7 +27,11 @@
|
|||||||
const res = await fetch('/api/boards', {
|
const res = await fetch('/api/boards', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: newTitle, visibility: newVisibility })
|
body: JSON.stringify({
|
||||||
|
title: newTitle,
|
||||||
|
visibility: newVisibility,
|
||||||
|
...(newCategoryId ? { categoryId: newCategoryId } : {})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -27,6 +39,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newTitle = '';
|
newTitle = '';
|
||||||
|
newCategoryId = '';
|
||||||
showCreate = false;
|
showCreate = false;
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -59,8 +72,8 @@
|
|||||||
{#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 border border-red-200 p-2 text-sm text-red-700">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<form onsubmit={createBoard} class="flex gap-3 items-end">
|
<form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap">
|
||||||
<div class="flex-1">
|
<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 mb-1">Board title</label>
|
||||||
<input
|
<input
|
||||||
id="board-title"
|
id="board-title"
|
||||||
@@ -82,6 +95,21 @@
|
|||||||
<option value="PUBLIC">Public</option>
|
<option value="PUBLIC">Public</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{#if data.categories.length > 0}
|
||||||
|
<div>
|
||||||
|
<label for="board-cat" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
id="board-cat"
|
||||||
|
bind:value={newCategoryId}
|
||||||
|
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.categories as cat}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
@@ -100,7 +128,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.boards.length === 0}
|
<!-- Category filter -->
|
||||||
|
{#if data.categories.length > 0}
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<span class="text-sm text-gray-500">Filter:</span>
|
||||||
|
<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"
|
||||||
|
onclick={() => (filterCategoryId = '')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{#each data.categories as cat}
|
||||||
|
<button
|
||||||
|
class="rounded-full px-3 py-1 text-xs {filterCategoryId === cat.id ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition"
|
||||||
|
onclick={() => (filterCategoryId = cat.id)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredBoards.length === 0}
|
||||||
<div class="text-center py-16">
|
<div class="text-center py-16">
|
||||||
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
@@ -108,21 +157,28 @@
|
|||||||
<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">No boards yet.</p>
|
<p class="text-gray-500">{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}</p>
|
||||||
{#if data.user}
|
{#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 text-sm mt-1">Create your first board to get started.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{#each data.boards 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"
|
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition"
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
<div class="flex items-start justify-between gap-2">
|
||||||
{board.title}
|
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
||||||
</h2>
|
{board.title}
|
||||||
|
</h2>
|
||||||
|
{#if board.category}
|
||||||
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500 flex-shrink-0">
|
||||||
|
{board.category.name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</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">
|
||||||
<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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { dndzone } from 'svelte-dnd-action';
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
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 CardModal from '$lib/components/CardModal.svelte';
|
import CardModal from '$lib/components/CardModal.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -24,11 +25,22 @@
|
|||||||
|
|
||||||
const flipDurationMs = 200;
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
function getChecklistProgress(card: any): { done: number; total: number } | null {
|
||||||
|
if (!card.checklists || card.checklists.length === 0) return null;
|
||||||
|
let done = 0, total = 0;
|
||||||
|
for (const cl of card.checklists) {
|
||||||
|
for (const item of cl.items) {
|
||||||
|
total++;
|
||||||
|
if (item.completed) done++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total > 0 ? { done, total } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Column DnD ──────────────────────────────────
|
// ─── Column DnD ──────────────────────────────────
|
||||||
function handleColumnSort(e: CustomEvent<any>) {
|
function handleColumnSort(e: CustomEvent<any>) {
|
||||||
columns = e.detail.items;
|
columns = e.detail.items;
|
||||||
if (e.detail.info.trigger === 'droppedIntoZone') {
|
if (e.detail.info.trigger === 'droppedIntoZone') {
|
||||||
// Update positions based on new order
|
|
||||||
columns.forEach((col: any, idx: number) => {
|
columns.forEach((col: any, idx: number) => {
|
||||||
const before = idx > 0 ? columns[idx - 1].position : null;
|
const before = idx > 0 ? columns[idx - 1].position : null;
|
||||||
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
|
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
|
||||||
@@ -138,6 +150,17 @@
|
|||||||
if (selectedCard?.id === cardId) selectedCard = null;
|
if (selectedCard?.id === cardId) selectedCard = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Card updated from modal ─────────────────────
|
||||||
|
function handleCardUpdate(updatedCard: any) {
|
||||||
|
for (const col of columns) {
|
||||||
|
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
col.cards[idx] = { ...col.cards[idx], ...updatedCard };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -150,11 +173,14 @@
|
|||||||
boardId={data.board.id}
|
boardId={data.board.id}
|
||||||
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
|
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
|
||||||
canEdit={data.canEdit}
|
canEdit={data.canEdit}
|
||||||
|
boardMembers={data.board.members}
|
||||||
|
currentUserId={data.user?.id || ''}
|
||||||
onclose={() => (selectedCard = null)}
|
onclose={() => (selectedCard = null)}
|
||||||
ondelete={(cardId) => {
|
ondelete={(cardId) => {
|
||||||
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
|
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
|
||||||
if (col) deleteCard(col.id, cardId);
|
if (col) deleteCard(col.id, cardId);
|
||||||
}}
|
}}
|
||||||
|
onupdate={handleCardUpdate}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -250,6 +276,8 @@
|
|||||||
onfinalize={(e) => handleCardSort(column.id, e)}
|
onfinalize={(e) => handleCardSort(column.id, e)}
|
||||||
>
|
>
|
||||||
{#each column.cards as card (card.id)}
|
{#each column.cards as card (card.id)}
|
||||||
|
{@const progress = getChecklistProgress(card)}
|
||||||
|
{@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 transition cursor-pointer"
|
||||||
animate:flip={{ duration: flipDurationMs }}
|
animate:flip={{ duration: flipDurationMs }}
|
||||||
@@ -259,21 +287,45 @@
|
|||||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||||
{#each card.labels as label}
|
{#each card.labels as label}
|
||||||
<span
|
<span
|
||||||
class="inline-block h-2 w-8 rounded-full"
|
class="rounded px-1.5 py-0.5 text-[10px] text-white leading-none"
|
||||||
style="background-color: {label.tag.color}"
|
style="background-color: {label.tag.color}"
|
||||||
title={label.tag.name}
|
>
|
||||||
></span>
|
{label.tag.name}
|
||||||
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-sm text-gray-800">{card.title}</span>
|
<span class="text-sm text-gray-800">{card.title}</span>
|
||||||
{#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0}
|
{#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">
|
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 flex-wrap">
|
||||||
{#if card.dueDate}
|
{#if card.dueDate}
|
||||||
<span>Due {new Date(card.dueDate).toLocaleDateString()}</span>
|
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}">
|
||||||
|
{formatDueDate(card.dueDate)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if progress}
|
||||||
|
<span class="inline-flex items-center gap-1 {progress.done === progress.total ? 'text-[var(--color-success)]' : ''}">
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{progress.done}/{progress.total}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if card._count?.comments > 0}
|
{#if card._count?.comments > 0}
|
||||||
<span>💬 {card._count.comments}</span>
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v6a2 2 0 01-2 2H7.414l-3.207 3.207A1 1 0 012 15.5V5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{card._count.comments}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if card._count?.attachments > 0}
|
||||||
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{card._count.attachments}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -287,6 +339,13 @@
|
|||||||
{assignee.user.name[0].toUpperCase()}
|
{assignee.user.name[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if card.assignees.length > 3}
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white"
|
||||||
|
>
|
||||||
|
+{card.assignees.length - 3}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user