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:
Generated
+22
@@ -17,6 +17,7 @@
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^5.1.0",
|
||||
"marked": "^15.0.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"pino": "^10.3.1",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
@@ -3112,6 +3113,18 @@
|
||||
"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": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
@@ -4176,6 +4189,15 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"express": "^5.1.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"marked": "^15.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"socket.io": "^4.8.0",
|
||||
|
||||
+148
-21
@@ -13,6 +13,8 @@ model Tenant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
slug String @unique
|
||||
logoUrl String? @map("logo_url")
|
||||
logoText String? @map("logo_text")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -23,6 +25,8 @@ model Tenant {
|
||||
categories Category[]
|
||||
boardTemplates BoardTemplate[]
|
||||
theme TenantTheme?
|
||||
invites TenantInvite[]
|
||||
webhooks Webhook[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -70,6 +74,7 @@ model User {
|
||||
comments Comment[]
|
||||
activities Activity[]
|
||||
notifications Notification[]
|
||||
cardWatchers CardWatcher[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@map("users")
|
||||
@@ -108,14 +113,18 @@ model Board {
|
||||
background String?
|
||||
categoryId String? @map("category_id") @db.Uuid
|
||||
archived Boolean @default(false)
|
||||
cardPrefix String? @map("card_prefix")
|
||||
nextCardNum Int @default(1) @map("next_card_num")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
columns Column[]
|
||||
members BoardMember[]
|
||||
activities Activity[]
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
columns Column[]
|
||||
members BoardMember[]
|
||||
activities Activity[]
|
||||
customFieldDefs CustomFieldDef[]
|
||||
automationRules AutomationRule[]
|
||||
|
||||
@@map("boards")
|
||||
}
|
||||
@@ -150,23 +159,27 @@ model Column {
|
||||
// ─── Cards ────────────────────────────────────────────────────
|
||||
|
||||
model Card {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
columnId String @map("column_id") @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
position String // Fractional index (lexicographic)
|
||||
dueDate DateTime? @map("due_date")
|
||||
archived Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
columnId String @map("column_id") @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
position String // Fractional index (lexicographic)
|
||||
dueDate DateTime? @map("due_date")
|
||||
archived Boolean @default(false)
|
||||
dueReminderSent Boolean @default(false) @map("due_reminder_sent")
|
||||
cardNumber Int? @map("card_number")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
|
||||
assignees CardAssignee[]
|
||||
labels CardLabel[]
|
||||
checklists Checklist[]
|
||||
comments Comment[]
|
||||
attachments Attachment[]
|
||||
activities Activity[]
|
||||
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
|
||||
assignees CardAssignee[]
|
||||
labels CardLabel[]
|
||||
checklists Checklist[]
|
||||
comments Comment[]
|
||||
attachments Attachment[]
|
||||
activities Activity[]
|
||||
watchers CardWatcher[]
|
||||
customFieldValues CustomFieldValue[]
|
||||
|
||||
@@map("cards")
|
||||
}
|
||||
@@ -331,6 +344,120 @@ model BoardTemplate {
|
||||
@@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 ───────────────────────────────
|
||||
|
||||
model TenantTheme {
|
||||
|
||||
@@ -60,6 +60,24 @@ ALTER TABLE notifications FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE board_templates ENABLE 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) ──────────
|
||||
|
||||
-- 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 ───────────────────────────────
|
||||
|
||||
-- Notifications
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import pino from 'pino';
|
||||
import cron from 'node-cron';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
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
|
||||
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
|
||||
app.use(handler);
|
||||
|
||||
|
||||
Vendored
+2
@@ -53,6 +53,8 @@ declare global {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl?: string | null;
|
||||
logoText?: string | null;
|
||||
theme?: TenantThemeData | null;
|
||||
} | null;
|
||||
user: {
|
||||
|
||||
@@ -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">→</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -9,6 +9,7 @@
|
||||
import CommentList from './CommentList.svelte';
|
||||
import AttachmentList from './AttachmentList.svelte';
|
||||
import ActivityFeed from './ActivityFeed.svelte';
|
||||
import CustomFieldsSection from './CustomFieldsSection.svelte';
|
||||
import { api } from '$lib/utils/api.js';
|
||||
|
||||
type Props = {
|
||||
@@ -18,12 +19,14 @@
|
||||
canEdit: boolean;
|
||||
boardMembers: any[];
|
||||
currentUserId: string;
|
||||
cardPrefix?: string | null;
|
||||
onclose: () => void;
|
||||
ondelete: (cardId: string) => 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 title = $state(card.title);
|
||||
@@ -110,7 +113,49 @@
|
||||
body: JSON.stringify({ archived: true })
|
||||
});
|
||||
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>
|
||||
@@ -126,6 +171,9 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between p-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<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}
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
|
||||
<input
|
||||
@@ -193,6 +241,13 @@
|
||||
onupdate={updateChecklists}
|
||||
/>
|
||||
|
||||
<CustomFieldsSection
|
||||
cardId={card.id}
|
||||
{boardId}
|
||||
{columnId}
|
||||
{canEdit}
|
||||
/>
|
||||
|
||||
<AttachmentList
|
||||
cardId={card.id}
|
||||
{boardId}
|
||||
@@ -244,12 +299,39 @@
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Actions</h3>
|
||||
<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={archiveCard}
|
||||
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"
|
||||
>
|
||||
Archive
|
||||
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
|
||||
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"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => ondelete(card.id)}
|
||||
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition"
|
||||
|
||||
@@ -11,23 +11,31 @@
|
||||
onchange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
let mode = $state<'color' | 'gradient'>(
|
||||
value?.startsWith('linear-gradient') || value?.startsWith('radial-gradient') ? 'gradient' : 'color'
|
||||
);
|
||||
let color = $state(mode === 'color' ? (value || '#000000') : '#000000');
|
||||
let mode = $state<'color' | 'gradient'>('color');
|
||||
let color = $state('#000000');
|
||||
let gradStart = $state('#0079bf');
|
||||
let gradEnd = $state('#6366f1');
|
||||
let gradDirection = $state('to right');
|
||||
|
||||
// Parse existing gradient value
|
||||
if (mode === 'gradient' && value) {
|
||||
const match = value.match(/linear-gradient\(([^,]+),\s*(#[0-9a-fA-F]{6}),\s*(#[0-9a-fA-F]{6})\)/);
|
||||
if (match) {
|
||||
gradDirection = match[1].trim();
|
||||
gradStart = match[2];
|
||||
gradEnd = match[3];
|
||||
// Re-sync local state when the value prop changes (e.g. switching Light/Dark tabs)
|
||||
$effect(() => {
|
||||
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) {
|
||||
gradDirection = match[1].trim();
|
||||
gradStart = match[2];
|
||||
gradEnd = match[3];
|
||||
}
|
||||
} else if (v) {
|
||||
mode = 'color';
|
||||
color = v;
|
||||
} else {
|
||||
mode = 'color';
|
||||
color = '#000000';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function emitColor() {
|
||||
value = color;
|
||||
@@ -41,11 +49,7 @@
|
||||
|
||||
function switchMode(newMode: 'color' | 'gradient') {
|
||||
mode = newMode;
|
||||
if (newMode === 'color') {
|
||||
emitColor();
|
||||
} else {
|
||||
emitGradient();
|
||||
}
|
||||
// Don't emit — let the user pick values first
|
||||
}
|
||||
|
||||
function clear() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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':
|
||||
goto('/boards');
|
||||
break;
|
||||
case 'm':
|
||||
goto('/my-work');
|
||||
break;
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
showHelp = !showHelp;
|
||||
@@ -49,6 +52,7 @@
|
||||
const shortcuts = [
|
||||
{ key: '/', desc: 'Focus search' },
|
||||
{ key: 'b', desc: 'Go to boards' },
|
||||
{ key: 'm', desc: 'My Work' },
|
||||
{ key: '?', desc: 'Toggle this help' },
|
||||
{ key: 'Esc', desc: 'Close dialogs' },
|
||||
{ key: 'n', desc: 'New card (on board)' }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,30 @@ export async function notify(tx: TxClient, opts: NotifyOptions) {
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl?: string | null;
|
||||
logoText?: string | null;
|
||||
theme?: TenantThemeData | null;
|
||||
}
|
||||
|
||||
@@ -32,6 +34,8 @@ export async function resolveTenant(hostname: string): Promise<TenantInfo | null
|
||||
id: record.tenant.id,
|
||||
name: record.tenant.name,
|
||||
slug: record.tenant.slug,
|
||||
logoUrl: record.tenant.logoUrl ?? null,
|
||||
logoText: record.tenant.logoText ?? null,
|
||||
theme: record.tenant.theme ?? null
|
||||
};
|
||||
tenantCache.set(host, { tenant, expiresAt: Date.now() + CACHE_TTL });
|
||||
|
||||
@@ -16,7 +16,8 @@ export const boardSchema = z.object({
|
||||
description: z.string().max(2000).optional(),
|
||||
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'),
|
||||
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({
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,17 @@
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-14 items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2 text-white font-bold text-lg">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="7" cy="5" r="2.2" />
|
||||
<circle cx="12" cy="3.5" r="2.2" />
|
||||
<circle cx="17" cy="5" r="2.2" />
|
||||
<ellipse cx="12" cy="14" rx="6" ry="7" />
|
||||
</svg>
|
||||
{data.tenant?.name ?? 'Pounce'}
|
||||
{#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">
|
||||
<circle cx="7" cy="5" r="2.2" />
|
||||
<circle cx="12" cy="3.5" r="2.2" />
|
||||
<circle cx="17" cy="5" r="2.2" />
|
||||
<ellipse cx="12" cy="14" rx="6" ry="7" />
|
||||
</svg>
|
||||
{/if}
|
||||
{data.tenant?.logoText ?? data.tenant?.name ?? 'Pounce'}
|
||||
</a>
|
||||
|
||||
{#if data.user}
|
||||
@@ -46,6 +50,14 @@
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<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
|
||||
onclick={toggleTheme}
|
||||
class="rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
|
||||
@@ -176,6 +188,13 @@
|
||||
Admin
|
||||
</a>
|
||||
{/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
|
||||
href="/boards"
|
||||
class="rounded bg-white/20 px-3 py-1.5 text-sm text-white hover:bg-white/30 transition text-center"
|
||||
|
||||
@@ -20,6 +20,101 @@
|
||||
let editingCategoryId = $state<string | null>(null);
|
||||
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) {
|
||||
const result = await api(`/api/admin/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -292,6 +387,146 @@
|
||||
</div>
|
||||
</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} · {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 -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Categories</h2>
|
||||
|
||||
@@ -8,9 +8,19 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
requireTenantAdmin(locals.user);
|
||||
|
||||
const theme = await withTenant(tenant.id, (tx) =>
|
||||
tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } })
|
||||
);
|
||||
const [theme, tenantData] = await withTenant(tenant.id, async (tx) => {
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
let activeTab = $state<'light' | 'dark'>('light');
|
||||
let saving = $state(false);
|
||||
let logoText = $state(data.logoText ?? '');
|
||||
|
||||
// Draft theme — initialize from server data
|
||||
let draft = $state<TenantThemeData>(data.theme ? { ...data.theme } : {});
|
||||
@@ -51,7 +52,7 @@
|
||||
saving = true;
|
||||
const { ok } = await api('/api/admin/branding', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(draft)
|
||||
body: JSON.stringify({ ...draft, logoText: logoText || null })
|
||||
});
|
||||
if (ok) {
|
||||
toast.success('Theme saved');
|
||||
@@ -140,6 +141,30 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
@@ -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) =>
|
||||
tx.tenantTheme.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
|
||||
@@ -8,14 +8,15 @@ import { join } from 'path';
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
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];
|
||||
|
||||
const SLOT_TO_FIELD: Record<Slot, string> = {
|
||||
'light-page-bg': 'lightPageBgImage',
|
||||
'light-nav-bg': 'lightNavBgImage',
|
||||
'dark-page-bg': 'darkPageBgImage',
|
||||
'dark-nav-bg': 'darkNavBgImage'
|
||||
'dark-nav-bg': 'darkNavBgImage',
|
||||
'logo': 'logoUrl'
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
@@ -42,13 +43,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const imageUrl = `/api/branding/${tenant.id}/${slot}`;
|
||||
const field = SLOT_TO_FIELD[slot as Slot];
|
||||
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenantTheme.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
create: { tenantId: tenant.id, [field]: imageUrl },
|
||||
update: { [field]: imageUrl }
|
||||
})
|
||||
);
|
||||
if (slot === 'logo') {
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenant.update({
|
||||
where: { id: tenant.id },
|
||||
data: { logoUrl: imageUrl }
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenantTheme.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
create: { tenantId: tenant.id, [field]: imageUrl },
|
||||
update: { [field]: imageUrl }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearTenantCache();
|
||||
|
||||
@@ -73,13 +83,22 @@ export const DELETE: RequestHandler = async ({ request, locals }) => {
|
||||
|
||||
const field = SLOT_TO_FIELD[slot as Slot];
|
||||
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenantTheme.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
create: { tenantId: tenant.id, [field]: null },
|
||||
update: { [field]: null }
|
||||
})
|
||||
);
|
||||
if (slot === 'logo') {
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenant.update({
|
||||
where: { id: tenant.id },
|
||||
data: { logoUrl: null }
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await withTenant(tenant.id, (tx) =>
|
||||
tx.tenantTheme.upsert({
|
||||
where: { tenantId: tenant.id },
|
||||
create: { tenantId: tenant.id, [field]: null },
|
||||
update: { [field]: null }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearTenantCache();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -53,6 +53,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
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
|
||||
let templateColumns: { title: string }[] = [];
|
||||
@@ -77,6 +78,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
title,
|
||||
description,
|
||||
visibility,
|
||||
cardPrefix,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
members: {
|
||||
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 { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
import { runAutomations } from '$lib/server/automations.js';
|
||||
|
||||
// Create card in column
|
||||
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);
|
||||
|
||||
// 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({
|
||||
data: {
|
||||
columnId: params.columnId,
|
||||
title: result.data.title,
|
||||
description: result.data.description,
|
||||
position
|
||||
position,
|
||||
cardNumber
|
||||
},
|
||||
include: {
|
||||
assignees: {
|
||||
@@ -53,6 +63,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
|
||||
import { canViewBoard } from '$lib/server/permissions.js';
|
||||
import { broadcast } from '$lib/server/realtime.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
|
||||
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 } } }
|
||||
},
|
||||
attachments: { orderBy: { createdAt: 'desc' } },
|
||||
watchers: { select: { userId: true } },
|
||||
column: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -103,6 +106,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
cardId: params.cardId,
|
||||
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;
|
||||
@@ -116,7 +127,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
+49
@@ -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"`
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -14,6 +14,38 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
|
||||
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 cards: any[] = await tx.$queryRaw`
|
||||
SELECT
|
||||
|
||||
@@ -17,6 +17,34 @@
|
||||
let error = $state('');
|
||||
let filterCategoryId = $state('');
|
||||
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(
|
||||
filterCategoryId
|
||||
@@ -85,6 +113,20 @@
|
||||
</svg>
|
||||
{showArchived ? 'Showing archived' : 'Show archived'}
|
||||
</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
|
||||
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"
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
import ActivityFeed from '$lib/components/ActivityFeed.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 type { FilterState } from '$lib/components/BoardFilters.svelte';
|
||||
|
||||
@@ -35,6 +38,9 @@
|
||||
let showArchivedCards = $state(false);
|
||||
let archivedCards = $state<any[]>([]);
|
||||
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;
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
async function toggleArchivedCards() {
|
||||
showArchivedCards = !showArchivedCards;
|
||||
@@ -346,6 +382,32 @@
|
||||
|
||||
// ─── Card updated from modal ─────────────────────
|
||||
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) {
|
||||
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
|
||||
if (idx !== -1) {
|
||||
@@ -364,16 +426,22 @@
|
||||
<CardModal
|
||||
card={selectedCard}
|
||||
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}
|
||||
boardMembers={data.board.members}
|
||||
currentUserId={data.user?.id || ''}
|
||||
cardPrefix={data.board.cardPrefix}
|
||||
onclose={() => (selectedCard = null)}
|
||||
ondelete={(cardId) => {
|
||||
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
|
||||
if (col) deleteCard(col.id, cardId);
|
||||
}}
|
||||
onupdate={handleCardUpdate}
|
||||
oncopied={(newCard) => {
|
||||
const col = columns.find((c: any) => c.id === newCard.columnId);
|
||||
if (col) col.cards = [...col.cards, newCard];
|
||||
toast.success('Card copied');
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -388,6 +456,17 @@
|
||||
</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">
|
||||
<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">
|
||||
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
@@ -409,6 +488,21 @@
|
||||
</div>
|
||||
{/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
|
||||
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"
|
||||
@@ -423,6 +517,19 @@
|
||||
Archived
|
||||
</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
|
||||
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"
|
||||
@@ -447,7 +554,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="flex-1 overflow-x-auto p-4">
|
||||
<div
|
||||
@@ -462,7 +569,8 @@
|
||||
onfinalize={handleColumnSort}
|
||||
>
|
||||
{#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
|
||||
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);"
|
||||
@@ -503,7 +611,11 @@
|
||||
<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>
|
||||
{/if}
|
||||
{#if data.canEdit}
|
||||
<ColumnSortMenu
|
||||
currentSort={colSort}
|
||||
onsort={(sort) => (columnSorts[column.id] = sort)}
|
||||
/>
|
||||
{#if data.canEdit}
|
||||
<button
|
||||
onclick={() => deleteColumn(column.id)}
|
||||
class="text-gray-400 dark:text-gray-500 hover:text-[var(--color-danger)] transition p-1"
|
||||
@@ -524,7 +636,7 @@
|
||||
items: visibleCards,
|
||||
flipDurationMs,
|
||||
type: 'cards',
|
||||
dragDisabled: !data.canEdit || hasActiveFilters
|
||||
dragDisabled: !data.canEdit || hasActiveFilters || selectMode || !!colSort
|
||||
}}
|
||||
onconsider={(e) => handleCardSort(column.id, e)}
|
||||
onfinalize={(e) => handleCardSort(column.id, e)}
|
||||
@@ -533,10 +645,19 @@
|
||||
{@const progress = getChecklistProgress(card)}
|
||||
{@const dueUrgency = getDueUrgency(card.dueDate)}
|
||||
<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);"
|
||||
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}
|
||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||
@@ -550,6 +671,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
{#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">
|
||||
@@ -609,9 +733,12 @@
|
||||
<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>
|
||||
{#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>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -729,3 +856,21 @@
|
||||
{/if}
|
||||
</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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>·</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>
|
||||
Reference in New Issue
Block a user