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>
110 lines
3.1 KiB
Svelte
110 lines
3.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
|
|
|
|
type Props = {
|
|
value: string;
|
|
readonly?: boolean;
|
|
onsave?: (value: string) => void;
|
|
};
|
|
|
|
let { value, readonly = false, onsave }: Props = $props();
|
|
|
|
let editing = $state(false);
|
|
let draft = $state('');
|
|
let previewing = $state(false);
|
|
let ready = $state(false);
|
|
|
|
$effect.pre(() => {
|
|
if (!editing) draft = value;
|
|
});
|
|
|
|
onMount(async () => {
|
|
await initDOMPurify();
|
|
ready = true;
|
|
});
|
|
|
|
function save() {
|
|
onsave?.(draft);
|
|
editing = false;
|
|
previewing = false;
|
|
}
|
|
|
|
function cancel() {
|
|
draft = value;
|
|
editing = false;
|
|
previewing = false;
|
|
}
|
|
</script>
|
|
|
|
{#if editing && !readonly}
|
|
<div>
|
|
<div class="flex gap-2 mb-2">
|
|
<button
|
|
class="text-xs px-2 py-1 rounded {!previewing ? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
|
onclick={() => (previewing = false)}
|
|
>
|
|
Write
|
|
</button>
|
|
<button
|
|
class="text-xs px-2 py-1 rounded {previewing ? 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' : 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
|
onclick={() => (previewing = true)}
|
|
>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
{#if previewing}
|
|
<div class="prose rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[120px] text-sm">
|
|
{#if ready}
|
|
{@html renderMarkdown(draft)}
|
|
{:else}
|
|
<p class="text-gray-400 dark:text-gray-500">{draft}</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<textarea
|
|
bind:value={draft}
|
|
rows="6"
|
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
|
placeholder="Add a more detailed description... (Markdown supported)"
|
|
></textarea>
|
|
{/if}
|
|
<div class="flex gap-2 mt-2">
|
|
<button
|
|
onclick={save}
|
|
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onclick={cancel}
|
|
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
<div
|
|
class="{!readonly ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded' : ''} p-2 -mx-2 min-h-[40px]"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => { if (!readonly) editing = true; }}
|
|
>
|
|
{#if value}
|
|
<div class="prose text-sm">
|
|
{#if ready}
|
|
{@html renderMarkdown(value)}
|
|
{:else}
|
|
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{value}</p>
|
|
{/if}
|
|
</div>
|
|
{:else if !readonly}
|
|
<p class="text-sm text-gray-400 dark:text-gray-500">Add a more detailed description...</p>
|
|
{:else}
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No description</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|