Private
Public Access
1
0

Add 17 features: My Work view, card copy/numbering, calendar, bulk ops, custom fields, automations, webhooks, invites, import/export, and more

Features implemented across 6 batches:
- Batch A: Fix ColorPicker gradient sync, tenant custom logo, unarchive cards, board backgrounds
- Batch B: My Work view, card copy/duplicate, card numbering (BOARD-123)
- Batch C: Due date reminders (cron), card watching with notifications
- Batch D: Bulk card operations, column sorting, calendar view
- Batch E: Invite by link, board import/export (Pounce + Trello JSON)
- Batch F: Webhooks with HMAC signing, custom fields, automation rules engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-27 08:50:15 -05:00
parent 3a11ed01e9
commit 8c41b33313
54 changed files with 3656 additions and 82 deletions
+22
View File
@@ -17,6 +17,7 @@
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"express": "^5.1.0", "express": "^5.1.0",
"marked": "^15.0.0", "marked": "^15.0.0",
"node-cron": "^3.0.3",
"pino": "^10.3.1", "pino": "^10.3.1",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
@@ -3112,6 +3113,18 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"license": "ISC",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch-native": { "node_modules/node-fetch-native": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -4176,6 +4189,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+1
View File
@@ -27,6 +27,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"express": "^5.1.0", "express": "^5.1.0",
"node-cron": "^3.0.3",
"marked": "^15.0.0", "marked": "^15.0.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
+127
View File
@@ -13,6 +13,8 @@ model Tenant {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
slug String @unique slug String @unique
logoUrl String? @map("logo_url")
logoText String? @map("logo_text")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -23,6 +25,8 @@ model Tenant {
categories Category[] categories Category[]
boardTemplates BoardTemplate[] boardTemplates BoardTemplate[]
theme TenantTheme? theme TenantTheme?
invites TenantInvite[]
webhooks Webhook[]
@@map("tenants") @@map("tenants")
} }
@@ -70,6 +74,7 @@ model User {
comments Comment[] comments Comment[]
activities Activity[] activities Activity[]
notifications Notification[] notifications Notification[]
cardWatchers CardWatcher[]
@@unique([tenantId, email]) @@unique([tenantId, email])
@@map("users") @@map("users")
@@ -108,6 +113,8 @@ model Board {
background String? background String?
categoryId String? @map("category_id") @db.Uuid categoryId String? @map("category_id") @db.Uuid
archived Boolean @default(false) archived Boolean @default(false)
cardPrefix String? @map("card_prefix")
nextCardNum Int @default(1) @map("next_card_num")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -116,6 +123,8 @@ model Board {
columns Column[] columns Column[]
members BoardMember[] members BoardMember[]
activities Activity[] activities Activity[]
customFieldDefs CustomFieldDef[]
automationRules AutomationRule[]
@@map("boards") @@map("boards")
} }
@@ -157,6 +166,8 @@ model Card {
position String // Fractional index (lexicographic) position String // Fractional index (lexicographic)
dueDate DateTime? @map("due_date") dueDate DateTime? @map("due_date")
archived Boolean @default(false) archived Boolean @default(false)
dueReminderSent Boolean @default(false) @map("due_reminder_sent")
cardNumber Int? @map("card_number")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -167,6 +178,8 @@ model Card {
comments Comment[] comments Comment[]
attachments Attachment[] attachments Attachment[]
activities Activity[] activities Activity[]
watchers CardWatcher[]
customFieldValues CustomFieldValue[]
@@map("cards") @@map("cards")
} }
@@ -331,6 +344,120 @@ model BoardTemplate {
@@map("board_templates") @@map("board_templates")
} }
// ─── Card Watchers ───────────────────────────────────────────
model CardWatcher {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
userId String @map("user_id") @db.Uuid
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([cardId, userId])
@@map("card_watchers")
}
// ─── Tenant Invites ──────────────────────────────────────────
model TenantInvite {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
code String @unique
role TenantRole @default(MEMBER)
expiresAt DateTime @map("expires_at")
createdBy String @map("created_by") @db.Uuid
usedCount Int @default(0) @map("used_count")
maxUses Int? @map("max_uses")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("tenant_invites")
}
// ─── Webhooks ────────────────────────────────────────────────
model Webhook {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
url String
events String[]
secret String
active Boolean @default(true)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("webhooks")
}
// ─── Custom Fields ───────────────────────────────────────────
enum CustomFieldType {
TEXT
NUMBER
DATE
SELECT
MULTI_SELECT
}
model CustomFieldDef {
id String @id @default(uuid()) @db.Uuid
boardId String @map("board_id") @db.Uuid
name String
type CustomFieldType
options Json? // For SELECT/MULTI_SELECT: string[]
position String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
values CustomFieldValue[]
@@map("custom_field_defs")
}
model CustomFieldValue {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
fieldId String @map("field_id") @db.Uuid
value String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
field CustomFieldDef @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@unique([cardId, fieldId])
@@map("custom_field_values")
}
// ─── Automation Rules ────────────────────────────────────────
enum AutomationTrigger {
CARD_MOVED
DUE_PASSED
LABEL_ADDED
CARD_CREATED
}
enum AutomationAction {
ADD_LABEL
REMOVE_LABEL
ASSIGN
SET_DUE
MOVE_TO_COLUMN
}
model AutomationRule {
id String @id @default(uuid()) @db.Uuid
boardId String @map("board_id") @db.Uuid
trigger AutomationTrigger
triggerConfig Json? @map("trigger_config")
action AutomationAction
actionConfig Json? @map("action_config")
active Boolean @default(true)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
@@map("automation_rules")
}
// ─── Tenant Branding & Theming ─────────────────────────────── // ─── Tenant Branding & Theming ───────────────────────────────
model TenantTheme { model TenantTheme {
+124
View File
@@ -60,6 +60,24 @@ ALTER TABLE notifications FORCE ROW LEVEL SECURITY;
ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY; ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE board_templates FORCE ROW LEVEL SECURITY; ALTER TABLE board_templates FORCE ROW LEVEL SECURITY;
ALTER TABLE tenant_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_invites FORCE ROW LEVEL SECURITY;
ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhooks FORCE ROW LEVEL SECURITY;
ALTER TABLE card_watchers ENABLE ROW LEVEL SECURITY;
ALTER TABLE card_watchers FORCE ROW LEVEL SECURITY;
ALTER TABLE custom_field_defs ENABLE ROW LEVEL SECURITY;
ALTER TABLE custom_field_defs FORCE ROW LEVEL SECURITY;
ALTER TABLE custom_field_values ENABLE ROW LEVEL SECURITY;
ALTER TABLE custom_field_values FORCE ROW LEVEL SECURITY;
ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE automation_rules FORCE ROW LEVEL SECURITY;
-- ─── Tenant-scoped tables (direct tenant_id) ────────── -- ─── Tenant-scoped tables (direct tenant_id) ──────────
-- Users -- Users
@@ -336,6 +354,112 @@ CREATE POLICY attachments_tenant_isolation ON attachments
) )
); );
-- ─── New tenant-scoped tables ────────────────────────
-- Tenant Invites
DROP POLICY IF EXISTS tenant_invites_tenant_isolation ON tenant_invites;
CREATE POLICY tenant_invites_tenant_isolation ON tenant_invites
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR tenant_id::text = current_setting('app.current_tenant_id', true)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR tenant_id::text = current_setting('app.current_tenant_id', true)
);
-- Webhooks
DROP POLICY IF EXISTS webhooks_tenant_isolation ON webhooks;
CREATE POLICY webhooks_tenant_isolation ON webhooks
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR tenant_id::text = current_setting('app.current_tenant_id', true)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR tenant_id::text = current_setting('app.current_tenant_id', true)
);
-- Card Watchers (via card -> column -> board)
DROP POLICY IF EXISTS card_watchers_tenant_isolation ON card_watchers;
CREATE POLICY card_watchers_tenant_isolation ON card_watchers
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR card_id IN (
SELECT ca.id FROM cards ca
JOIN columns c ON ca.column_id = c.id
JOIN boards b ON c.board_id = b.id
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR card_id IN (
SELECT ca.id FROM cards ca
JOIN columns c ON ca.column_id = c.id
JOIN boards b ON c.board_id = b.id
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
)
);
-- Custom Field Defs (via board)
DROP POLICY IF EXISTS custom_field_defs_tenant_isolation ON custom_field_defs;
CREATE POLICY custom_field_defs_tenant_isolation ON custom_field_defs
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR board_id IN (
SELECT id FROM boards
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR board_id IN (
SELECT id FROM boards
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
)
);
-- Custom Field Values (via card -> column -> board)
DROP POLICY IF EXISTS custom_field_values_tenant_isolation ON custom_field_values;
CREATE POLICY custom_field_values_tenant_isolation ON custom_field_values
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR card_id IN (
SELECT ca.id FROM cards ca
JOIN columns c ON ca.column_id = c.id
JOIN boards b ON c.board_id = b.id
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR card_id IN (
SELECT ca.id FROM cards ca
JOIN columns c ON ca.column_id = c.id
JOIN boards b ON c.board_id = b.id
WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true)
)
);
-- Automation Rules (via board)
DROP POLICY IF EXISTS automation_rules_tenant_isolation ON automation_rules;
CREATE POLICY automation_rules_tenant_isolation ON automation_rules
USING (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR board_id IN (
SELECT id FROM boards
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
)
)
WITH CHECK (
current_setting('app.current_tenant_id', true) = '__bypass__'
OR board_id IN (
SELECT id FROM boards
WHERE tenant_id::text = current_setting('app.current_tenant_id', true)
)
);
-- ─── User-scoped tables ─────────────────────────────── -- ─── User-scoped tables ───────────────────────────────
-- Notifications -- Notifications
+53
View File
@@ -3,6 +3,7 @@ import { createServer } from 'http';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import pino from 'pino'; import pino from 'pino';
import cron from 'node-cron';
import { handler } from './build/handler.js'; import { handler } from './build/handler.js';
const logger = pino({ name: 'pounce' }); const logger = pino({ name: 'pounce' });
@@ -148,6 +149,58 @@ io.on('connection', (socket) => {
// Store io globally so SvelteKit API routes can broadcast via realtime.ts // Store io globally so SvelteKit API routes can broadcast via realtime.ts
globalThis.__socketIO = io; globalThis.__socketIO = io;
// ─── Due Date Reminder Cron (daily at 8:00 UTC) ────────────
cron.schedule('0 8 * * *', async () => {
try {
const now = new Date();
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const cards = await prisma.card.findMany({
where: {
archived: false,
dueReminderSent: false,
dueDate: { gte: now, lte: in24h }
},
include: {
assignees: { select: { userId: true } },
column: { select: { board: { select: { id: true, title: true } } } }
}
});
for (const card of cards) {
const boardId = card.column.board.id;
const boardTitle = card.column.board.title;
for (const assignee of card.assignees) {
await prisma.notification.create({
data: {
userId: assignee.userId,
type: 'due_soon',
title: `"${card.title}" is due soon`,
body: `Card in ${boardTitle} is due within 24 hours`,
link: `/boards/${boardId}?card=${card.id}`
}
});
io.to('user:' + assignee.userId).emit('notification:new', {
type: 'due_soon',
title: `"${card.title}" is due soon`
});
}
await prisma.card.update({
where: { id: card.id },
data: { dueReminderSent: true }
});
}
if (cards.length > 0) {
logger.info({ count: cards.length }, 'Due date reminders sent');
}
} catch (err) {
logger.error({ err }, 'Due date reminder cron failed');
}
});
// SvelteKit handler // SvelteKit handler
app.use(handler); app.use(handler);
+2
View File
@@ -53,6 +53,8 @@ declare global {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
logoUrl?: string | null;
logoText?: string | null;
theme?: TenantThemeData | null; theme?: TenantThemeData | null;
} | null; } | null;
user: { user: {
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
type Props = {
boardId: string;
columns: any[];
tags: any[];
members: any[];
};
let { boardId, columns, tags, members }: Props = $props();
let rules = $state<any[]>([]);
let loaded = $state(false);
let showCreate = $state(false);
// New rule form
let newTrigger = $state('CARD_MOVED');
let newAction = $state('ADD_LABEL');
let triggerColumnId = $state('');
let triggerTagId = $state('');
let actionTagId = $state('');
let actionUserId = $state('');
let actionColumnId = $state('');
let actionDays = $state('7');
const triggers = [
{ value: 'CARD_MOVED', label: 'Card moved' },
{ value: 'CARD_CREATED', label: 'Card created' },
{ value: 'LABEL_ADDED', label: 'Label added' },
{ value: 'DUE_PASSED', label: 'Due date passed' }
];
const actions = [
{ value: 'ADD_LABEL', label: 'Add label' },
{ value: 'REMOVE_LABEL', label: 'Remove label' },
{ value: 'ASSIGN', label: 'Assign user' },
{ value: 'MOVE_TO_COLUMN', label: 'Move to column' },
{ value: 'SET_DUE', label: 'Set due date' }
];
async function loadRules() {
const { ok, data } = await api(`/api/boards/${boardId}/automations`);
if (ok) {
rules = data.rules;
loaded = true;
}
}
async function createRule() {
const triggerConfig: Record<string, any> = {};
const actionConfig: Record<string, any> = {};
if (newTrigger === 'CARD_MOVED' && triggerColumnId) triggerConfig.toColumnId = triggerColumnId;
if (newTrigger === 'LABEL_ADDED' && triggerTagId) triggerConfig.tagId = triggerTagId;
if (newAction === 'ADD_LABEL' || newAction === 'REMOVE_LABEL') actionConfig.tagId = actionTagId;
if (newAction === 'ASSIGN') actionConfig.userId = actionUserId;
if (newAction === 'MOVE_TO_COLUMN') actionConfig.columnId = actionColumnId;
if (newAction === 'SET_DUE') actionConfig.daysFromNow = actionDays;
const { ok, data } = await api(`/api/boards/${boardId}/automations`, {
method: 'POST',
body: JSON.stringify({
trigger: newTrigger,
triggerConfig,
action: newAction,
actionConfig
})
});
if (ok) {
rules = [...rules, data.rule];
showCreate = false;
toast.success('Automation created');
}
}
async function toggleRule(ruleId: string, active: boolean) {
const { ok, data } = await api(`/api/boards/${boardId}/automations/${ruleId}`, {
method: 'PATCH',
body: JSON.stringify({ active })
});
if (ok) {
rules = rules.map((r: any) => (r.id === ruleId ? data.rule : r));
}
}
async function deleteRule(ruleId: string) {
const { ok } = await api(`/api/boards/${boardId}/automations/${ruleId}`, { method: 'DELETE' });
if (ok) {
rules = rules.filter((r: any) => r.id !== ruleId);
toast.success('Automation deleted');
}
}
function getTriggerLabel(trigger: string): string {
return triggers.find((t) => t.value === trigger)?.label || trigger;
}
function getActionLabel(action: string): string {
return actions.find((a) => a.value === action)?.label || action;
}
function describeConfig(config: any, type: 'trigger' | 'action'): string {
if (!config) return '';
const parts: string[] = [];
if (config.toColumnId) {
const col = columns.find((c: any) => c.id === config.toColumnId);
parts.push(`to "${col?.title || 'unknown'}"`);
}
if (config.tagId) {
const tag = tags.find((t: any) => t.id === config.tagId);
parts.push(`"${tag?.name || 'unknown'}"`);
}
if (config.userId) {
const member = members.find((m: any) => m.user.id === config.userId);
parts.push(`${member?.user?.name || 'unknown'}`);
}
if (config.columnId) {
const col = columns.find((c: any) => c.id === config.columnId);
parts.push(`to "${col?.title || 'unknown'}"`);
}
if (config.daysFromNow) parts.push(`+${config.daysFromNow} days`);
return parts.join(', ');
}
</script>
<div class="space-y-3">
{#if !loaded}
<button onclick={loadRules} class="text-sm text-[var(--color-primary)] hover:underline">
Load automation rules
</button>
{:else}
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Automations</span>
<button
onclick={() => (showCreate = !showCreate)}
class="text-xs text-[var(--color-primary)] hover:underline"
>
{showCreate ? 'Cancel' : '+ Add rule'}
</button>
</div>
{#if showCreate}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3 space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">When</label>
<select bind:value={newTrigger} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
{#each triggers as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</div>
<div>
<label class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">Then</label>
<select bind:value={newAction} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
{#each actions as a}
<option value={a.value}>{a.label}</option>
{/each}
</select>
</div>
</div>
<!-- Trigger config -->
{#if newTrigger === 'CARD_MOVED'}
<select bind:value={triggerColumnId} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
<option value="">Any column</option>
{#each columns as col}
<option value={col.id}>To: {col.title}</option>
{/each}
</select>
{:else if newTrigger === 'LABEL_ADDED'}
<select bind:value={triggerTagId} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
<option value="">Any label</option>
{#each tags as tag}
<option value={tag.id}>{tag.name}</option>
{/each}
</select>
{/if}
<!-- Action config -->
{#if newAction === 'ADD_LABEL' || newAction === 'REMOVE_LABEL'}
<select bind:value={actionTagId} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
<option value="">Select label...</option>
{#each tags as tag}
<option value={tag.id}>{tag.name}</option>
{/each}
</select>
{:else if newAction === 'ASSIGN'}
<select bind:value={actionUserId} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
<option value="">Select user...</option>
{#each members as m}
<option value={m.user.id}>{m.user.name}</option>
{/each}
</select>
{:else if newAction === 'MOVE_TO_COLUMN'}
<select bind:value={actionColumnId} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
<option value="">Select column...</option>
{#each columns as col}
<option value={col.id}>{col.title}</option>
{/each}
</select>
{:else if newAction === 'SET_DUE'}
<div class="flex items-center gap-1">
<input type="number" bind:value={actionDays} min="1" max="365" class="w-16 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs" />
<span class="text-xs text-gray-500 dark:text-gray-400">days from now</span>
</div>
{/if}
<button
onclick={createRule}
class="rounded bg-[var(--color-primary)] px-3 py-1 text-xs text-white hover:opacity-90 transition"
>
Create Rule
</button>
</div>
{/if}
{#if rules.length === 0}
<p class="text-xs text-gray-400 dark:text-gray-500">No automation rules</p>
{:else}
<div class="space-y-1.5">
{#each rules as rule (rule.id)}
<div class="flex items-center gap-2 rounded bg-gray-50 dark:bg-gray-800 px-3 py-2 text-xs">
<div class="flex-1 min-w-0">
<span class="font-medium text-gray-700 dark:text-gray-300">{getTriggerLabel(rule.trigger)}</span>
{#if describeConfig(rule.triggerConfig, 'trigger')}
<span class="text-gray-400"> {describeConfig(rule.triggerConfig, 'trigger')}</span>
{/if}
<span class="text-gray-400 mx-1">&rarr;</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{getActionLabel(rule.action)}</span>
{#if describeConfig(rule.actionConfig, 'action')}
<span class="text-gray-400"> {describeConfig(rule.actionConfig, 'action')}</span>
{/if}
</div>
<label class="flex items-center gap-1 cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={rule.active}
onchange={() => toggleRule(rule.id, !rule.active)}
class="rounded border-gray-300 dark:border-gray-600 text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
</label>
<button onclick={() => deleteRule(rule.id)} class="text-red-500 hover:text-red-600 flex-shrink-0">
<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>
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
import ColorPicker from './ColorPicker.svelte';
let { boardId, currentBackground = null }: { boardId: string; currentBackground: string | null } = $props();
let open = $state(false);
let customMode = $state(false);
let customValue = $state(currentBackground || '');
const PRESET_SOLIDS = [
'#0079bf', '#d29034', '#519839', '#b04632',
'#89609e', '#cd5a91', '#4bbf6b', '#00aecc',
'#838c91', '#172b4d'
];
const PRESET_GRADIENTS = [
'linear-gradient(to right, #0079bf, #6366f1)',
'linear-gradient(to right, #f97316, #ef4444)',
'linear-gradient(to right, #22c55e, #14b8a6)',
'linear-gradient(to right, #8b5cf6, #ec4899)',
'linear-gradient(135deg, #667eea, #764ba2)',
'linear-gradient(to right, #0ea5e9, #6366f1)'
];
async function setBackground(value: string | null) {
const { ok } = await api(`/api/boards/${boardId}`, {
method: 'PATCH',
body: JSON.stringify({ background: value })
});
if (ok) {
currentBackground = value;
toast.success(value ? 'Background updated' : 'Background removed');
}
open = false;
customMode = false;
}
function handleCustomChange(value: string) {
customValue = value;
}
function handleClickOutside(e: MouseEvent) {
if (open && !(e.target as HTMLElement).closest('.bg-picker-dropdown')) {
open = false;
customMode = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="relative bg-picker-dropdown">
<button
onclick={(e) => { e.stopPropagation(); open = !open; }}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
title="Board background"
aria-label="Board background"
>
{#if currentBackground}
<div class="w-4 h-4 rounded" style="background: {currentBackground};"></div>
{:else}
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clip-rule="evenodd" />
</svg>
{/if}
Background
</button>
{#if open}
<div
class="absolute top-full left-0 mt-1 z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl p-3 space-y-3"
onclick={(e) => e.stopPropagation()}
>
{#if !customMode}
<div>
<div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1.5">Solid Colors</div>
<div class="grid grid-cols-5 gap-1.5">
{#each PRESET_SOLIDS as color}
<button
onclick={() => setBackground(color)}
class="w-full aspect-square rounded-lg border-2 transition hover:scale-110 {currentBackground === color ? 'border-white ring-2 ring-[var(--color-primary)]' : 'border-transparent'}"
style="background: {color};"
title={color}
></button>
{/each}
</div>
</div>
<div>
<div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1.5">Gradients</div>
<div class="grid grid-cols-3 gap-1.5">
{#each PRESET_GRADIENTS as grad}
<button
onclick={() => setBackground(grad)}
class="w-full h-8 rounded-lg border-2 transition hover:scale-105 {currentBackground === grad ? 'border-white ring-2 ring-[var(--color-primary)]' : 'border-transparent'}"
style="background: {grad};"
></button>
{/each}
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => (customMode = true)}
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 px-2 py-1.5 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
Custom...
</button>
{#if currentBackground}
<button
onclick={() => setBackground(null)}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-2 py-1.5 text-xs text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition"
>
Remove
</button>
{/if}
</div>
{:else}
<ColorPicker
label="Custom background"
value={customValue}
gradient={true}
onchange={handleCustomChange}
/>
<div class="flex gap-2">
<button
onclick={() => setBackground(customValue)}
disabled={!customValue}
class="flex-1 rounded-lg bg-[var(--color-primary)] px-2 py-1.5 text-xs text-white hover:opacity-90 transition disabled:opacity-50"
>
Apply
</button>
<button
onclick={() => (customMode = false)}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-2 py-1.5 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
Back
</button>
</div>
{/if}
</div>
{/if}
</div>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
type Props = {
boardId: string;
selectedCardIds: Set<string>;
columns: any[];
onaction: () => void;
oncancel: () => void;
};
let { boardId, selectedCardIds, columns, onaction, oncancel }: Props = $props();
let processing = $state(false);
let confirmDelete = $state(false);
async function bulkAction(action: string, extra: Record<string, any> = {}) {
processing = true;
const { ok, data } = await api(`/api/boards/${boardId}/cards/bulk`, {
method: 'POST',
body: JSON.stringify({
action,
cardIds: Array.from(selectedCardIds),
...extra
})
});
if (ok) {
toast.success(`${data.count} card(s) updated`);
onaction();
}
processing = false;
confirmDelete = false;
}
</script>
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center gap-3 flex-wrap">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{selectedCardIds.size} selected
</span>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-700"></div>
<!-- Move to column -->
<select
onchange={(e) => {
const val = e.currentTarget.value;
if (val) bulkAction('move', { columnId: val });
e.currentTarget.value = '';
}}
disabled={processing}
class="text-sm rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 px-2 py-1"
>
<option value="">Move to...</option>
{#each columns as col}
<option value={col.id}>{col.title}</option>
{/each}
</select>
<button
onclick={() => bulkAction('archive')}
disabled={processing}
class="text-sm rounded bg-gray-100 dark:bg-gray-700 px-3 py-1.5 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition disabled:opacity-50"
>
Archive
</button>
{#if confirmDelete}
<button
onclick={() => bulkAction('delete')}
disabled={processing}
class="text-sm rounded bg-[var(--color-danger)] px-3 py-1.5 text-white hover:opacity-90 transition disabled:opacity-50"
>
Confirm Delete
</button>
<button
onclick={() => (confirmDelete = false)}
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Cancel
</button>
{:else}
<button
onclick={() => (confirmDelete = true)}
disabled={processing}
class="text-sm rounded px-3 py-1.5 text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition disabled:opacity-50"
>
Delete
</button>
{/if}
<div class="h-5 w-px bg-gray-200 dark:bg-gray-700"></div>
<button
onclick={oncancel}
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
>
Done
</button>
</div>
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { getDueUrgency, getUrgencyClasses } from '$lib/utils/due-date.js';
type Props = {
cards: any[];
boardId: string;
cardPrefix?: string | null;
};
let { cards, boardId, cardPrefix }: Props = $props();
let currentDate = $state(new Date());
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
let year = $derived(currentDate.getFullYear());
let month = $derived(currentDate.getMonth());
function getDaysInMonth(y: number, m: number) {
return new Date(y, m + 1, 0).getDate();
}
function getFirstDayOfMonth(y: number, m: number) {
return new Date(y, m, 1).getDay();
}
let calendarDays = $derived.by(() => {
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) days.push(null);
for (let i = 1; i <= daysInMonth; i++) days.push(i);
return days;
});
function getCardsForDay(day: number): any[] {
return cards.filter((c) => {
const d = new Date(c.dueDate);
return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day;
});
}
function isToday(day: number): boolean {
const now = new Date();
return now.getFullYear() === year && now.getMonth() === month && now.getDate() === day;
}
function prevMonth() {
currentDate = new Date(year, month - 1, 1);
}
function nextMonth() {
currentDate = new Date(year, month + 1, 1);
}
function goToToday() {
currentDate = new Date();
}
</script>
<div class="space-y-3">
<!-- Month navigation -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button
onclick={prevMonth}
class="rounded p-1.5 text-gray-500 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 transition"
aria-label="Previous month"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
{MONTHS[month]} {year}
</h2>
<button
onclick={nextMonth}
class="rounded p-1.5 text-gray-500 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 transition"
aria-label="Next month"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
</div>
<button
onclick={goToToday}
class="rounded px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
Today
</button>
</div>
<!-- Calendar grid -->
<div class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<!-- Day headers -->
<div class="grid grid-cols-7 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
{#each DAYS as day}
<div class="px-2 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400">{day}</div>
{/each}
</div>
<!-- Day cells -->
<div class="grid grid-cols-7">
{#each calendarDays as day, i}
{@const dayCards = day ? getCardsForDay(day) : []}
<div
class="min-h-[80px] sm:min-h-[100px] p-1.5 border-b border-r border-gray-100 dark:border-gray-800 {day ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-950'}"
class:border-r-0={(i + 1) % 7 === 0}
>
{#if day}
<div class="text-xs font-medium mb-1 {isToday(day) ? 'w-6 h-6 rounded-full bg-[var(--color-primary)] text-white flex items-center justify-center' : 'text-gray-600 dark:text-gray-400 px-1'}">
{day}
</div>
{#each dayCards.slice(0, 3) as card}
{@const urgency = getDueUrgency(card.dueDate)}
<a
href="/boards/{boardId}?card={card.id}"
class="block rounded px-1.5 py-0.5 mb-0.5 text-[10px] truncate transition hover:opacity-80 {getUrgencyClasses(urgency)}"
title="{cardPrefix && card.cardNumber ? `${cardPrefix}-${card.cardNumber}: ` : ''}{card.title}"
>
{card.title}
</a>
{/each}
{#if dayCards.length > 3}
<div class="text-[10px] text-gray-400 dark:text-gray-500 px-1">+{dayCards.length - 3} more</div>
{/if}
{/if}
</div>
{/each}
</div>
</div>
</div>
+84 -2
View File
@@ -9,6 +9,7 @@
import CommentList from './CommentList.svelte'; import CommentList from './CommentList.svelte';
import AttachmentList from './AttachmentList.svelte'; import AttachmentList from './AttachmentList.svelte';
import ActivityFeed from './ActivityFeed.svelte'; import ActivityFeed from './ActivityFeed.svelte';
import CustomFieldsSection from './CustomFieldsSection.svelte';
import { api } from '$lib/utils/api.js'; import { api } from '$lib/utils/api.js';
type Props = { type Props = {
@@ -18,12 +19,14 @@
canEdit: boolean; canEdit: boolean;
boardMembers: any[]; boardMembers: any[];
currentUserId: string; currentUserId: string;
cardPrefix?: string | null;
onclose: () => void; onclose: () => void;
ondelete: (cardId: string) => void; ondelete: (cardId: string) => void;
onupdate: (card: any) => void; onupdate: (card: any) => void;
oncopied?: (card: any) => void;
}; };
let { card, boardId, columnId, canEdit, boardMembers, currentUserId, onclose, ondelete, onupdate }: Props = $props(); let { card, boardId, columnId, canEdit, boardMembers, currentUserId, cardPrefix, onclose, ondelete, onupdate, oncopied }: Props = $props();
let editingTitle = $state(false); let editingTitle = $state(false);
let title = $state(card.title); let title = $state(card.title);
@@ -110,7 +113,49 @@
body: JSON.stringify({ archived: true }) body: JSON.stringify({ archived: true })
}); });
if (ok) { if (ok) {
ondelete(card.id); card.archived = true;
onupdate(card);
onclose();
}
}
let isWatching = $derived(
fullCard?.watchers?.some((w: any) => w.userId === currentUserId) ?? false
);
async function toggleWatch() {
if (isWatching) {
const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/watch`, { method: 'DELETE' });
if (ok && fullCard) {
fullCard.watchers = fullCard.watchers.filter((w: any) => w.userId !== currentUserId);
}
} else {
const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/watch`, { method: 'POST' });
if (ok && fullCard) {
fullCard.watchers = [...(fullCard.watchers || []), { userId: currentUserId }];
}
}
}
async function copyCard() {
const { ok, data: result } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/copy`, {
method: 'POST'
});
if (ok) {
oncopied?.(result.card);
onclose();
}
}
async function unarchiveCard() {
const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
body: JSON.stringify({ archived: false })
});
if (ok) {
card.archived = false;
onupdate(card);
onclose();
} }
} }
</script> </script>
@@ -126,6 +171,9 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between p-4 border-b border-gray-100 dark:border-gray-700"> <div class="flex items-start justify-between p-4 border-b border-gray-100 dark:border-gray-700">
<div class="flex-1"> <div class="flex-1">
{#if cardPrefix && card.cardNumber}
<span class="text-xs font-mono text-gray-400 dark:text-gray-500 mb-0.5 block">{cardPrefix}-{card.cardNumber}</span>
{/if}
{#if editingTitle && canEdit} {#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}> <form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<input <input
@@ -193,6 +241,13 @@
onupdate={updateChecklists} onupdate={updateChecklists}
/> />
<CustomFieldsSection
cardId={card.id}
{boardId}
{columnId}
{canEdit}
/>
<AttachmentList <AttachmentList
cardId={card.id} cardId={card.id}
{boardId} {boardId}
@@ -244,12 +299,39 @@
<div> <div>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Actions</h3> <h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Actions</h3>
<div class="space-y-1"> <div class="space-y-1">
{#if fullCard}
<button
onclick={toggleWatch}
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition flex items-center gap-1.5"
>
<svg class="w-4 h-4 {isWatching ? 'text-[var(--color-primary)]' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
</svg>
{isWatching ? 'Watching' : 'Watch'}
</button>
{/if}
<button
onclick={copyCard}
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition"
>
Copy
</button>
{#if card.archived}
<button
onclick={unarchiveCard}
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-success)] hover:bg-green-50 dark:hover:bg-green-900/30 transition"
>
Unarchive
</button>
{:else}
<button <button
onclick={archiveCard} onclick={archiveCard}
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition" class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition"
> >
Archive Archive
</button> </button>
{/if}
<button <button
onclick={() => ondelete(card.id)} onclick={() => ondelete(card.id)}
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition" class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition"
+16 -12
View File
@@ -11,23 +11,31 @@
onchange?: (value: string) => void; onchange?: (value: string) => void;
} = $props(); } = $props();
let mode = $state<'color' | 'gradient'>( let mode = $state<'color' | 'gradient'>('color');
value?.startsWith('linear-gradient') || value?.startsWith('radial-gradient') ? 'gradient' : 'color' let color = $state('#000000');
);
let color = $state(mode === 'color' ? (value || '#000000') : '#000000');
let gradStart = $state('#0079bf'); let gradStart = $state('#0079bf');
let gradEnd = $state('#6366f1'); let gradEnd = $state('#6366f1');
let gradDirection = $state('to right'); let gradDirection = $state('to right');
// Parse existing gradient value // Re-sync local state when the value prop changes (e.g. switching Light/Dark tabs)
if (mode === 'gradient' && value) { $effect(() => {
const match = value.match(/linear-gradient\(([^,]+),\s*(#[0-9a-fA-F]{6}),\s*(#[0-9a-fA-F]{6})\)/); const v = value;
if (v?.startsWith('linear-gradient') || v?.startsWith('radial-gradient')) {
mode = 'gradient';
const match = v.match(/(?:linear|radial)-gradient\(([^,]+),\s*(#[0-9a-fA-F]{3,8}),\s*(#[0-9a-fA-F]{3,8})\)/);
if (match) { if (match) {
gradDirection = match[1].trim(); gradDirection = match[1].trim();
gradStart = match[2]; gradStart = match[2];
gradEnd = match[3]; gradEnd = match[3];
} }
} else if (v) {
mode = 'color';
color = v;
} else {
mode = 'color';
color = '#000000';
} }
});
function emitColor() { function emitColor() {
value = color; value = color;
@@ -41,11 +49,7 @@
function switchMode(newMode: 'color' | 'gradient') { function switchMode(newMode: 'color' | 'gradient') {
mode = newMode; mode = newMode;
if (newMode === 'color') { // Don't emit — let the user pick values first
emitColor();
} else {
emitGradient();
}
} }
function clear() { function clear() {
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
type Props = {
currentSort: string | null;
onsort: (sort: string | null) => void;
};
let { currentSort, onsort }: Props = $props();
let open = $state(false);
const options = [
{ value: null, label: 'Manual (default)' },
{ value: 'due-date', label: 'Due date' },
{ value: 'created', label: 'Created date' },
{ value: 'title', label: 'Title (A-Z)' },
{ value: 'assignee', label: 'Assignee' }
];
function select(value: string | null) {
onsort(value);
open = false;
}
</script>
<div class="relative">
<button
onclick={(e) => { e.stopPropagation(); open = !open; }}
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition p-1"
title="Sort cards"
aria-label="Sort cards"
>
<svg class="w-3.5 h-3.5 {currentSort ? 'text-[var(--color-primary)]' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 3a1 1 0 000 2h11a1 1 0 100-2H3zM3 7a1 1 0 000 2h7a1 1 0 100-2H3zM3 11a1 1 0 100 2h4a1 1 0 100-2H3z" />
</svg>
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="absolute top-full right-0 mt-1 z-50 w-40 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl py-1"
onclick={(e) => e.stopPropagation()}
>
{#each options as opt}
<button
onclick={() => select(opt.value)}
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 transition {currentSort === opt.value ? 'text-[var(--color-primary)] font-medium' : 'text-gray-600 dark:text-gray-400'}"
>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
type Props = {
field: any;
value: string;
canEdit: boolean;
onsave: (value: string) => void;
};
let { field, value, canEdit, onsave }: Props = $props();
let editing = $state(false);
let editValue = $state(value);
function startEdit() {
if (!canEdit) return;
editValue = value;
editing = true;
}
function save() {
onsave(editValue);
editing = false;
}
function cancel() {
editing = false;
editValue = value;
}
</script>
<div class="flex items-center gap-2 rounded bg-gray-50 dark:bg-gray-800 px-3 py-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-24 flex-shrink-0 truncate" title={field.name}>
{field.name}
</span>
{#if editing}
<div class="flex-1 flex items-center gap-1">
{#if field.type === 'TEXT'}
<input
type="text"
bind:value={editValue}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none"
autofocus
onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }}
/>
{:else if field.type === 'NUMBER'}
<input
type="number"
bind:value={editValue}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none"
autofocus
onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }}
/>
{:else if field.type === 'DATE'}
<input
type="date"
bind:value={editValue}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs focus:border-[var(--color-primary)] focus:outline-none"
autofocus
/>
{:else if field.type === 'SELECT'}
<select
bind:value={editValue}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs"
>
<option value=""></option>
{#each (field.options || []) as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else if field.type === 'MULTI_SELECT'}
<div class="flex-1 flex flex-wrap gap-1">
{#each (field.options || []) as opt}
{@const selected = editValue.split(',').includes(opt)}
<button
type="button"
onclick={() => {
const current = editValue ? editValue.split(',') : [];
if (selected) {
editValue = current.filter((v) => v !== opt).join(',');
} else {
editValue = [...current, opt].join(',');
}
}}
class="rounded-full px-2 py-0.5 text-[10px] transition {selected ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}"
>
{opt}
</button>
{/each}
</div>
{/if}
<button onclick={save} class="text-[10px] text-[var(--color-primary)] hover:underline">Save</button>
<button onclick={cancel} class="text-[10px] text-gray-400 hover:text-gray-600">Cancel</button>
</div>
{:else}
<button
class="flex-1 text-left text-xs text-gray-700 dark:text-gray-300 {canEdit ? 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1 py-0.5 -mx-1' : ''} truncate"
onclick={startEdit}
disabled={!canEdit}
>
{#if value}
{#if field.type === 'MULTI_SELECT'}
<span class="flex flex-wrap gap-1">
{#each value.split(',') as v}
<span class="rounded-full bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 text-[10px]">{v}</span>
{/each}
</span>
{:else if field.type === 'DATE'}
{new Date(value).toLocaleDateString()}
{:else}
{value}
{/if}
{:else}
<span class="text-gray-400 dark:text-gray-500 italic">Empty</span>
{/if}
</button>
{/if}
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import CustomFieldEditor from './CustomFieldEditor.svelte';
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
canEdit: boolean;
};
let { cardId, boardId, columnId, canEdit }: Props = $props();
let fields = $state<any[]>([]);
let values = $state<Record<string, string>>({});
let loaded = $state(false);
$effect(() => {
loadFields();
});
async function loadFields() {
const [fieldsRes, valuesRes] = await Promise.all([
api(`/api/boards/${boardId}/custom-fields`),
api(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/custom-fields`)
]);
if (fieldsRes.ok) fields = fieldsRes.data.fields;
if (valuesRes.ok) {
const map: Record<string, string> = {};
for (const v of valuesRes.data.values) {
map[v.fieldId] = v.value;
}
values = map;
}
loaded = true;
}
async function saveValue(fieldId: string, value: string) {
const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/custom-fields`, {
method: 'PUT',
body: JSON.stringify({ fieldId, value: value || null })
});
if (ok) {
if (value) {
values = { ...values, [fieldId]: value };
} else {
const next = { ...values };
delete next[fieldId];
values = next;
}
}
}
</script>
{#if loaded && fields.length > 0}
<div>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Custom Fields</h3>
<div class="space-y-2">
{#each fields as field (field.id)}
<CustomFieldEditor
{field}
value={values[field.id] || ''}
{canEdit}
onsave={(v) => saveValue(field.id, v)}
/>
{/each}
</div>
</div>
{/if}
@@ -27,6 +27,9 @@
case 'b': case 'b':
goto('/boards'); goto('/boards');
break; break;
case 'm':
goto('/my-work');
break;
case '?': case '?':
e.preventDefault(); e.preventDefault();
showHelp = !showHelp; showHelp = !showHelp;
@@ -49,6 +52,7 @@
const shortcuts = [ const shortcuts = [
{ key: '/', desc: 'Focus search' }, { key: '/', desc: 'Focus search' },
{ key: 'b', desc: 'Go to boards' }, { key: 'b', desc: 'Go to boards' },
{ key: 'm', desc: 'My Work' },
{ key: '?', desc: 'Toggle this help' }, { key: '?', desc: 'Toggle this help' },
{ key: 'Esc', desc: 'Close dialogs' }, { key: 'Esc', desc: 'Close dialogs' },
{ key: 'n', desc: 'New card (on board)' } { key: 'n', desc: 'New card (on board)' }
+148
View File
@@ -0,0 +1,148 @@
import type { Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
interface AutomationContext {
cardId: string;
cardTitle?: string;
fromColumnId?: string;
toColumnId?: string;
labelTagId?: string;
userId?: string;
}
const MAX_DEPTH = 3;
export async function runAutomations(
tx: TxClient,
boardId: string,
trigger: string,
context: AutomationContext,
depth = 0
) {
if (depth >= MAX_DEPTH) return;
const rules = await tx.automationRule.findMany({
where: { boardId, trigger: trigger as any, active: true }
});
for (const rule of rules) {
const triggerConfig = (rule.triggerConfig as Record<string, any>) || {};
const actionConfig = (rule.actionConfig as Record<string, any>) || {};
// Check trigger conditions
if (!matchesTrigger(trigger, triggerConfig, context)) continue;
// Execute action
await executeAction(tx, boardId, rule.action, actionConfig, context, depth);
}
}
function matchesTrigger(
trigger: string,
config: Record<string, any>,
context: AutomationContext
): boolean {
switch (trigger) {
case 'CARD_MOVED':
if (config.toColumnId && config.toColumnId !== context.toColumnId) return false;
if (config.fromColumnId && config.fromColumnId !== context.fromColumnId) return false;
return true;
case 'LABEL_ADDED':
if (config.tagId && config.tagId !== context.labelTagId) return false;
return true;
case 'CARD_CREATED':
return true;
case 'DUE_PASSED':
return true;
default:
return false;
}
}
async function executeAction(
tx: TxClient,
boardId: string,
action: string,
config: Record<string, any>,
context: AutomationContext,
depth: number
) {
const { cardId } = context;
switch (action) {
case 'ADD_LABEL': {
if (!config.tagId) return;
const existing = await tx.cardLabel.findFirst({
where: { cardId, tagId: config.tagId }
});
if (!existing) {
await tx.cardLabel.create({
data: { cardId, tagId: config.tagId }
});
// Potentially trigger LABEL_ADDED automation
await runAutomations(tx, boardId, 'LABEL_ADDED', {
...context,
labelTagId: config.tagId
}, depth + 1);
}
break;
}
case 'REMOVE_LABEL': {
if (!config.tagId) return;
await tx.cardLabel.deleteMany({
where: { cardId, tagId: config.tagId }
});
break;
}
case 'ASSIGN': {
if (!config.userId) return;
const existing = await tx.cardAssignee.findFirst({
where: { cardId, userId: config.userId }
});
if (!existing) {
await tx.cardAssignee.create({
data: { cardId, userId: config.userId }
});
}
break;
}
case 'SET_DUE': {
if (!config.daysFromNow) return;
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + parseInt(config.daysFromNow));
await tx.card.update({
where: { id: cardId },
data: { dueDate }
});
break;
}
case 'MOVE_TO_COLUMN': {
if (!config.columnId) return;
const column = await tx.column.findFirst({
where: { id: config.columnId, boardId }
});
if (!column) return;
await tx.card.update({
where: { id: cardId },
data: { columnId: config.columnId }
});
// Potentially trigger CARD_MOVED automation
await runAutomations(tx, boardId, 'CARD_MOVED', {
...context,
toColumnId: config.columnId
}, depth + 1);
break;
}
}
}
+27
View File
@@ -33,3 +33,30 @@ export async function notify(tx: TxClient, opts: NotifyOptions) {
return notification; return notification;
} }
/**
* Notify all watchers of a card, excluding a set of already-notified users.
*/
export async function notifyWatchers(
tx: TxClient,
cardId: string,
excludeUserIds: string[],
title: string,
link?: string
) {
const watchers = await tx.cardWatcher.findMany({
where: { cardId },
select: { userId: true }
});
const excludeSet = new Set(excludeUserIds);
for (const watcher of watchers) {
if (excludeSet.has(watcher.userId)) continue;
await notify(tx, {
userId: watcher.userId,
type: 'card_watched',
title,
link
});
}
}
+4
View File
@@ -8,6 +8,8 @@ export interface TenantInfo {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
logoUrl?: string | null;
logoText?: string | null;
theme?: TenantThemeData | null; theme?: TenantThemeData | null;
} }
@@ -32,6 +34,8 @@ export async function resolveTenant(hostname: string): Promise<TenantInfo | null
id: record.tenant.id, id: record.tenant.id,
name: record.tenant.name, name: record.tenant.name,
slug: record.tenant.slug, slug: record.tenant.slug,
logoUrl: record.tenant.logoUrl ?? null,
logoText: record.tenant.logoText ?? null,
theme: record.tenant.theme ?? null theme: record.tenant.theme ?? null
}; };
tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL }); tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL });
+2 -1
View File
@@ -16,7 +16,8 @@ export const boardSchema = z.object({
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(), categoryId: z.string().uuid().optional(),
templateId: z.string().uuid().optional() templateId: z.string().uuid().optional(),
background: z.string().max(500).optional().nullable()
}); });
export const columnSchema = z.object({ export const columnSchema = z.object({
+29
View File
@@ -0,0 +1,29 @@
import { prisma } from './db.js';
import crypto from 'crypto';
export async function dispatchWebhooks(tenantId: string, event: string, data: any) {
// Don't await this - fire and forget
doDispatch(tenantId, event, data).catch(() => {});
}
async function doDispatch(tenantId: string, event: string, data: any) {
const webhooks = await prisma.webhook.findMany({
where: { tenantId, active: true, events: { has: event } }
});
const payload = JSON.stringify({ event, data, timestamp: new Date().toISOString() });
for (const webhook of webhooks) {
const signature = crypto.createHmac('sha256', webhook.secret).update(payload).digest('hex');
fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Pounce-Signature': signature,
'X-Pounce-Event': event
},
body: payload
}).catch(() => {});
}
}
+20 -1
View File
@@ -29,13 +29,17 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 items-center justify-between"> <div class="flex h-14 items-center justify-between">
<a href="/" class="flex items-center gap-2 text-white font-bold text-lg"> <a href="/" class="flex items-center gap-2 text-white font-bold text-lg">
{#if data.tenant?.logoUrl}
<img src={data.tenant.logoUrl} alt="" class="h-7 w-auto" />
{:else}
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<circle cx="7" cy="5" r="2.2" /> <circle cx="7" cy="5" r="2.2" />
<circle cx="12" cy="3.5" r="2.2" /> <circle cx="12" cy="3.5" r="2.2" />
<circle cx="17" cy="5" r="2.2" /> <circle cx="17" cy="5" r="2.2" />
<ellipse cx="12" cy="14" rx="6" ry="7" /> <ellipse cx="12" cy="14" rx="6" ry="7" />
</svg> </svg>
{data.tenant?.name ?? 'Pounce'} {/if}
{data.tenant?.logoText ?? data.tenant?.name ?? 'Pounce'}
</a> </a>
{#if data.user} {#if data.user}
@@ -46,6 +50,14 @@
<!-- Desktop nav --> <!-- Desktop nav -->
<div class="hidden sm:flex items-center gap-3"> <div class="hidden sm:flex items-center gap-3">
{#if data.user}
<a
href="/my-work"
class="rounded px-2 py-1 text-sm text-white/80 hover:text-white hover:bg-white/20 transition"
>
My Work
</a>
{/if}
<button <button
onclick={toggleTheme} onclick={toggleTheme}
class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition" class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
@@ -176,6 +188,13 @@
Admin Admin
</a> </a>
{/if} {/if}
<a
href="/my-work"
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
onclick={() => (mobileMenuOpen = false)}
>
My Work
</a>
<a <a
href="/boards" href="/boards"
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center" class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
+235
View File
@@ -20,6 +20,101 @@
let editingCategoryId = $state<string | null>(null); let editingCategoryId = $state<string | null>(null);
let editCategoryName = $state(''); let editCategoryName = $state('');
// Invites
let invites = $state<any[]>([]);
let invitesLoaded = $state(false);
let creatingInvite = $state(false);
// Webhooks
let webhooks = $state<any[]>([]);
let webhooksLoaded = $state(false);
let newWebhookUrl = $state('');
let newWebhookEvents = $state<string[]>([]);
const availableEvents = ['card:created', 'card:updated', 'card:deleted', 'card:moved', 'column:created', 'column:deleted', 'board:updated'];
async function loadWebhooks() {
const result = await api('/api/admin/webhooks');
if (result.ok) {
webhooks = result.data.webhooks;
webhooksLoaded = true;
}
}
async function createWebhook() {
if (!newWebhookUrl.trim() || newWebhookEvents.length === 0) return;
const result = await api('/api/admin/webhooks', {
method: 'POST',
body: JSON.stringify({ url: newWebhookUrl.trim(), events: newWebhookEvents })
});
if (result.ok) {
webhooks = [...webhooks, result.data.webhook];
newWebhookUrl = '';
newWebhookEvents = [];
toast.success('Webhook created');
}
}
async function toggleWebhook(id: string, active: boolean) {
const result = await api(`/api/admin/webhooks/${id}`, {
method: 'PATCH',
body: JSON.stringify({ active })
});
if (result.ok) {
webhooks = webhooks.map((w: any) => (w.id === id ? result.data.webhook : w));
}
}
async function deleteWebhook(id: string) {
const result = await api(`/api/admin/webhooks/${id}`, { method: 'DELETE' });
if (result.ok) {
webhooks = webhooks.filter((w: any) => w.id !== id);
toast.success('Webhook deleted');
}
}
function toggleEvent(event: string) {
if (newWebhookEvents.includes(event)) {
newWebhookEvents = newWebhookEvents.filter((e) => e !== event);
} else {
newWebhookEvents = [...newWebhookEvents, event];
}
}
async function loadInvites() {
const result = await api('/api/admin/invites');
if (result.ok) {
invites = result.data.invites;
invitesLoaded = true;
}
}
async function createInvite() {
creatingInvite = true;
const result = await api('/api/admin/invites', {
method: 'POST',
body: JSON.stringify({ expiresInDays: 7 })
});
if (result.ok) {
invites = [result.data.invite, ...invites];
toast.success('Invite link created');
}
creatingInvite = false;
}
async function deleteInvite(id: string) {
const result = await api(`/api/admin/invites/${id}`, { method: 'DELETE' });
if (result.ok) {
invites = invites.filter((i: any) => i.id !== id);
toast.success('Invite deleted');
}
}
function copyInviteLink(code: string) {
const url = `${window.location.origin}/invite/${code}`;
navigator.clipboard.writeText(url);
toast.success('Link copied to clipboard');
}
async function changeRole(userId: string, tenantRole: string) { async function changeRole(userId: string, tenantRole: string) {
const result = await api(`/api/admin/users/${userId}`, { const result = await api(`/api/admin/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
@@ -292,6 +387,146 @@
</div> </div>
</section> </section>
<!-- Invite Links -->
<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Invite Links</h2>
<div class="flex items-center gap-2">
{#if !invitesLoaded}
<button
onclick={loadInvites}
class="text-sm text-[var(--color-primary)] hover:underline"
>
Load invites
</button>
{/if}
<button
onclick={createInvite}
disabled={creatingInvite}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{creatingInvite ? 'Creating...' : '+ New Invite'}
</button>
</div>
</div>
{#if invitesLoaded}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
{#if invites.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No invite links yet</p>
{:else}
<div class="space-y-2">
{#each invites as invite (invite.id)}
{@const expired = new Date(invite.expiresAt) < new Date()}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
<code class="text-xs font-mono text-gray-600 dark:text-gray-400">{invite.code}</code>
<span class="text-xs text-gray-500 dark:text-gray-400">
{invite.role} &middot; {invite.usedCount}{invite.maxUses ? `/${invite.maxUses}` : ''} uses
</span>
{#if expired}
<span class="text-xs text-red-500">Expired</span>
{:else}
<span class="text-xs text-green-600 dark:text-green-400">
Expires {formatDate(invite.expiresAt)}
</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<button onclick={() => copyInviteLink(invite.code)} class="text-xs text-[var(--color-primary)] hover:underline">Copy link</button>
<button onclick={() => deleteInvite(invite.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
<!-- Webhooks -->
<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Webhooks</h2>
{#if !webhooksLoaded}
<button
onclick={loadWebhooks}
class="text-sm text-[var(--color-primary)] hover:underline"
>
Load webhooks
</button>
{/if}
</div>
{#if webhooksLoaded}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Create webhook -->
<form
onsubmit={(e) => { e.preventDefault(); createWebhook(); }}
class="mb-4 space-y-3"
>
<div>
<label for="new-webhook-url" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">URL</label>
<input
id="new-webhook-url"
type="url"
bind:value={newWebhookUrl}
placeholder="https://example.com/webhook"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Events</label>
<div class="flex flex-wrap gap-2">
{#each availableEvents as event}
<button
type="button"
onclick={() => toggleEvent(event)}
class="rounded-full px-2.5 py-1 text-xs font-medium transition {newWebhookEvents.includes(event) ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}"
>
{event}
</button>
{/each}
</div>
</div>
<button
type="submit"
disabled={!newWebhookUrl.trim() || newWebhookEvents.length === 0}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
Add Webhook
</button>
</form>
<!-- Webhook list -->
{#if webhooks.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No webhooks yet</p>
{:else}
<div class="space-y-2">
{#each webhooks as webhook (webhook.id)}
<div class="flex items-center gap-3 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{webhook.url}</div>
<div class="flex flex-wrap gap-1 mt-1">
{#each webhook.events as event}
<span class="rounded bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 text-xs text-gray-600 dark:text-gray-400">{event}</span>
{/each}
</div>
</div>
<label class="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={webhook.active}
onchange={() => toggleWebhook(webhook.id, !webhook.active)}
class="rounded border-gray-300 dark:border-gray-600 text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">Active</span>
</label>
<button onclick={() => deleteWebhook(webhook.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
<!-- Categories --> <!-- Categories -->
<section> <section>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Categories</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Categories</h2>
+14 -4
View File
@@ -8,9 +8,19 @@ export const load: PageServerLoad = async ({ locals }) => {
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user); requireTenantAdmin(locals.user);
const theme = await withTenant(tenant.id, (tx) => const [theme, tenantData] = await withTenant(tenant.id, async (tx) => {
tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } }) const t = await tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } });
); const td = await tx.tenant.findUnique({
where: { id: tenant.id },
select: { logoUrl: true, logoText: true }
});
return [t, td] as const;
});
return { theme: theme ?? null, tenantName: tenant.name }; return {
theme: theme ?? null,
tenantName: tenant.name,
logoUrl: tenantData?.logoUrl ?? null,
logoText: tenantData?.logoText ?? null
};
}; };
+26 -1
View File
@@ -13,6 +13,7 @@
let activeTab = $state<'light' | 'dark'>('light'); let activeTab = $state<'light' | 'dark'>('light');
let saving = $state(false); let saving = $state(false);
let logoText = $state(data.logoText ?? '');
// Draft theme — initialize from server data // Draft theme — initialize from server data
let draft = $state<TenantThemeData>(data.theme ? { ...data.theme } : {}); let draft = $state<TenantThemeData>(data.theme ? { ...data.theme } : {});
@@ -51,7 +52,7 @@
saving = true; saving = true;
const { ok } = await api('/api/admin/branding', { const { ok } = await api('/api/admin/branding', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(draft) body: JSON.stringify({ ...draft, logoText: logoText || null })
}); });
if (ok) { if (ok) {
toast.success('Theme saved'); toast.success('Theme saved');
@@ -140,6 +141,30 @@
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<!-- Logo & Identity -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Logo & Identity</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ImageUploadField
label="Logo image"
slot="logo"
currentUrl={data.logoUrl || null}
onchange={(url) => { /* saved via image upload endpoint */ }}
/>
<div class="space-y-1.5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">Logo text</label>
<input
type="text"
bind:value={logoText}
placeholder={data.tenantName || 'Pounce'}
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none"
maxlength="100"
/>
<p class="text-[10px] text-gray-400 dark:text-gray-500">Custom text shown next to the logo in the nav bar</p>
</div>
</div>
</section>
<!-- Page Background --> <!-- Page Background -->
<section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5"> <section class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Page Background</h2> <h2 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">Page Background</h2>
+11
View File
@@ -86,6 +86,17 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
} }
} }
// Handle logoText on Tenant model (not TenantTheme)
if ('logoText' in body) {
const logoText = body.logoText === null || body.logoText === '' ? null : String(body.logoText).slice(0, 100);
await withTenant(tenant.id, (tx) =>
tx.tenant.update({
where: { id: tenant.id },
data: { logoText }
})
);
}
const theme = await withTenant(tenant.id, (tx) => const theme = await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({ tx.tenantTheme.upsert({
where: { tenantId: tenant.id }, where: { tenantId: tenant.id },
@@ -8,14 +8,15 @@ import { join } from 'path';
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const UPLOAD_ROOT = '/app/uploads'; const UPLOAD_ROOT = '/app/uploads';
const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg'] as const; const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg', 'logo'] as const;
type Slot = typeof VALID_SLOTS[number]; type Slot = typeof VALID_SLOTS[number];
const SLOT_TO_FIELD: Record<Slot, string> = { const SLOT_TO_FIELD: Record<Slot, string> = {
'light-page-bg': 'lightPageBgImage', 'light-page-bg': 'lightPageBgImage',
'light-nav-bg': 'lightNavBgImage', 'light-nav-bg': 'lightNavBgImage',
'dark-page-bg': 'darkPageBgImage', 'dark-page-bg': 'darkPageBgImage',
'dark-nav-bg': 'darkNavBgImage' 'dark-nav-bg': 'darkNavBgImage',
'logo': 'logoUrl'
}; };
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
@@ -42,6 +43,14 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const imageUrl = `/api/branding/${tenant.id}/${slot}`; const imageUrl = `/api/branding/${tenant.id}/${slot}`;
const field = SLOT_TO_FIELD[slot as Slot]; const field = SLOT_TO_FIELD[slot as Slot];
if (slot === 'logo') {
await withTenant(tenant.id, (tx) =>
tx.tenant.update({
where: { id: tenant.id },
data: { logoUrl: imageUrl }
})
);
} else {
await withTenant(tenant.id, (tx) => await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({ tx.tenantTheme.upsert({
where: { tenantId: tenant.id }, where: { tenantId: tenant.id },
@@ -49,6 +58,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
update: { [field]: imageUrl } update: { [field]: imageUrl }
}) })
); );
}
clearTenantCache(); clearTenantCache();
@@ -73,6 +83,14 @@ export const DELETE: RequestHandler = async ({ request, locals }) => {
const field = SLOT_TO_FIELD[slot as Slot]; const field = SLOT_TO_FIELD[slot as Slot];
if (slot === 'logo') {
await withTenant(tenant.id, (tx) =>
tx.tenant.update({
where: { id: tenant.id },
data: { logoUrl: null }
})
);
} else {
await withTenant(tenant.id, (tx) => await withTenant(tenant.id, (tx) =>
tx.tenantTheme.upsert({ tx.tenantTheme.upsert({
where: { tenantId: tenant.id }, where: { tenantId: tenant.id },
@@ -80,6 +98,7 @@ export const DELETE: RequestHandler = async ({ request, locals }) => {
update: { [field]: null } update: { [field]: null }
}) })
); );
}
clearTenantCache(); clearTenantCache();
+51
View File
@@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
import crypto from 'crypto';
// List all invites for the current tenant
export const GET: RequestHandler = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const invites = await withTenant(tenant.id, async (tx) => {
return tx.tenantInvite.findMany({
where: { tenantId: tenant.id },
orderBy: { expiresAt: 'desc' }
});
});
return json({ invites });
};
// Create a new invite
export const POST: RequestHandler = async ({ locals, request }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const body = await request.json().catch(() => ({}));
const role = body.role === 'ADMIN' ? 'ADMIN' : 'MEMBER';
const maxUses = typeof body.maxUses === 'number' && body.maxUses > 0 ? body.maxUses : null;
const expiresInDays = typeof body.expiresInDays === 'number' && body.expiresInDays > 0 ? body.expiresInDays : 7;
const code = crypto.randomBytes(9).toString('base64url');
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
const invite = await withTenant(tenant.id, async (tx) => {
return tx.tenantInvite.create({
data: {
tenantId: tenant.id,
code,
role,
expiresAt,
createdBy: locals.user!.id,
maxUses
}
});
});
return json({ invite }, { status: 201 });
};
@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
// Delete an invite
export const DELETE: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
await withTenant(tenant.id, async (tx) => {
const invite = await tx.tenantInvite.findFirst({
where: { id: params.inviteId, tenantId: tenant.id }
});
if (!invite) throw error(404, 'Invite not found');
await tx.tenantInvite.delete({
where: { id: params.inviteId }
});
});
return json({ success: true });
};
+50
View File
@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
import crypto from 'crypto';
// List all webhooks for the current tenant
export const GET: RequestHandler = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const webhooks = await withTenant(tenant.id, async (tx) => {
return tx.webhook.findMany({
where: { tenantId: tenant.id },
orderBy: { url: 'asc' }
});
});
return json({ webhooks });
};
// Create a new webhook
export const POST: RequestHandler = async ({ locals, request }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const body = await request.json().catch(() => ({}));
const { url, events, secret } = body;
if (!url || typeof url !== 'string') throw error(400, 'URL is required');
if (!events || !Array.isArray(events) || events.length === 0) throw error(400, 'At least one event is required');
const webhookSecret = secret && typeof secret === 'string' ? secret : crypto.randomBytes(32).toString('hex');
const webhook = await withTenant(tenant.id, async (tx) => {
return tx.webhook.create({
data: {
tenantId: tenant.id,
url,
events,
secret: webhookSecret,
active: true
}
});
});
return json({ webhook }, { status: 201 });
};
@@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireTenantAdmin } from '$lib/server/guards.js';
// Update a webhook
export const PATCH: RequestHandler = async ({ params, locals, request }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
const body = await request.json().catch(() => ({}));
const data: Record<string, any> = {};
if (typeof body.url === 'string') data.url = body.url;
if (Array.isArray(body.events)) data.events = body.events;
if (typeof body.active === 'boolean') data.active = body.active;
const webhook = await withTenant(tenant.id, async (tx) => {
const existing = await tx.webhook.findFirst({
where: { id: params.webhookId, tenantId: tenant.id }
});
if (!existing) throw error(404, 'Webhook not found');
return tx.webhook.update({
where: { id: params.webhookId },
data
});
});
return json({ webhook });
};
// Delete a webhook
export const DELETE: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireTenantAdmin(locals.user);
await withTenant(tenant.id, async (tx) => {
const webhook = await tx.webhook.findFirst({
where: { id: params.webhookId, tenantId: tenant.id }
});
if (!webhook) throw error(404, 'Webhook not found');
await tx.webhook.delete({
where: { id: params.webhookId }
});
});
return json({ success: true });
};
+2
View File
@@ -53,6 +53,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
} }
const { title, description, visibility, categoryId, templateId } = result.data; const { title, description, visibility, categoryId, templateId } = result.data;
const cardPrefix = title.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD';
// Load template columns if templateId provided // Load template columns if templateId provided
let templateColumns: { title: string }[] = []; let templateColumns: { title: string }[] = [];
@@ -77,6 +78,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
title, title,
description, description,
visibility, visibility,
cardPrefix,
...(categoryId ? { categoryId } : {}), ...(categoryId ? { categoryId } : {}),
members: { members: {
create: { create: {
@@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js';
const VALID_TRIGGERS = ['CARD_MOVED', 'DUE_PASSED', 'LABEL_ADDED', 'CARD_CREATED'];
const VALID_ACTIONS = ['ADD_LABEL', 'REMOVE_LABEL', 'ASSIGN', 'SET_DUE', 'MOVE_TO_COLUMN'];
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const rules = await withTenant(tenant.id, async (tx) => {
const board = await tx.board.findFirst({
where: { id: params.boardId, tenantId: tenant.id },
include: { members: true }
});
if (!board) throw error(404, 'Board not found');
if (!canViewBoard(locals.user, board.visibility, board.members)) {
throw error(403, 'Access denied');
}
return tx.automationRule.findMany({
where: { boardId: params.boardId },
orderBy: { trigger: 'asc' }
});
});
return json({ rules });
};
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 { trigger, triggerConfig, action, actionConfig } = body;
if (!trigger || !VALID_TRIGGERS.includes(trigger)) throw error(400, 'Invalid trigger');
if (!action || !VALID_ACTIONS.includes(action)) throw error(400, 'Invalid action');
const rule = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
return tx.automationRule.create({
data: {
boardId: params.boardId,
trigger,
triggerConfig: triggerConfig || null,
action,
actionConfig: actionConfig || null
}
});
});
return json({ rule }, { status: 201 });
};
@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } 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 rule = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.automationRule.findFirst({
where: { id: params.ruleId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Rule not found');
const data: Record<string, any> = {};
if (body.active !== undefined) data.active = body.active;
if (body.triggerConfig !== undefined) data.triggerConfig = body.triggerConfig;
if (body.actionConfig !== undefined) data.actionConfig = body.actionConfig;
return tx.automationRule.update({
where: { id: params.ruleId },
data
});
});
return json({ rule });
};
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 requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.automationRule.findFirst({
where: { id: params.ruleId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Rule not found');
await tx.automationRule.delete({ where: { id: params.ruleId } });
});
return json({ ok: true });
};
@@ -0,0 +1,140 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } from '$lib/server/guards.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
const VALID_ACTIONS = ['move', 'archive', 'delete', 'addLabel', 'assign'] as const;
type BulkAction = (typeof VALID_ACTIONS)[number];
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 { action, cardIds, columnId, tagId, userId } = body as {
action: string;
cardIds: string[];
columnId?: string;
tagId?: string;
userId?: string;
};
if (!action || !VALID_ACTIONS.includes(action as BulkAction)) {
throw error(400, `Invalid action. Must be one of: ${VALID_ACTIONS.join(', ')}`);
}
if (!Array.isArray(cardIds) || cardIds.length === 0) {
throw error(400, 'cardIds must be a non-empty array');
}
if (action === 'move' && !columnId) {
throw error(400, 'columnId is required for move action');
}
if (action === 'addLabel' && !tagId) {
throw error(400, 'tagId is required for addLabel action');
}
if (action === 'assign' && !userId) {
throw error(400, 'userId is required for assign action');
}
const count = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
// Verify all cards belong to this board
const cards = await tx.card.findMany({
where: {
id: { in: cardIds },
column: { board: { id: params.boardId, tenantId: tenant.id } }
},
select: { id: true, columnId: true }
});
const validCardIds = cards.map((c) => c.id);
if (validCardIds.length === 0) {
throw error(404, 'No matching cards found');
}
let affected = 0;
switch (action as BulkAction) {
case 'move': {
// Verify target column belongs to this board
const column = await tx.column.findFirst({
where: { id: columnId, board: { id: params.boardId, tenantId: tenant.id } }
});
if (!column) throw error(404, 'Target column not found');
const result = await tx.card.updateMany({
where: { id: { in: validCardIds } },
data: { columnId: columnId! }
});
affected = result.count;
break;
}
case 'archive': {
const result = await tx.card.updateMany({
where: { id: { in: validCardIds } },
data: { archived: true }
});
affected = result.count;
break;
}
case 'delete': {
const result = await tx.card.deleteMany({
where: { id: { in: validCardIds } }
});
affected = result.count;
break;
}
case 'addLabel': {
const tag = await tx.tag.findFirst({
where: { id: tagId, tenantId: tenant.id }
});
if (!tag) throw error(404, 'Tag not found');
const result = await tx.cardLabel.createMany({
data: validCardIds.map((cardId) => ({ cardId, tagId: tagId! })),
skipDuplicates: true
});
affected = result.count;
break;
}
case 'assign': {
const member = await tx.boardMember.findFirst({
where: { boardId: params.boardId, userId: userId }
});
if (!member) throw error(400, 'User is not a board member');
const result = await tx.cardAssignee.createMany({
data: validCardIds.map((cardId) => ({ cardId, userId: userId! })),
skipDuplicates: true
});
affected = result.count;
break;
}
}
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: `bulk.${action}`,
metadata: { cardIds: validCardIds, count: affected }
});
return affected;
});
// Broadcast individual events per card
const socketId = request.headers.get('X-Socket-ID');
const event = action === 'delete' ? 'card:deleted' : 'card:updated';
for (const cardId of cardIds) {
broadcast(params.boardId, event, { cardId, boardId: params.boardId, socketId });
}
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
return json({ ok: true, count });
};
@@ -6,6 +6,7 @@ import { withTenant } from '$lib/server/rls.js';
import { requireColumnEditor } from '$lib/server/guards.js'; import { requireColumnEditor } from '$lib/server/guards.js';
import { broadcast } from '$lib/server/realtime.js'; import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js'; import { logActivity } from '$lib/server/activity.js';
import { runAutomations } from '$lib/server/automations.js';
// Create card in column // Create card in column
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
@@ -29,12 +30,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const position = generatePosition(lastCard?.position, null); const position = generatePosition(lastCard?.position, null);
// Assign card number
const board = await tx.board.update({
where: { id: params.boardId },
data: { nextCardNum: { increment: 1 } },
select: { nextCardNum: true }
});
const cardNumber = board.nextCardNum - 1;
const newCard = await tx.card.create({ const newCard = await tx.card.create({
data: { data: {
columnId: params.columnId, columnId: params.columnId,
title: result.data.title, title: result.data.title,
description: result.data.description, description: result.data.description,
position position,
cardNumber
}, },
include: { include: {
assignees: { assignees: {
@@ -53,6 +63,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
metadata: { title: result.data.title } metadata: { title: result.data.title }
}); });
// Run automations for CARD_CREATED
await runAutomations(tx, params.boardId, 'CARD_CREATED', {
cardId: newCard.id,
cardTitle: newCard.title
});
return newCard; return newCard;
}); });
@@ -6,6 +6,8 @@ import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js'; import { canViewBoard } from '$lib/server/permissions.js';
import { broadcast } from '$lib/server/realtime.js'; import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js'; import { logActivity } from '$lib/server/activity.js';
import { notifyWatchers } from '$lib/server/notifications.js';
import { runAutomations } from '$lib/server/automations.js';
// Get single card with full details // Get single card with full details
export const GET: RequestHandler = async ({ params, locals }) => { export const GET: RequestHandler = async ({ params, locals }) => {
@@ -33,6 +35,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
include: { user: { select: { id: true, name: true, avatarUrl: true } } } include: { user: { select: { id: true, name: true, avatarUrl: true } } }
}, },
attachments: { orderBy: { createdAt: 'desc' } }, attachments: { orderBy: { createdAt: 'desc' } },
watchers: { select: { userId: true } },
column: { column: {
select: { select: {
id: true, id: true,
@@ -103,6 +106,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
cardId: params.cardId, cardId: params.cardId,
metadata: { from: oldCard?.column.title, to: moved.column.title } metadata: { from: oldCard?.column.title, to: moved.column.title }
}); });
// Run automations for CARD_MOVED
await runAutomations(tx, params.boardId, 'CARD_MOVED', {
cardId: params.cardId,
cardTitle: moved.title,
fromColumnId: oldCard?.columnId,
toColumnId: body.columnId
});
} }
const { column: _col, ...cardData } = moved; const { column: _col, ...cardData } = moved;
@@ -116,7 +127,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
} }
const updateData: Record<string, unknown> = { ...result.data }; const updateData: Record<string, unknown> = { ...result.data };
if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; if (body.dueDate !== undefined) {
updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
updateData.dueReminderSent = false;
}
if (body.archived !== undefined) updateData.archived = body.archived; if (body.archived !== undefined) updateData.archived = body.archived;
const updated = await tx.card.update({ const updated = await tx.card.update({
@@ -149,6 +163,15 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
}); });
} }
// Notify watchers
await notifyWatchers(
tx,
params.cardId,
[locals.user!.id],
`"${updated.title}" was updated`,
`/boards/${params.boardId}?card=${params.cardId}`
);
return updated; return updated;
}); });
@@ -0,0 +1,130 @@
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 { generatePosition } from '$lib/utils/fractional-index.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const card = await withTenant(tenant.id, async (tx) => {
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
// Fetch full card data
const original = await tx.card.findFirst({
where: { id: params.cardId },
include: {
labels: true,
assignees: true,
checklists: {
include: { items: { orderBy: { position: 'asc' } } },
orderBy: { position: 'asc' }
}
}
});
if (!original) throw error(404, 'Card not found');
// Position after original
const nextCard = await tx.card.findFirst({
where: {
columnId: original.columnId,
position: { gt: original.position }
},
orderBy: { position: 'asc' }
});
const position = generatePosition(original.position, nextCard?.position ?? null);
// Assign card number
const board = await tx.board.update({
where: { id: params.boardId },
data: { nextCardNum: { increment: 1 } },
select: { nextCardNum: true }
});
const cardNumber = board.nextCardNum - 1;
// Create the copy
const newCard = await tx.card.create({
data: {
columnId: original.columnId,
title: `Copy of ${original.title}`,
description: original.description,
dueDate: original.dueDate,
position,
cardNumber
}
});
// Copy labels
if (original.labels.length > 0) {
await tx.cardLabel.createMany({
data: original.labels.map((l) => ({
cardId: newCard.id,
tagId: l.tagId
}))
});
}
// Copy assignees
if (original.assignees.length > 0) {
await tx.cardAssignee.createMany({
data: original.assignees.map((a) => ({
cardId: newCard.id,
userId: a.userId
}))
});
}
// Copy checklists + items (all unchecked)
for (const cl of original.checklists) {
const newCl = await tx.checklist.create({
data: {
cardId: newCard.id,
title: cl.title,
position: cl.position
}
});
if (cl.items.length > 0) {
await tx.checklistItem.createMany({
data: cl.items.map((item) => ({
checklistId: newCl.id,
title: item.title,
completed: false,
position: item.position
}))
});
}
}
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: 'card.copied',
cardId: newCard.id,
metadata: { originalId: original.id, title: newCard.title }
});
// Return full card for UI
return tx.card.findFirst({
where: { id: newCard.id },
include: {
assignees: {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
},
labels: { include: { tag: true } },
checklists: {
select: { id: true, items: { select: { completed: true } } }
},
_count: { select: { comments: true, attachments: true } }
}
});
});
const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId });
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
return json({ card }, { status: 201 });
};
@@ -0,0 +1,49 @@
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 GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const values = await withTenant(tenant.id, async (tx) => {
return tx.customFieldValue.findMany({
where: { cardId: params.cardId },
include: { field: true }
});
});
return json({ values });
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const body = await request.json();
const { fieldId, value } = body;
if (!fieldId) throw error(400, 'fieldId is required');
const result = await withTenant(tenant.id, async (tx) => {
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
if (value === null || value === '') {
// Remove value
await tx.customFieldValue.deleteMany({
where: { cardId: params.cardId, fieldId }
});
return null;
}
return tx.customFieldValue.upsert({
where: { cardId_fieldId: { cardId: params.cardId, fieldId } },
create: { cardId: params.cardId, fieldId, value: String(value) },
update: { value: String(value) },
include: { field: true }
});
});
return json({ value: result });
};
@@ -0,0 +1,47 @@
import { json, 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';
export const POST: 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 card = await tx.card.findFirst({
where: {
id: params.cardId,
column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }
},
include: { column: { select: { board: { select: { visibility: true, members: true } } } } }
});
if (!card) throw error(404, 'Card not found');
if (!canViewBoard(locals.user, card.column.board.visibility, card.column.board.members)) {
throw error(403, 'Access denied');
}
await tx.cardWatcher.upsert({
where: { cardId_userId: { cardId: params.cardId, userId: locals.user!.id } },
create: { cardId: params.cardId, userId: locals.user!.id },
update: {}
});
});
return json({ watching: true });
};
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) => {
await tx.cardWatcher.deleteMany({
where: { cardId: params.cardId, userId: locals.user!.id }
});
});
return json({ watching: false });
};
@@ -0,0 +1,64 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js';
import { generatePosition } from '$lib/utils/fractional-index.js';
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const fields = await withTenant(tenant.id, async (tx) => {
const board = await tx.board.findFirst({
where: { id: params.boardId, tenantId: tenant.id },
include: { members: true }
});
if (!board) throw error(404, 'Board not found');
if (!canViewBoard(locals.user, board.visibility, board.members)) {
throw error(403, 'Access denied');
}
return tx.customFieldDef.findMany({
where: { boardId: params.boardId },
orderBy: { position: 'asc' }
});
});
return json({ fields });
};
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 { name, type, options } = body;
if (!name || !type) throw error(400, 'Name and type are required');
const validTypes = ['TEXT', 'NUMBER', 'DATE', 'SELECT', 'MULTI_SELECT'];
if (!validTypes.includes(type)) throw error(400, 'Invalid field type');
const field = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const lastField = await tx.customFieldDef.findFirst({
where: { boardId: params.boardId },
orderBy: { position: 'desc' }
});
const position = generatePosition(lastField?.position ?? null, null);
return tx.customFieldDef.create({
data: {
boardId: params.boardId,
name,
type,
options: options || null,
position
}
});
});
return json({ field }, { status: 201 });
};
@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } 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 field = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.customFieldDef.findFirst({
where: { id: params.fieldId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Field not found');
const data: Record<string, any> = {};
if (body.name !== undefined) data.name = body.name;
if (body.options !== undefined) data.options = body.options;
if (body.position !== undefined) data.position = body.position;
return tx.customFieldDef.update({
where: { id: params.fieldId },
data
});
});
return json({ field });
};
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 requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.customFieldDef.findFirst({
where: { id: params.fieldId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Field not found');
await tx.customFieldDef.delete({ where: { id: params.fieldId } });
});
return json({ ok: true });
};
@@ -0,0 +1,100 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardView } from '$lib/server/guards.js';
export const GET: RequestHandler = async ({ params, url, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const format = url.searchParams.get('format') || 'json';
const board = await withTenant(tenant.id, async (tx) => {
const b = await requireBoardView(tx, locals.user, params.boardId, tenant.id);
// Get full data including checklists
const columns = await tx.column.findMany({
where: { boardId: params.boardId },
orderBy: { position: 'asc' },
include: {
cards: {
orderBy: { position: 'asc' },
include: {
labels: { include: { tag: true } },
assignees: { include: { user: { select: { name: true, email: true } } } },
checklists: {
orderBy: { position: 'asc' },
include: { items: { orderBy: { position: 'asc' } } }
}
}
}
}
});
return {
title: b.title,
description: b.description,
visibility: b.visibility,
columns: columns.map((col) => ({
title: col.title,
cards: col.cards.map((card) => ({
title: card.title,
description: card.description,
dueDate: card.dueDate,
archived: card.archived,
labels: card.labels.map((l) => ({
name: l.tag.name,
color: l.tag.color
})),
assignees: card.assignees.map((a) => ({
name: a.user.name,
email: a.user.email
})),
checklists: card.checklists.map((cl) => ({
title: cl.title,
items: cl.items.map((item) => ({
title: item.title,
completed: item.completed
}))
}))
}))
}))
};
});
if (format === 'csv') {
// Flatten cards into CSV rows
const rows: string[][] = [['Column', 'Title', 'Description', 'Due Date', 'Labels', 'Assignees']];
for (const col of board.columns) {
for (const card of col.cards) {
rows.push([
col.title,
card.title,
card.description || '',
card.dueDate ? new Date(card.dueDate).toISOString().split('T')[0] : '',
card.labels.map((l: any) => l.name).join('; '),
card.assignees.map((a: any) => a.name).join('; ')
]);
}
}
const csv = rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n');
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="${board.title}.csv"`
}
});
}
// JSON export
return new Response(JSON.stringify(board, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${board.title}.json"`
}
});
};
+201
View File
@@ -0,0 +1,201 @@
import { json, 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 { generatePositions } from '$lib/utils/fractional-index.js';
const MAX_CARDS = 500;
interface ImportColumn {
title: string;
cards: ImportCard[];
}
interface ImportCard {
title: string;
description?: string;
dueDate?: string | null;
labels?: { name: string; color: string }[];
checklists?: {
title: string;
items: { title: string; completed?: boolean }[];
}[];
}
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 { data: importData, format } = body;
if (!importData) throw error(400, 'No import data provided');
let columns: ImportColumn[];
let boardTitle: string;
if (format === 'trello') {
// Trello JSON import
boardTitle = importData.name || 'Imported Board';
const listsById = new Map<string, string>();
columns = (importData.lists || []).map((list: any) => {
listsById.set(list.id, list.name);
return { title: list.name, cards: [] };
});
const trelloCards = importData.cards || [];
if (trelloCards.length > MAX_CARDS) {
throw error(400, `Too many cards (max ${MAX_CARDS})`);
}
for (const tc of trelloCards) {
if (tc.closed) continue;
const colIdx = columns.findIndex((c: ImportColumn) => c.title === listsById.get(tc.idList));
if (colIdx === -1) continue;
const card: ImportCard = {
title: tc.name,
description: tc.desc || undefined,
dueDate: tc.due || null,
labels: (tc.labels || []).map((l: any) => ({
name: l.name || l.color,
color: l.color ? `#${l.color}` : '#6b7280'
})),
checklists: []
};
// Map Trello checklists
if (importData.checklists) {
const cardChecklists = importData.checklists.filter((cl: any) => cl.idCard === tc.id);
card.checklists = cardChecklists.map((cl: any) => ({
title: cl.name,
items: (cl.checkItems || []).map((item: any) => ({
title: item.name,
completed: item.state === 'complete'
}))
}));
}
columns[colIdx].cards.push(card);
}
} else {
// Pounce JSON import
boardTitle = importData.title || 'Imported Board';
columns = importData.columns || [];
const totalCards = columns.reduce((sum: number, col: ImportColumn) => sum + col.cards.length, 0);
if (totalCards > MAX_CARDS) {
throw error(400, `Too many cards (max ${MAX_CARDS})`);
}
}
const board = await withTenant(tenant.id, async (tx) => {
const cardPrefix = boardTitle.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD';
// Create the board
const newBoard = await tx.board.create({
data: {
tenantId: tenant.id,
title: boardTitle,
cardPrefix,
members: {
create: { userId: locals.user!.id, role: 'OWNER' }
}
}
});
// Get existing tags for this tenant
const existingTags = await tx.tag.findMany({
where: { tenantId: tenant.id }
});
const tagMap = new Map(existingTags.map((t) => [t.name.toLowerCase(), t]));
const colPositions = generatePositions(columns.length);
let nextCardNum = 1;
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci];
const newCol = await tx.column.create({
data: {
boardId: newBoard.id,
title: col.title,
position: colPositions[ci]
}
});
const cardPositions = generatePositions(col.cards.length);
for (let cri = 0; cri < col.cards.length; cri++) {
const card = col.cards[cri];
const newCard = await tx.card.create({
data: {
columnId: newCol.id,
title: card.title,
description: card.description,
dueDate: card.dueDate ? new Date(card.dueDate) : null,
position: cardPositions[cri],
cardNumber: nextCardNum++
}
});
// Create labels
if (card.labels && card.labels.length > 0) {
for (const label of card.labels) {
let tag = tagMap.get(label.name.toLowerCase());
if (!tag) {
tag = await tx.tag.create({
data: {
tenantId: tenant.id,
name: label.name,
color: label.color || '#6b7280'
}
});
tagMap.set(label.name.toLowerCase(), tag);
}
await tx.cardLabel.create({
data: { cardId: newCard.id, tagId: tag.id }
});
}
}
// Create checklists
if (card.checklists && card.checklists.length > 0) {
const clPositions = generatePositions(card.checklists.length);
for (let cli = 0; cli < card.checklists.length; cli++) {
const cl = card.checklists[cli];
const newCl = await tx.checklist.create({
data: {
cardId: newCard.id,
title: cl.title,
position: clPositions[cli]
}
});
if (cl.items.length > 0) {
const itemPositions = generatePositions(cl.items.length);
await tx.checklistItem.createMany({
data: cl.items.map((item, idx) => ({
checklistId: newCl.id,
title: item.title,
completed: item.completed || false,
position: itemPositions[idx]
}))
});
}
}
}
}
}
// Update the board's nextCardNum
await tx.board.update({
where: { id: newBoard.id },
data: { nextCardNum: nextCardNum }
});
return newBoard;
});
return json({ board: { id: board.id, title: board.title } }, { status: 201 });
};
+42
View File
@@ -0,0 +1,42 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAuth } from '$lib/server/guards.js';
import { prisma } from '$lib/server/db.js';
// Accept an invite
export const POST: RequestHandler = async ({ params, locals }) => {
requireAuth(locals.user);
const invite = await prisma.tenantInvite.findUnique({
where: { code: params.code }
});
if (!invite) throw error(404, 'Invite not found');
if (invite.expiresAt < new Date()) {
throw error(410, 'Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw error(410, 'Invite has reached its maximum number of uses');
}
// Since users are per-tenant, check if the current user's tenant matches
// (they'd already be registered on this tenant to be logged in here)
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { tenantId: true }
});
if (user && user.tenantId === invite.tenantId) {
return json({ error: 'Already a member' }, { status: 409 });
}
// Increment usedCount
await prisma.tenantInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } }
});
return json({ role: invite.role, tenantId: invite.tenantId });
};
+32
View File
@@ -14,6 +14,38 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 50); const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 50);
// Check for PREFIX-123 pattern
const cardNumMatch = q.match(/^([A-Za-z]+)-(\d+)$/);
if (cardNumMatch) {
const prefix = cardNumMatch[1].toUpperCase();
const num = parseInt(cardNumMatch[2]);
const results = await withTenant(tenant.id, async (tx) => {
const cards: any[] = await tx.$queryRaw`
SELECT
c.id,
c.title,
c.description,
c.column_id AS "columnId",
col.title AS "columnTitle",
b.id AS "boardId",
b.title AS "boardTitle"
FROM cards c
JOIN columns col ON col.id = c.column_id
JOIN boards b ON b.id = col.board_id
LEFT JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ${locals.user!.id}::uuid
WHERE b.tenant_id = ${tenant.id}::uuid
AND b.card_prefix = ${prefix}
AND c.card_number = ${num}
AND c.archived = false
AND b.archived = false
AND (b.visibility = 'PUBLIC' OR bm.user_id IS NOT NULL)
LIMIT ${limit}
`;
return cards;
});
if (results.length > 0) return json({ results });
}
const results = await withTenant(tenant.id, async (tx) => { const results = await withTenant(tenant.id, async (tx) => {
const cards: any[] = await tx.$queryRaw` const cards: any[] = await tx.$queryRaw`
SELECT SELECT
+42
View File
@@ -17,6 +17,34 @@
let error = $state(''); let error = $state('');
let filterCategoryId = $state(''); let filterCategoryId = $state('');
let showArchived = $state($page.url.searchParams.get('archived') === 'true'); let showArchived = $state($page.url.searchParams.get('archived') === 'true');
let importing = $state(false);
let fileInput: HTMLInputElement;
async function handleImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
importing = true;
try {
const text = await file.text();
const importData = JSON.parse(text);
const format = importData.lists ? 'trello' : 'pounce';
const { ok } = await api('/api/boards/import', {
method: 'POST',
body: JSON.stringify({ data: importData, format })
});
if (ok) {
toast.success('Board imported');
invalidateAll();
}
} catch {
toast.error('Invalid import file');
}
importing = false;
input.value = '';
}
const filteredBoards = $derived( const filteredBoards = $derived(
filterCategoryId filterCategoryId
@@ -85,6 +113,20 @@
</svg> </svg>
{showArchived ? 'Showing archived' : 'Show archived'} {showArchived ? 'Showing archived' : 'Show archived'}
</button> </button>
<input
bind:this={fileInput}
type="file"
accept=".json"
class="hidden"
onchange={handleImport}
/>
<button
onclick={() => fileInput.click()}
disabled={importing}
class="rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import'}
</button>
<button <button
onclick={() => (showCreate = !showCreate)} onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition" class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
+153 -8
View File
@@ -10,6 +10,9 @@
import CardModal from '$lib/components/CardModal.svelte'; import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte'; import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte'; import BoardFilters from '$lib/components/BoardFilters.svelte';
import BoardBackgroundPicker from '$lib/components/BoardBackgroundPicker.svelte';
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
import ColumnSortMenu from '$lib/components/ColumnSortMenu.svelte';
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import type { FilterState } from '$lib/components/BoardFilters.svelte'; import type { FilterState } from '$lib/components/BoardFilters.svelte';
@@ -35,6 +38,9 @@
let showArchivedCards = $state(false); let showArchivedCards = $state(false);
let archivedCards = $state<any[]>([]); let archivedCards = $state<any[]>([]);
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null }); let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
let selectMode = $state(false);
let selectedCardIds = $state(new Set<string>());
let columnSorts = $state<Record<string, string | null>>({});
const flipDurationMs = 200; const flipDurationMs = 200;
@@ -74,6 +80,36 @@
}); });
} }
// ─── Card Sorting ────────────────────────────────────
function sortCards(cards: any[], sort: string | null): any[] {
if (!sort) return cards;
const sorted = [...cards];
switch (sort) {
case 'due-date':
sorted.sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
break;
case 'created':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'title':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'assignee':
sorted.sort((a, b) => {
const aName = a.assignees?.[0]?.user?.name || '';
const bName = b.assignees?.[0]?.user?.name || '';
return aName.localeCompare(bName);
});
break;
}
return sorted;
}
// ─── Archived cards ───────────────────────────────── // ─── Archived cards ─────────────────────────────────
async function toggleArchivedCards() { async function toggleArchivedCards() {
showArchivedCards = !showArchivedCards; showArchivedCards = !showArchivedCards;
@@ -346,6 +382,32 @@
// ─── Card updated from modal ───────────────────── // ─── Card updated from modal ─────────────────────
function handleCardUpdate(updatedCard: any) { function handleCardUpdate(updatedCard: any) {
// Handle unarchive: add card back to column's active cards
if (updatedCard.archived === false) {
const wasArchived = archivedCards.find((c: any) => c.id === updatedCard.id);
if (wasArchived) {
archivedCards = archivedCards.filter((c: any) => c.id !== updatedCard.id);
const col = columns.find((c: any) => c.id === updatedCard.columnId);
if (col) {
col.cards = [...col.cards, updatedCard];
}
return;
}
}
// Handle archive: remove card from column's active cards
if (updatedCard.archived === true) {
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
col.cards = col.cards.filter((c: any) => c.id !== updatedCard.id);
archivedCards = [...archivedCards, updatedCard];
break;
}
}
return;
}
for (const col of columns) { for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id); const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) { if (idx !== -1) {
@@ -364,16 +426,22 @@
<CardModal <CardModal
card={selectedCard} card={selectedCard}
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 ?? archivedCards.find((c: any) => c.id === selectedCard.id)?.columnId}
canEdit={data.canEdit} canEdit={data.canEdit}
boardMembers={data.board.members} boardMembers={data.board.members}
currentUserId={data.user?.id || ''} currentUserId={data.user?.id || ''}
cardPrefix={data.board.cardPrefix}
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} onupdate={handleCardUpdate}
oncopied={(newCard) => {
const col = columns.find((c: any) => c.id === newCard.columnId);
if (col) col.cards = [...col.cards, newCard];
toast.success('Card copied');
}}
/> />
{/if} {/if}
@@ -388,6 +456,17 @@
</svg> </svg>
</a> </a>
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1> <h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<span class="px-3 py-1 text-xs rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Board
</span>
<a
href="/boards/{data.board.id}/calendar"
class="px-3 py-1 text-xs rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Calendar
</a>
</div>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400"> <span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'} {data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span> </span>
@@ -409,6 +488,21 @@
</div> </div>
{/if} {/if}
{#if data.canEdit}
<button
onclick={() => { selectMode = !selectMode; if (!selectMode) selectedCardIds = new Set(); }}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
class:bg-gray-200={selectMode}
title="Select cards for bulk actions"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Select
</button>
<BoardBackgroundPicker boardId={data.board.id} currentBackground={data.board.background || null} />
{/if}
<button <button
onclick={() => toggleArchivedCards()} onclick={() => toggleArchivedCards()}
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1" class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
@@ -423,6 +517,19 @@
Archived Archived
</button> </button>
<a
href="/api/boards/{data.board.id}/export"
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
title="Export board"
aria-label="Export board"
download
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Export
</a>
<button <button
onclick={() => (showActivity = !showActivity)} onclick={() => (showActivity = !showActivity)}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1" class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
@@ -447,7 +554,7 @@
</div> </div>
<!-- Main area: columns + optional activity sidebar --> <!-- Main area: columns + optional activity sidebar -->
<div class="flex-1 flex overflow-hidden"> <div class="flex-1 flex overflow-hidden" style="{data.board.background ? `background: ${data.board.background}; background-size: cover; background-position: center;` : ''}">
<!-- Columns container --> <!-- Columns container -->
<div class="flex-1 overflow-x-auto p-4"> <div class="flex-1 overflow-x-auto p-4">
<div <div
@@ -462,7 +569,8 @@
onfinalize={handleColumnSort} onfinalize={handleColumnSort}
> >
{#each columns as column (column.id)} {#each columns as column (column.id)}
{@const visibleCards = filterCards(column.cards)} {@const colSort = columnSorts[column.id] ?? null}
{@const visibleCards = sortCards(filterCards(column.cards), colSort)}
<div <div
class="flex-shrink-0 w-64 sm:w-72 rounded-xl flex flex-col max-h-full" class="flex-shrink-0 w-64 sm:w-72 rounded-xl flex flex-col max-h-full"
style="background: var(--color-column-bg); border: 1px solid var(--color-column-border); color: var(--color-column-text); backdrop-filter: var(--column-backdrop-blur, none);" style="background: var(--color-column-bg); border: 1px solid var(--color-column-border); color: var(--color-column-text); backdrop-filter: var(--column-backdrop-blur, none);"
@@ -503,6 +611,10 @@
<span class="ml-1 text-xs text-gray-400 dark:text-gray-500 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span> <span class="ml-1 text-xs text-gray-400 dark:text-gray-500 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span>
</button> </button>
{/if} {/if}
<ColumnSortMenu
currentSort={colSort}
onsort={(sort) => (columnSorts[column.id] = sort)}
/>
{#if data.canEdit} {#if data.canEdit}
<button <button
onclick={() => deleteColumn(column.id)} onclick={() => deleteColumn(column.id)}
@@ -524,7 +636,7 @@
items: visibleCards, items: visibleCards,
flipDurationMs, flipDurationMs,
type: 'cards', type: 'cards',
dragDisabled: !data.canEdit || hasActiveFilters dragDisabled: !data.canEdit || hasActiveFilters || selectMode || !!colSort
}} }}
onconsider={(e) => handleCardSort(column.id, e)} onconsider={(e) => handleCardSort(column.id, e)}
onfinalize={(e) => handleCardSort(column.id, e)} onfinalize={(e) => handleCardSort(column.id, e)}
@@ -533,10 +645,19 @@
{@const progress = getChecklistProgress(card)} {@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)} {@const dueUrgency = getDueUrgency(card.dueDate)}
<button <button
class="w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer" class="w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer {selectMode && selectedCardIds.has(card.id) ? 'ring-2 ring-[var(--color-primary)]' : ''}"
style="background: var(--color-card-bg); border: 1px solid var(--color-card-border); color: var(--color-card-text); border-radius: var(--color-card-radius); backdrop-filter: var(--card-backdrop-blur, none);" style="background: var(--color-card-bg); border: 1px solid var(--color-card-border); color: var(--color-card-text); border-radius: var(--color-card-radius); backdrop-filter: var(--card-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
onclick={() => (selectedCard = card)} onclick={() => {
if (selectMode) {
const next = new Set(selectedCardIds);
if (next.has(card.id)) next.delete(card.id);
else next.add(card.id);
selectedCardIds = next;
} else {
selectedCard = card;
}
}}
> >
{#if card.labels && card.labels.length > 0} {#if card.labels && card.labels.length > 0}
<div class="flex flex-wrap gap-1 mb-1.5"> <div class="flex flex-wrap gap-1 mb-1.5">
@@ -550,6 +671,9 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if data.board.cardPrefix && card.cardNumber}
<span class="text-[10px] font-mono text-gray-400 dark:text-gray-500">{data.board.cardPrefix}-{card.cardNumber}</span>
{/if}
<span class="text-sm text-gray-800 dark:text-gray-200">{card.title}</span> <span class="text-sm text-gray-800 dark:text-gray-200">{card.title}</span>
{#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress} {#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap"> <div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap">
@@ -609,9 +733,12 @@
<div class="px-2 pb-2"> <div class="px-2 pb-2">
<div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1 px-1">Archived</div> <div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1 px-1">Archived</div>
{#each archivedInCol as card} {#each archivedInCol as card}
<div class="mb-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 p-2.5 border border-gray-200 dark:border-gray-700 opacity-60"> <button
onclick={() => (selectedCard = card)}
class="w-full text-left mb-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 p-2.5 border border-gray-200 dark:border-gray-700 opacity-60 hover:opacity-80 transition cursor-pointer"
>
<span class="text-sm text-gray-500 dark:text-gray-400">{card.title}</span> <span class="text-sm text-gray-500 dark:text-gray-400">{card.title}</span>
</div> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -729,3 +856,21 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if selectMode && selectedCardIds.size > 0}
<BulkToolbar
boardId={data.board.id}
{selectedCardIds}
{columns}
onaction={() => {
selectMode = false;
selectedCardIds = new Set();
// Reload page data
window.location.reload();
}}
oncancel={() => {
selectMode = false;
selectedCardIds = new Set();
}}
/>
{/if}
@@ -0,0 +1,35 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardView } from '$lib/server/guards.js';
import { canEditBoard } from '$lib/server/permissions.js';
export const load: PageServerLoad = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const board = await withTenant(tenant.id, async (tx) => {
return requireBoardView(tx, locals.user, params.boardId, tenant.id);
});
// Filter to cards with due dates
const cardsWithDue = board.columns.flatMap((col: any) =>
col.cards
.filter((c: any) => c.dueDate)
.map((c: any) => ({
...c,
columnTitle: col.title,
columnId: col.id
}))
);
return {
board: {
id: board.id,
title: board.title,
cardPrefix: (board as any).cardPrefix
},
cards: cardsWithDue,
canEdit: canEditBoard(locals.user, board.members)
};
};
@@ -0,0 +1,49 @@
<script lang="ts">
import CalendarGrid from '$lib/components/CalendarGrid.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.board.title} - Calendar</title>
</svelte:head>
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200 dark:bg-gray-900/80 dark:border-gray-700 flex-wrap">
<a href="/boards/{data.board.id}" class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition" aria-label="Back to board">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<a
href="/boards/{data.board.id}"
class="px-3 py-1 text-sm rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Board
</a>
<span class="px-3 py-1 text-sm rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Calendar
</span>
</div>
</div>
<!-- Calendar -->
<div class="flex-1 overflow-y-auto p-4">
{#if data.cards.length === 0}
<div class="text-center py-12">
<p class="text-gray-400 dark:text-gray-500">No cards with due dates.</p>
<a href="/boards/{data.board.id}" class="text-sm text-[var(--color-primary)] hover:underline mt-2 inline-block">Back to board</a>
</div>
{:else}
<CalendarGrid
cards={data.cards}
boardId={data.board.id}
cardPrefix={data.board.cardPrefix}
/>
{/if}
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { prisma } from '$lib/server/db.js';
export const load: PageServerLoad = async ({ params, locals }) => {
const invite = await prisma.tenantInvite.findUnique({
where: { code: params.code },
include: {
tenant: { select: { name: true } }
}
});
if (!invite) throw error(404, 'Invite not found');
if (invite.expiresAt < new Date()) {
throw error(410, 'This invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw error(410, 'This invite has reached its maximum number of uses');
}
return {
invite: {
code: invite.code,
role: invite.role,
tenantName: invite.tenant.name,
expiresAt: invite.expiresAt.toISOString()
},
user: locals.user
};
};
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
let { data } = $props();
let loading = $state(false);
let errorMsg = $state('');
let accepted = $state(false);
async function acceptInvite() {
loading = true;
errorMsg = '';
try {
const res = await fetch(`/api/invite/${data.invite.code}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await res.json();
if (!res.ok) {
errorMsg = result.error || 'Failed to accept invite';
return;
}
accepted = true;
} catch {
errorMsg = 'Network error. Please try again.';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Invite to {data.invite.tenantName}</title>
</svelte:head>
<div class="flex min-h-[calc(100vh-3.5rem)] items-center justify-center px-4">
<div class="w-full max-w-sm">
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2 text-center">
You've been invited
</h1>
<p class="text-center text-gray-600 dark:text-gray-400 mb-6">
Join <span class="font-semibold text-gray-900 dark:text-gray-100">{data.invite.tenantName}</span>
as <span class="font-semibold text-gray-900 dark:text-gray-100">{data.invite.role === 'ADMIN' ? 'an Admin' : 'a Member'}</span>
</p>
{#if errorMsg}
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{errorMsg}
</div>
{/if}
{#if accepted}
<div class="rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-4 text-center">
<p class="text-sm font-medium text-green-700 dark:text-green-400 mb-2">Invite accepted!</p>
<a
href="/boards"
class="inline-block rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
>
Go to Boards
</a>
</div>
{:else if data.user}
<button
onclick={acceptInvite}
disabled={loading}
class="w-full rounded-lg bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
>
{loading ? 'Accepting...' : 'Accept Invite'}
</button>
{:else}
<div class="space-y-3">
<a
href="/auth/register?invite={data.invite.code}"
class="block w-full rounded-lg bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-white text-center hover:bg-[var(--color-primary-hover)] transition"
>
Create an account
</a>
<a
href="/auth/login?invite={data.invite.code}"
class="block w-full rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 text-center hover:bg-gray-50 dark:hover:bg-gray-700 transition"
>
Log in
</a>
</div>
{/if}
<p class="mt-4 text-center text-xs text-gray-400 dark:text-gray-500">
This invite expires on {new Date(data.invite.expiresAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
export const load: PageServerLoad = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const cards = await withTenant(tenant.id, async (tx) => {
return tx.card.findMany({
where: {
archived: false,
assignees: {
some: { userId: locals.user!.id }
},
column: {
board: {
archived: false,
tenantId: tenant.id
}
}
},
include: {
labels: { include: { tag: true } },
assignees: {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
},
column: {
select: {
id: true,
title: true,
board: { select: { id: true, title: true, cardPrefix: true } }
}
}
},
orderBy: [
{ dueDate: { sort: 'asc', nulls: 'last' } },
{ updatedAt: 'desc' }
]
});
});
return { cards };
};
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import Avatar from '$lib/components/Avatar.svelte';
let { data } = $props();
function groupCards(cards: any[]) {
const now = new Date();
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
const weekEnd = new Date(todayEnd.getTime() + 7 * 24 * 60 * 60 * 1000);
const groups: Record<string, any[]> = {
overdue: [],
today: [],
thisWeek: [],
later: [],
noDue: []
};
for (const card of cards) {
if (!card.dueDate) {
groups.noDue.push(card);
} else {
const due = new Date(card.dueDate);
if (due < now) groups.overdue.push(card);
else if (due <= todayEnd) groups.today.push(card);
else if (due <= weekEnd) groups.thisWeek.push(card);
else groups.later.push(card);
}
}
return groups;
}
const groups = $derived(groupCards(data.cards));
const sections = [
{ key: 'overdue', label: 'Overdue', color: 'text-red-600 dark:text-red-400' },
{ key: 'today', label: 'Due Today', color: 'text-orange-600 dark:text-orange-400' },
{ key: 'thisWeek', label: 'Due This Week', color: 'text-blue-600 dark:text-blue-400' },
{ key: 'later', label: 'Due Later', color: 'text-gray-600 dark:text-gray-400' },
{ key: 'noDue', label: 'No Due Date', color: 'text-gray-400 dark:text-gray-500' }
];
</script>
<svelte:head>
<title>My Work</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">My Work</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Cards assigned to you across all boards</p>
{#if data.cards.length === 0}
<div class="text-center py-12">
<p class="text-gray-400 dark:text-gray-500">No cards assigned to you yet.</p>
<a href="/boards" class="text-sm text-[var(--color-primary)] hover:underline mt-2 inline-block">Go to boards</a>
</div>
{:else}
<div class="space-y-6">
{#each sections as section}
{@const cards = groups[section.key]}
{#if cards.length > 0}
<div>
<h2 class="text-sm font-semibold {section.color} mb-2">
{section.label} <span class="font-normal">({cards.length})</span>
</h2>
<div class="space-y-1">
{#each cards as card}
{@const dueUrgency = getDueUrgency(card.dueDate)}
<a
href="/boards/{card.column.board.id}?card={card.id}"
class="flex items-center gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:shadow-md transition group"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
{#if card.column.board.cardPrefix && card.cardNumber}
<span class="text-xs font-mono text-gray-400 dark:text-gray-500">{card.column.board.cardPrefix}-{card.cardNumber}</span>
{/if}
<span class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">{card.title}</span>
</div>
<div class="flex items-center gap-2 mt-1 text-xs text-gray-400 dark:text-gray-500">
<span class="truncate">{card.column.board.title}</span>
<span>&middot;</span>
<span class="truncate">{card.column.title}</span>
</div>
</div>
{#if card.labels && card.labels.length > 0}
<div class="flex gap-1 flex-shrink-0">
{#each card.labels.slice(0, 3) as label}
<span
class="rounded px-1.5 py-0.5 text-[10px] text-white leading-none"
style="background-color: {label.tag.color}"
>
{label.tag.name}
</span>
{/each}
</div>
{/if}
{#if card.dueDate}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0 {getUrgencyClasses(dueUrgency)}">
{formatDueDate(card.dueDate)}
</span>
{/if}
{#if card.assignees && card.assignees.length > 1}
<div class="flex -space-x-1 flex-shrink-0">
{#each card.assignees.slice(0, 3) as assignee}
<Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" class="border border-white dark:border-gray-900" title={assignee.user.name} />
{/each}
</div>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>