d418c367cb
Security fixes: - Validate theme background image URLs against whitelist pattern - Add board permission check to Socket.IO join-board handler - Disable raw HTML passthrough in markdown parser (XSS prevention) - Add UUID validation on avatar and branding route params (path traversal) - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Validate webhook URLs (HTTPS only, block internal addresses) - Add import payload size limit (5MB) - Remove server uptime from health endpoint - Make seed password configurable via SEED_ADMIN_PASSWORD env var - Patch DOMPurify dependency vulnerability via npm audit fix Warning fixes: - Convert $state(prop) to $effect.pre sync pattern (state_referenced_locally) - Add for/id pairings to form labels (a11y_label_has_associated_control) - Add svelte-ignore for intentional autofocus usage (a11y_autofocus) - Add ARIA roles to interactive/overlay divs (a11y_no_static_element_interactions) - Add aria-label to icon-only buttons (a11y_consider_explicit_label) - Add keyboard handler alongside click events (a11y_click_events_have_key_events) - Fix non-reactive fileInput declaration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
154 lines
5.2 KiB
Svelte
154 lines
5.2 KiB
Svelte
<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('');
|
|
|
|
$effect.pre(() => {
|
|
customValue = 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}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
<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"
|
|
role="presentation"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(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};"
|
|
aria-label="Select gradient background"
|
|
></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>
|