Add board settings modal for editing board properties and deleting boards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { trapFocus } from '$lib/utils/focus-trap.js';
|
||||||
|
import { api } from '$lib/utils/api.js';
|
||||||
|
import { toast } from '$lib/stores/toasts.svelte.js';
|
||||||
|
import BoardBackgroundPicker from './BoardBackgroundPicker.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
board: any;
|
||||||
|
categories: any[];
|
||||||
|
canDelete: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
onsave: (updated: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { board, categories, canDelete, onclose, onsave }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state(board.title);
|
||||||
|
let description = $state(board.description || '');
|
||||||
|
let visibility = $state(board.visibility);
|
||||||
|
let categoryId = $state(board.categoryId || '');
|
||||||
|
let saving = $state(false);
|
||||||
|
let confirmingDelete = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
saving = true;
|
||||||
|
const { ok, data } = await api(`/api/boards/${board.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
visibility,
|
||||||
|
categoryId: categoryId || undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
toast.success('Board updated');
|
||||||
|
onsave(data.board);
|
||||||
|
}
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBoard() {
|
||||||
|
deleting = true;
|
||||||
|
const { ok } = await api(`/api/boards/${board.id}`, { method: 'DELETE' });
|
||||||
|
if (ok) {
|
||||||
|
toast.success('Board deleted');
|
||||||
|
goto('/boards');
|
||||||
|
}
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-lg bg-white dark:bg-gray-900 rounded-xl shadow-2xl max-sm:min-h-screen max-sm:rounded-none"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="board-settings-title"
|
||||||
|
use:trapFocus
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<h2 id="board-settings-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100">Board Settings</h2>
|
||||||
|
<button onclick={onclose} class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition p-1" aria-label="Close">
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label for="board-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
id="board-title"
|
||||||
|
bind:value={title}
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="board-desc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="board-desc"
|
||||||
|
bind:value={description}
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility -->
|
||||||
|
<div>
|
||||||
|
<label for="board-vis" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Visibility</label>
|
||||||
|
<select
|
||||||
|
id="board-vis"
|
||||||
|
bind:value={visibility}
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<option value="PRIVATE">Private</option>
|
||||||
|
<option value="PUBLIC">Public</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
{#if categories.length > 0}
|
||||||
|
<div>
|
||||||
|
<label for="board-cat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
id="board-cat"
|
||||||
|
bind:value={categoryId}
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each categories as cat}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Background</span>
|
||||||
|
<BoardBackgroundPicker boardId={board.id} currentBackground={board.background} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
onclick={save}
|
||||||
|
disabled={saving || !title.trim()}
|
||||||
|
class="w-full rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
{#if canDelete}
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--color-danger)] mb-2">Danger Zone</h3>
|
||||||
|
{#if confirmingDelete}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Are you sure? This will permanently delete the board and all its data.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={deleteBoard}
|
||||||
|
disabled={deleting}
|
||||||
|
class="rounded-lg bg-[var(--color-danger)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingDelete = false)}
|
||||||
|
class="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingDelete = true)}
|
||||||
|
class="rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/30 transition"
|
||||||
|
>
|
||||||
|
Delete this board
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,18 +2,29 @@ import { error } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad } from './$types.js';
|
import type { PageServerLoad } from './$types.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardView } from '$lib/server/guards.js';
|
import { requireBoardView } from '$lib/server/guards.js';
|
||||||
import { canEditBoard } from '$lib/server/permissions.js';
|
import { canEditBoard, canDeleteBoard } from '$lib/server/permissions.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown tenant');
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
|
|
||||||
const board = await withTenant(tenant.id, async (tx) => {
|
const { board, categories } = await withTenant(tenant.id, async (tx) => {
|
||||||
return requireBoardView(tx, locals.user, params.boardId, tenant.id);
|
const [board, categories] = await Promise.all([
|
||||||
|
requireBoardView(tx, locals.user, params.boardId, tenant.id),
|
||||||
|
locals.user
|
||||||
|
? tx.category.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
]);
|
||||||
|
return { board, categories };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
board,
|
board,
|
||||||
canEdit: canEditBoard(locals.user, board.members)
|
categories,
|
||||||
|
canEdit: canEditBoard(locals.user, board.members),
|
||||||
|
canDelete: canDeleteBoard(locals.user, board.members)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { api } from '$lib/utils/api.js';
|
import { api } from '$lib/utils/api.js';
|
||||||
import { toast } from '$lib/stores/toasts.svelte.js';
|
import { toast } from '$lib/stores/toasts.svelte.js';
|
||||||
import CardModal from '$lib/components/CardModal.svelte';
|
import CardModal from '$lib/components/CardModal.svelte';
|
||||||
|
import BoardSettingsModal from '$lib/components/BoardSettingsModal.svelte';
|
||||||
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
||||||
import BoardFilters from '$lib/components/BoardFilters.svelte';
|
import BoardFilters from '$lib/components/BoardFilters.svelte';
|
||||||
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
|
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
let editingColumnTitle = $state('');
|
let editingColumnTitle = $state('');
|
||||||
let viewingUsers = $state<any[]>([]);
|
let viewingUsers = $state<any[]>([]);
|
||||||
let showActivity = $state(false);
|
let showActivity = $state(false);
|
||||||
|
let showSettings = $state(false);
|
||||||
let showArchivedCards = $state(false);
|
let showArchivedCards = $state(false);
|
||||||
let archivedCards = $state<any[]>([]);
|
let archivedCards = $state<any[]>([]);
|
||||||
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
|
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
|
||||||
@@ -205,6 +207,9 @@
|
|||||||
if (d.board) {
|
if (d.board) {
|
||||||
data.board.title = d.board.title;
|
data.board.title = d.board.title;
|
||||||
data.board.visibility = d.board.visibility;
|
data.board.visibility = d.board.visibility;
|
||||||
|
if (d.board.description !== undefined) data.board.description = d.board.description;
|
||||||
|
if (d.board.background !== undefined) data.board.background = d.board.background;
|
||||||
|
if (d.board.categoryId !== undefined) data.board.categoryId = d.board.categoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +426,23 @@
|
|||||||
<title>{data.board.title}</title>
|
<title>{data.board.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if showSettings}
|
||||||
|
<BoardSettingsModal
|
||||||
|
board={data.board}
|
||||||
|
categories={data.categories}
|
||||||
|
canDelete={data.canDelete}
|
||||||
|
onclose={() => (showSettings = false)}
|
||||||
|
onsave={(updated) => {
|
||||||
|
data.board.title = updated.title;
|
||||||
|
data.board.visibility = updated.visibility;
|
||||||
|
data.board.description = updated.description;
|
||||||
|
data.board.background = updated.background;
|
||||||
|
data.board.categoryId = updated.categoryId;
|
||||||
|
showSettings = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if selectedCard}
|
{#if selectedCard}
|
||||||
<CardModal
|
<CardModal
|
||||||
card={selectedCard}
|
card={selectedCard}
|
||||||
@@ -526,6 +548,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Activity
|
Activity
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if data.canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => (showSettings = true)}
|
||||||
|
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 settings"
|
||||||
|
aria-label="Board settings"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters bar -->
|
<!-- Filters bar -->
|
||||||
|
|||||||
Reference in New Issue
Block a user