diff --git a/package-lock.json b/package-lock.json index 0d61b6a..449fb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { - "name": "kanban", + "name": "pounce", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "kanban", + "name": "pounce", "version": "0.1.0", "dependencies": { "@prisma/client": "^6.6.0", "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.50.2", + "@types/dompurify": "^3.0.5", "bcrypt": "^6.0.0", "date-fns": "^4.1.0", + "dompurify": "^3.3.1", "express": "^5.1.0", "marked": "^15.0.0", "socket.io": "^4.8.0", @@ -1438,6 +1440,15 @@ "@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": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1996,6 +2007,15 @@ "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", "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": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", diff --git a/package.json b/package.json index 2c9439d..a37a397 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@prisma/client": "^6.6.0", "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.50.2", + "@types/dompurify": "^3.0.5", "bcrypt": "^6.0.0", "date-fns": "^4.1.0", + "dompurify": "^3.3.1", "express": "^5.1.0", "marked": "^15.0.0", "socket.io": "^4.8.0", diff --git a/src/app.css b/src/app.css index d9f896a..6f578a3 100644 --- a/src/app.css +++ b/src/app.css @@ -18,3 +18,57 @@ '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; +} diff --git a/src/lib/components/AssigneePicker.svelte b/src/lib/components/AssigneePicker.svelte new file mode 100644 index 0000000..f054127 --- /dev/null +++ b/src/lib/components/AssigneePicker.svelte @@ -0,0 +1,113 @@ + + +
+

Assignees

+
+ {#each currentAssignees as assignee} +
+
+ {assignee.user.name[0].toUpperCase()} +
+ {assignee.user.name} + {#if canEdit} + + {/if} +
+ {/each} + {#if canEdit} +
+ + {#if showDropdown} + +
(showDropdown = false)}>
+
+
+ {#each boardMembers as member} + + {/each} +
+
+ {/if} +
+ {/if} +
+
diff --git a/src/lib/components/AttachmentList.svelte b/src/lib/components/AttachmentList.svelte new file mode 100644 index 0000000..52d7c4d --- /dev/null +++ b/src/lib/components/AttachmentList.svelte @@ -0,0 +1,127 @@ + + +
+

Attachments

+ + {#if canEdit} +
+ +
+ {/if} + + {#if localAttachments.length > 0} +
+ {#each localAttachments as att} +
+ + {getIcon(att.mimetype)} + + + {att.filename} + + {formatSize(att.size)} + {#if canEdit} + + {/if} +
+ {/each} +
+ {:else} +

No attachments

+ {/if} +
diff --git a/src/lib/components/CardModal.svelte b/src/lib/components/CardModal.svelte index 24852d1..d5e7939 100644 --- a/src/lib/components/CardModal.svelte +++ b/src/lib/components/CardModal.svelte @@ -1,44 +1,71 @@
-
+
@@ -86,107 +152,108 @@
- -
- - {#if card.labels && card.labels.length > 0} -
-

Labels

-
- {#each card.labels as label} - - {label.tag.name} - - {/each} -
-
- {/if} + +
+ +
+ + - -
-

Description

- {#if editingDescription && canEdit} -
- -
- - -
-
- {:else} -
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} -

{card.description}

- {:else if canEdit} -

Add a more detailed description...

- {:else} -

No description

- {/if} + +
+

Description

+ +
+ + + {#if loadingFull} +
+
+
+
+ {:else if fullCard} + + + + + {/if}
- - {#if card.dueDate} -
-

Due Date

-

{new Date(card.dueDate).toLocaleDateString()}

-
- {/if} + +
+ - - {#if card.assignees && card.assignees.length > 0} -
-

Assignees

-
- {#each card.assignees as assignee} -
-
- {assignee.user.name[0].toUpperCase()} -
- {assignee.user.name} -
- {/each} + + + + {#if canEdit} +
+

Actions

+
+ + +
-
- {/if} -
- - - {#if canEdit} -
- + {/if}
- {/if} +
diff --git a/src/lib/components/ChecklistSection.svelte b/src/lib/components/ChecklistSection.svelte new file mode 100644 index 0000000..9550b04 --- /dev/null +++ b/src/lib/components/ChecklistSection.svelte @@ -0,0 +1,228 @@ + + +
+

Checklists

+ + {#each checklists as cl} + {@const p = progress(cl)} +
+
+ {cl.title} +
+ + {p.done}/{p.total} + + {#if canEdit} + + {/if} +
+
+ + + {#if p.total > 0} +
+
+
+ {/if} + + +
+ {#each cl.items as item} +
+ {#if canEdit} + toggleItem(cl.id, item)} + class="rounded border-gray-300 text-[var(--color-primary)]" + /> + {:else} + + {/if} + + {item.title} + + {#if canEdit} + + {/if} +
+ {/each} +
+ + + {#if canEdit} +
{ e.preventDefault(); addItem(cl.id); }} + class="flex gap-1 mt-2" + > + + +
+ {/if} +
+ {/each} + + {#if canEdit} + {#if addingChecklist} +
{ e.preventDefault(); addChecklist(); }} class="flex gap-1 mt-2"> + + + +
+ {:else} + + {/if} + {/if} +
diff --git a/src/lib/components/CommentList.svelte b/src/lib/components/CommentList.svelte new file mode 100644 index 0000000..c026b91 --- /dev/null +++ b/src/lib/components/CommentList.svelte @@ -0,0 +1,182 @@ + + +
+

Comments

+ + + {#if canEdit} +
+ +
+ +
+
+ {/if} + + +
+ {#each localComments as comment} +
+
+ {comment.user.name[0].toUpperCase()} +
+
+
+ {comment.user.name} + + {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })} + +
+ {#if editingId === comment.id} +
+ +
+ + +
+
+ {:else} +
+ {#if ready} + {@html renderMarkdown(comment.content)} + {:else} +

{comment.content}

+ {/if} +
+
+ {#if comment.userId === currentUserId} + + {/if} + {#if comment.userId === currentUserId || canEdit} + + {/if} +
+ {/if} +
+
+ {/each} +
+ + {#if localComments.length === 0} +

No comments yet

+ {/if} +
diff --git a/src/lib/components/DueDatePicker.svelte b/src/lib/components/DueDatePicker.svelte new file mode 100644 index 0000000..12cc640 --- /dev/null +++ b/src/lib/components/DueDatePicker.svelte @@ -0,0 +1,97 @@ + + +
+

Due Date

+ {#if picking && canEdit} +
+ setDate(e.currentTarget.value)} + class="rounded border border-gray-300 px-2 py-1 text-sm" + disabled={saving} + /> + +
+ {:else if value} +
+ + {formatted} + + {#if canEdit} + + + {/if} +
+ {:else if canEdit} + + {:else} +

No due date

+ {/if} +
diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte new file mode 100644 index 0000000..fd6a876 --- /dev/null +++ b/src/lib/components/MarkdownEditor.svelte @@ -0,0 +1,108 @@ + + +{#if editing && !readonly} +
+
+ + +
+ {#if previewing} +
+ {#if ready} + {@html renderMarkdown(draft)} + {:else} +

{draft}

+ {/if} +
+ {:else} + + {/if} +
+ + +
+
+{:else} + +
{ if (!readonly) editing = true; }} + > + {#if value} +
+ {#if ready} + {@html renderMarkdown(value)} + {:else} +

{value}

+ {/if} +
+ {:else if !readonly} +

Add a more detailed description...

+ {:else} +

No description

+ {/if} +
+{/if} diff --git a/src/lib/components/TagPicker.svelte b/src/lib/components/TagPicker.svelte new file mode 100644 index 0000000..2e6555f --- /dev/null +++ b/src/lib/components/TagPicker.svelte @@ -0,0 +1,182 @@ + + +
+

Labels

+
+ {#each currentLabels as label} + + {label.tag.name} + {#if canEdit} + + {/if} + + {/each} + {#if canEdit} +
+ + {#if showDropdown} + +
(showDropdown = false)} + >
+
+ {#if loading} +

Loading...

+ {:else} +
+ {#each allTags as tag} + + {/each} +
+
+

Create tag

+
+ {#each presetColors as color} + + {/each} +
+
{ e.preventDefault(); createTag(); }} class="flex gap-1"> + + +
+
+ {/if} +
+ {/if} +
+ {/if} +
+
diff --git a/src/lib/server/guards.ts b/src/lib/server/guards.ts index 975ff98..2bfc9f8 100644 --- a/src/lib/server/guards.ts +++ b/src/lib/server/guards.ts @@ -30,7 +30,10 @@ const boardWithFull = { include: { user: { select: { id: true, name: true, avatarUrl: 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 } } } } } diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index bc7111b..507d5de 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -14,7 +14,8 @@ export const loginSchema = z.object({ export const boardSchema = z.object({ title: z.string().min(1, 'Title is required').max(200), 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({ @@ -47,3 +48,36 @@ export const changeMemberRoleSchema = z.object({ export const changeTenantRoleSchema = z.object({ 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) +}); diff --git a/src/lib/utils/due-date.ts b/src/lib/utils/due-date.ts new file mode 100644 index 0000000..3c9d1d5 --- /dev/null +++ b/src/lib/utils/due-date.ts @@ -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 }); +} diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts new file mode 100644 index 0000000..74b032a --- /dev/null +++ b/src/lib/utils/markdown.ts @@ -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 | null = null; + +export function initDOMPurify(): Promise { + 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; +} diff --git a/src/routes/api/attachments/[attachmentId]/+server.ts b/src/routes/api/attachments/[attachmentId]/+server.ts new file mode 100644 index 0000000..07d028a --- /dev/null +++ b/src/routes/api/attachments/[attachmentId]/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts index 697bc42..b22a574 100644 --- a/src/routes/api/boards/+server.ts +++ b/src/routes/api/boards/+server.ts @@ -50,7 +50,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { 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) => { return tx.board.create({ @@ -59,6 +59,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { title, description, visibility, + ...(categoryId ? { categoryId } : {}), members: { create: { userId: locals.user!.id, diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts new file mode 100644 index 0000000..397ac34 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts new file mode 100644 index 0000000..0b1c560 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts new file mode 100644 index 0000000..d582002 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts new file mode 100644 index 0000000..44cde83 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts new file mode 100644 index 0000000..cb7e878 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts new file mode 100644 index 0000000..7013c37 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts @@ -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 = {}; + 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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts new file mode 100644 index 0000000..1afdf05 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts new file mode 100644 index 0000000..1d65972 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts new file mode 100644 index 0000000..3cd581b --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/categories/+server.ts b/src/routes/api/categories/+server.ts new file mode 100644 index 0000000..eef4935 --- /dev/null +++ b/src/routes/api/categories/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/categories/[categoryId]/+server.ts b/src/routes/api/categories/[categoryId]/+server.ts new file mode 100644 index 0000000..a656a6d --- /dev/null +++ b/src/routes/api/categories/[categoryId]/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/tags/+server.ts b/src/routes/api/tags/+server.ts new file mode 100644 index 0000000..aba9c3e --- /dev/null +++ b/src/routes/api/tags/+server.ts @@ -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 }); +}; diff --git a/src/routes/api/tags/[tagId]/+server.ts b/src/routes/api/tags/[tagId]/+server.ts new file mode 100644 index 0000000..67b581f --- /dev/null +++ b/src/routes/api/tags/[tagId]/+server.ts @@ -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 }); +}; diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts index 6031db5..b1bf1a2 100644 --- a/src/routes/boards/+page.server.ts +++ b/src/routes/boards/+page.server.ts @@ -3,9 +3,9 @@ import { withTenant } from '$lib/server/rls.js'; export const load: PageServerLoad = async ({ locals }) => { 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 }; if (!locals.user) { @@ -17,17 +17,28 @@ export const load: PageServerLoad = async ({ locals }) => { ]; } - return tx.board.findMany({ - where, - include: { - members: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } + const [boards, categories] = await Promise.all([ + tx.board.findMany({ + where, + include: { + 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; }; diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index 85d42f8..a9f8c25 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -6,8 +6,16 @@ let showCreate = $state(false); let newTitle = $state(''); let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE'); + let newCategoryId = $state(''); let creating = $state(false); 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) { e.preventDefault(); @@ -19,7 +27,11 @@ const res = await fetch('/api/boards', { method: 'POST', 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(); if (!res.ok) { @@ -27,6 +39,7 @@ return; } newTitle = ''; + newCategoryId = ''; showCreate = false; invalidateAll(); } catch { @@ -59,8 +72,8 @@ {#if error}
{error}
{/if} -
-
+ +
Public
+ {#if data.categories.length > 0} +
+ + +
+ {/if} + {#each data.categories as cat} + + {/each} +
+ {/if} + + {#if filteredBoards.length === 0}
@@ -108,21 +157,28 @@ -

No boards yet.

- {#if data.user} +

{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}

+ {#if data.user && !filterCategoryId}

Create your first board to get started.

{/if}
{:else}
- {#each data.boards as board} + {#each filteredBoards as board} -

- {board.title} -

+
+

+ {board.title} +

+ {#if board.category} + + {board.category.name} + + {/if} +
{board._count.columns} columns {board.visibility === 'PUBLIC' ? 'Public' : 'Private'} diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 1c51575..8f8ed84 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -2,6 +2,7 @@ import { dndzone } from 'svelte-dnd-action'; import { flip } from 'svelte/animate'; 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'; let { data } = $props(); @@ -24,11 +25,22 @@ 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 ────────────────────────────────── function handleColumnSort(e: CustomEvent) { columns = e.detail.items; if (e.detail.info.trigger === 'droppedIntoZone') { - // Update positions based on new order columns.forEach((col: any, idx: number) => { const before = idx > 0 ? 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; } } + + // ─── 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; + } + } + } @@ -150,11 +173,14 @@ boardId={data.board.id} columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id} canEdit={data.canEdit} + boardMembers={data.board.members} + currentUserId={data.user?.id || ''} onclose={() => (selectedCard = null)} ondelete={(cardId) => { const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId)); if (col) deleteCard(col.id, cardId); }} + onupdate={handleCardUpdate} /> {/if} @@ -250,6 +276,8 @@ onfinalize={(e) => handleCardSort(column.id, e)} > {#each column.cards as card (card.id)} + {@const progress = getChecklistProgress(card)} + {@const dueUrgency = getDueUrgency(card.dueDate)}
{/if} {card.title} - {#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0} -
+ {#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress} +
{#if card.dueDate} - Due {new Date(card.dueDate).toLocaleDateString()} + + {formatDueDate(card.dueDate)} + + {/if} + {#if progress} + + + + + {progress.done}/{progress.total} + {/if} {#if card._count?.comments > 0} - 💬 {card._count.comments} + + + + + {card._count.comments} + + {/if} + {#if card._count?.attachments > 0} + + + + + {card._count.attachments} + {/if}
{/if} @@ -287,6 +339,13 @@ {assignee.user.name[0].toUpperCase()}
{/each} + {#if card.assignees.length > 3} +
+ +{card.assignees.length - 3} +
+ {/if}
{/if}