Files
web-app-launcher/src/lib/components/board/DraggableBoard.svelte
T
alexei.dolgolyov cba160ecb8 fix: address all code review findings
- Extract shared permission logic into boardPermissions.ts utility
- Fix DnD drag revert: add dirty flag to prevent  overwrite
- Wrap OAuth group sync in Prisma transaction (N+1 fix)
- Add empty widgetIds validation in widget reorder API
- Add invalidateAll() after guest toggle PATCH
- Replace console.error with user-visible error banners
- Extract WidgetCreationForm component (DraggableSection was 448 lines)
- Remove unused boardId prop from DraggableSection
- Always include OAuth state parameter + validate in callback
- Clean up copyLink timer on component destroy
- Add type-specific widget config validation in addWidget action
2026-03-25 00:03:32 +03:00

143 lines
3.4 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { dndzone } from 'svelte-dnd-action';
import DraggableSection from '$lib/components/section/DraggableSection.svelte';
interface WidgetData {
id: string;
type: string;
order: number;
config: string;
appId: string | null;
sectionId: string;
app: {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
} | null;
}
interface SectionData {
id: string;
title: string;
icon: string | null;
order: number;
isExpandedByDefault: boolean;
widgets: WidgetData[];
}
interface Props {
boardId: string;
sections: SectionData[];
apps: Array<{ id: string; name: string }>;
addWidgetSectionId: string | null;
onToggleAddWidget: (sectionId: string) => void;
onDeleteSection: (sectionId: string) => void;
onAddWidget: (sectionId: string, widgetData: string) => void;
onDeleteWidget: (widgetId: string) => void;
}
let {
boardId,
sections: initialSections,
apps,
addWidgetSectionId,
onToggleAddWidget,
onDeleteSection,
onAddWidget,
onDeleteWidget
}: Props = $props();
let sections = $state<SectionData[]>([...initialSections]);
let dirty = $state(false);
let errorMessage = $state('');
// Keep local state in sync when parent data changes (skip during drag)
$effect(() => {
if (!dirty) {
sections = [...initialSections];
}
});
const flipDurationMs = 200;
function handleConsider(e: CustomEvent<{ items: SectionData[] }>) {
dirty = true;
sections = e.detail.items;
}
async function handleFinalize(e: CustomEvent<{ items: SectionData[] }>) {
dirty = true;
sections = e.detail.items;
const sectionIds = sections.map((s) => s.id);
try {
await fetch(`/api/boards/${boardId}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionIds })
});
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Failed to persist section reorder';
} finally {
dirty = false;
}
}
async function handleWidgetsUpdate(sectionId: string, widgets: WidgetData[]) {
// Update local state
dirty = true;
sections = sections.map((s) => (s.id === sectionId ? { ...s, widgets } : s));
const widgetIds = widgets.map((w) => w.id);
try {
await fetch(`/api/boards/${boardId}/sections/${sectionId}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widgetIds })
});
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Failed to persist widget reorder';
} finally {
dirty = false;
}
}
</script>
{#if errorMessage}
<p class="mb-2 text-sm text-destructive">{errorMessage}</p>
{/if}
{#if sections.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
</div>
{:else}
<div
use:dndzone={{ items: sections, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="space-y-4"
>
{#each sections as section (section.id)}
<div>
<DraggableSection
{section}
{apps}
onWidgetsUpdate={handleWidgetsUpdate}
{addWidgetSectionId}
{onToggleAddWidget}
{onDeleteSection}
{onAddWidget}
{onDeleteWidget}
/>
</div>
{/each}
</div>
{/if}