cba160ecb8
- 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
197 lines
5.6 KiB
Svelte
197 lines
5.6 KiB
Svelte
<script lang="ts">
|
|
import { t } from 'svelte-i18n';
|
|
import { dndzone } from 'svelte-dnd-action';
|
|
import DraggableWidget from '$lib/components/widget/DraggableWidget.svelte';
|
|
import WidgetCreationForm from '$lib/components/widget/WidgetCreationForm.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 {
|
|
section: SectionData;
|
|
apps: Array<{ id: string; name: string }>;
|
|
onWidgetsUpdate: (sectionId: string, widgets: WidgetData[]) => void;
|
|
addWidgetSectionId: string | null;
|
|
onToggleAddWidget: (sectionId: string) => void;
|
|
onDeleteSection: (sectionId: string) => void;
|
|
onAddWidget: (sectionId: string, widgetData: string) => void;
|
|
onDeleteWidget: (widgetId: string) => void;
|
|
}
|
|
|
|
let {
|
|
section,
|
|
apps,
|
|
onWidgetsUpdate,
|
|
addWidgetSectionId,
|
|
onToggleAddWidget,
|
|
onDeleteSection,
|
|
onAddWidget,
|
|
onDeleteWidget
|
|
}: Props = $props();
|
|
|
|
let widgets = $state<WidgetData[]>([...section.widgets]);
|
|
let dirty = $state(false);
|
|
|
|
// Keep local state in sync when parent data changes (skip during drag)
|
|
$effect(() => {
|
|
if (!dirty) {
|
|
widgets = [...section.widgets];
|
|
}
|
|
});
|
|
|
|
const flipDurationMs = 200;
|
|
|
|
function handleConsider(e: CustomEvent<{ items: WidgetData[] }>) {
|
|
dirty = true;
|
|
widgets = e.detail.items;
|
|
}
|
|
|
|
function handleFinalize(e: CustomEvent<{ items: WidgetData[] }>) {
|
|
dirty = true;
|
|
widgets = e.detail.items;
|
|
onWidgetsUpdate(section.id, widgets);
|
|
dirty = false;
|
|
}
|
|
|
|
function getWidgetLabel(widget: WidgetData): string {
|
|
if (widget.type === 'app' && widget.app) {
|
|
return widget.app.name;
|
|
}
|
|
try {
|
|
const cfg = JSON.parse(widget.config || '{}');
|
|
if (widget.type === 'bookmark') return cfg.label || 'Bookmark';
|
|
if (widget.type === 'note') return (cfg.content || '').substring(0, 40) || 'Note';
|
|
if (widget.type === 'embed') return cfg.url || 'Embed';
|
|
if (widget.type === 'status') return cfg.label || 'Status';
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return `Widget #${widget.order}`;
|
|
}
|
|
</script>
|
|
|
|
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
<div class="mb-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Section drag handle -->
|
|
<div
|
|
class="flex shrink-0 cursor-grab items-center px-1 text-muted-foreground transition-opacity active:cursor-grabbing"
|
|
aria-label="Drag to reorder section"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="9" cy="5" r="1" />
|
|
<circle cx="9" cy="12" r="1" />
|
|
<circle cx="9" cy="19" r="1" />
|
|
<circle cx="15" cy="5" r="1" />
|
|
<circle cx="15" cy="12" r="1" />
|
|
<circle cx="15" cy="19" r="1" />
|
|
</svg>
|
|
</div>
|
|
<span class="font-medium text-foreground">{section.title}</span>
|
|
<span class="text-xs text-muted-foreground">{$t('section.order', { values: { order: section.order } })}</span>
|
|
{#if section.icon}
|
|
<span class="text-xs text-muted-foreground">({section.icon})</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onclick={() => onToggleAddWidget(section.id)}
|
|
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
>
|
|
{$t('widget.add')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => onDeleteSection(section.id)}
|
|
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
|
>
|
|
{$t('common.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if addWidgetSectionId === section.id}
|
|
<WidgetCreationForm
|
|
sectionId={section.id}
|
|
{apps}
|
|
onSubmit={onAddWidget}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Widgets drop zone -->
|
|
{#if widgets.length === 0}
|
|
<div
|
|
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
|
onconsider={handleConsider}
|
|
onfinalize={handleFinalize}
|
|
class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors"
|
|
>
|
|
<p class="text-center text-sm text-muted-foreground">
|
|
{$t('widget.no_widgets_dnd')}
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
|
onconsider={handleConsider}
|
|
onfinalize={handleFinalize}
|
|
class="min-h-[48px] space-y-2 rounded-lg border-2 border-dashed border-transparent p-1 transition-colors"
|
|
>
|
|
{#each widgets as widget (widget.id)}
|
|
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
|
|
<DraggableWidget>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
|
|
<span class="text-sm text-foreground">{getWidgetLabel(widget)}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => onDeleteWidget(widget.id)}
|
|
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
|
>
|
|
{$t('widget.remove')}
|
|
</button>
|
|
</div>
|
|
</DraggableWidget>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|