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 { withTenant } from '$lib/server/rls.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 }) => {
|
||||
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);
|
||||
const { board, categories } = await withTenant(tenant.id, async (tx) => {
|
||||
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 {
|
||||
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 { toast } from '$lib/stores/toasts.svelte.js';
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
import BoardSettingsModal from '$lib/components/BoardSettingsModal.svelte';
|
||||
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
||||
import BoardFilters from '$lib/components/BoardFilters.svelte';
|
||||
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
|
||||
@@ -34,6 +35,7 @@
|
||||
let editingColumnTitle = $state('');
|
||||
let viewingUsers = $state<any[]>([]);
|
||||
let showActivity = $state(false);
|
||||
let showSettings = $state(false);
|
||||
let showArchivedCards = $state(false);
|
||||
let archivedCards = $state<any[]>([]);
|
||||
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
|
||||
@@ -205,6 +207,9 @@
|
||||
if (d.board) {
|
||||
data.board.title = d.board.title;
|
||||
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>
|
||||
</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}
|
||||
<CardModal
|
||||
card={selectedCard}
|
||||
@@ -526,6 +548,20 @@
|
||||
</svg>
|
||||
Activity
|
||||
</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>
|
||||
|
||||
<!-- Filters bar -->
|
||||
|
||||
Reference in New Issue
Block a user