feat(inline-edit): add WYSIWYG inline dashboard editing mode

Replace the disconnected board edit page with inline editing directly
on the board view. Toggle with Ctrl+E or the Edit button. Features:

- Edit mode store with changeset accumulation and batch save
- Floating toolbar (save, discard, add section, board settings, exit)
- Widget hover overlays with edit/delete/drag controls
- Type-specific widget config panels for all 14 widget types
- Section inline editing (title, icon picker, delete)
- "+" buttons for adding widgets and sections inline
- Section-level drag-and-drop reordering via svelte-dnd-action
- Batch save API endpoint (single Prisma transaction)
- Board properties side panel with live theme/wallpaper preview
- Modal widget type picker with search filtering
- Icon picker component with visual grid and search
- Confirmation dialog modal for all destructive actions
- HTML format support for Note widget (in addition to markdown/text)
- Full i18n support (en + ru) for all new UI strings
- Legacy edit page banner linking to new inline mode
This commit is contained in:
2026-04-03 00:01:29 +03:00
parent d8f89c65dc
commit a6b09aae9c
35 changed files with 3148 additions and 51 deletions
@@ -0,0 +1,472 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { tick } from 'svelte';
interface Props {
widgetType: string;
initialConfig?: Record<string, unknown>;
apps?: Array<{ id: string; name: string }>;
mode: 'create' | 'edit';
onSave: (config: Record<string, unknown>) => void;
onCancel: () => void;
}
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
// -- Form fields initialised from config --
// App
let appId = $state((initialConfig.appId as string) ?? '');
// Bookmark
let bookmarkUrl = $state((initialConfig.url as string) ?? '');
let bookmarkLabel = $state((initialConfig.label as string) ?? '');
let bookmarkIcon = $state((initialConfig.icon as string) ?? '');
let bookmarkDescription = $state((initialConfig.description as string) ?? '');
// Note
let noteContent = $state((initialConfig.content as string) ?? '');
let noteFormat = $state<'markdown' | 'text' | 'html'>((initialConfig.format as 'markdown' | 'text' | 'html') ?? 'markdown');
// Embed
let embedUrl = $state((initialConfig.url as string) ?? '');
let embedHeight = $state((initialConfig.height as number) ?? 300);
let embedSandbox = $state((initialConfig.sandbox as string) ?? '');
// Status
let statusLabel = $state((initialConfig.label as string) ?? '');
let statusAppIds = $state<string[]>((initialConfig.appIds as string[]) ?? []);
// Clock
let clockTimezone = $state((initialConfig.timezone as string) ?? '');
let clockStyle = $state<string>((initialConfig.clockStyle as string) ?? 'digital');
let clockShowWeather = $state((initialConfig.showWeather as boolean) ?? false);
let clockLatitude = $state(String(initialConfig.latitude ?? ''));
let clockLongitude = $state(String(initialConfig.longitude ?? ''));
// System Stats
let sysStatsSourceUrl = $state((initialConfig.sourceUrl as string) ?? '');
let sysStatsSourceType = $state<string>((initialConfig.sourceType as string) ?? 'custom');
let sysStatsRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 30);
// RSS
let rssFeedUrl = $state((initialConfig.feedUrl as string) ?? '');
let rssMaxItems = $state((initialConfig.maxItems as number) ?? 10);
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
// Calendar
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
// Markdown
let markdownContent = $state((initialConfig.content as string) ?? '');
// Metric
let metricLabel = $state((initialConfig.label as string) ?? '');
let metricSource = $state<string>((initialConfig.source as string) ?? 'static');
let metricValue = $state(String(initialConfig.value ?? ''));
let metricUrl = $state((initialConfig.url as string) ?? '');
let metricJsonPath = $state((initialConfig.jsonPath as string) ?? '');
let metricQuery = $state((initialConfig.query as string) ?? '');
let metricUnit = $state((initialConfig.unit as string) ?? '');
let metricRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
// Link Group
let linkGroupLinks = $state<Array<{ label: string; url: string; icon: string }>>((initialConfig.links as Array<{ label: string; url: string; icon: string }>) ?? [{ label: '', url: '', icon: '' }]);
let linkGroupCollapsible = $state((initialConfig.collapsible as boolean) ?? false);
// Camera
let cameraStreamUrl = $state((initialConfig.streamUrl as string) ?? '');
let cameraType = $state<string>((initialConfig.type as string) ?? 'image');
let cameraRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 10);
let cameraAspectRatio = $state((initialConfig.aspectRatio as string) ?? '16/9');
// Integration
let integrationAppId = $state((initialConfig.appId as string) ?? '');
let integrationEndpointId = $state((initialConfig.endpointId as string) ?? '');
let integrationRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
function buildConfig(): Record<string, unknown> {
switch (widgetType) {
case 'app':
return { appId };
case 'bookmark':
return { url: bookmarkUrl, label: bookmarkLabel, icon: bookmarkIcon || undefined, description: bookmarkDescription || undefined };
case 'note':
return { content: noteContent, format: noteFormat };
case 'embed':
return { url: embedUrl, height: embedHeight, sandbox: embedSandbox || undefined };
case 'status':
return { appIds: statusAppIds, label: statusLabel || undefined };
case 'clock':
return { timezone: clockTimezone || undefined, clockStyle, showWeather: clockShowWeather, latitude: clockLatitude ? Number(clockLatitude) : undefined, longitude: clockLongitude ? Number(clockLongitude) : undefined };
case 'system_stats':
return { sourceUrl: sysStatsSourceUrl, sourceType: sysStatsSourceType, metrics: ['cpu', 'ram', 'disk'], refreshInterval: sysStatsRefreshInterval };
case 'rss':
return { feedUrl: rssFeedUrl, maxItems: rssMaxItems, showSummary: rssShowSummary };
case 'calendar':
return { icalUrls: calendarUrlsRaw.filter((u) => u.url.trim()), daysAhead: calendarDaysAhead };
case 'markdown':
return { content: markdownContent };
case 'metric':
return { label: metricLabel, source: metricSource, value: metricValue || undefined, url: metricUrl || undefined, jsonPath: metricJsonPath || undefined, query: metricQuery || undefined, unit: metricUnit || undefined, refreshInterval: metricRefreshInterval };
case 'link_group':
return { links: linkGroupLinks.filter((l) => l.url.trim()), collapsible: linkGroupCollapsible };
case 'camera':
return { streamUrl: cameraStreamUrl, type: cameraType, refreshInterval: cameraRefreshInterval, aspectRatio: cameraAspectRatio };
case 'integration':
return { appId: integrationAppId, endpointId: integrationEndpointId, refreshInterval: integrationRefreshInterval };
default:
return {};
}
}
function handleSave() {
onSave(buildConfig());
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onCancel();
}
function addLinkGroupLink() {
linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }];
}
function removeLinkGroupLink(index: number) {
linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index);
}
function addCalendarUrl() {
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
}
function removeCalendarUrl(index: number) {
calendarUrlsRaw = calendarUrlsRaw.filter((_, i) => i !== index);
}
// Helper for input styling
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
let firstInput: HTMLElement | undefined = $state();
$effect(() => { tick().then(() => firstInput?.focus()); });
</script>
<div
class="rounded-xl border border-border bg-card p-4 shadow-lg"
transition:fade={{ duration: 100 }}
onkeydown={handleKeydown}
role="dialog"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-foreground">
{mode === 'create' ? ($t('widget.add_widget') ?? 'Add Widget') : ($t('widget.edit_widget') ?? 'Edit Widget')}
<span class="ml-1 text-xs font-normal text-muted-foreground">({widgetType})</span>
</h3>
<button type="button" onclick={onCancel} class="rounded p-0.5 text-muted-foreground hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
<div class="max-h-80 space-y-3 overflow-y-auto">
{#if widgetType === 'app'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
<option value="">Select an app...</option>
{#each apps as app}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
{:else if widgetType === 'bookmark'}
<div>
<label class={labelClass}>URL</label>
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
<input type="text" bind:value={bookmarkLabel} class={inputClass} />
</div>
<div>
<label class={labelClass}>{$t('app.icon') ?? 'Icon'}</label>
<input type="text" bind:value={bookmarkIcon} placeholder="e.g. globe" class={inputClass} />
</div>
<div>
<label class={labelClass}>{$t('common.description') ?? 'Description'}</label>
<input type="text" bind:value={bookmarkDescription} class={inputClass} />
</div>
{:else if widgetType === 'note'}
<div>
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
<textarea bind:value={noteContent} rows="4" class={inputClass} bind:this={firstInput}></textarea>
</div>
<div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}</label>
<select bind:value={noteFormat} class={inputClass}>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
</div>
{:else if widgetType === 'embed'}
<div>
<label class={labelClass}>URL</label>
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</label>
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
</div>
<div>
<label class={labelClass}>Sandbox</label>
<input type="text" bind:value={embedSandbox} placeholder="allow-scripts allow-same-origin" class={inputClass} />
</div>
{:else if widgetType === 'status'}
<div>
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
<input type="text" bind:value={statusLabel} class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
<div class="space-y-1 rounded-lg border border-input bg-background p-2">
{#each apps as app}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={statusAppIds.includes(app.id)}
onchange={() => {
if (statusAppIds.includes(app.id)) {
statusAppIds = statusAppIds.filter((id) => id !== app.id);
} else {
statusAppIds = [...statusAppIds, app.id];
}
}}
class="h-3.5 w-3.5 rounded border-input accent-primary"
/>
{app.name}
</label>
{/each}
</div>
</div>
{:else if widgetType === 'clock'}
<div>
<label class={labelClass}>{$t('widget.timezone') ?? 'Timezone'}</label>
<input type="text" bind:value={clockTimezone} placeholder="America/New_York" class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}</label>
<select bind:value={clockStyle} class={inputClass}>
<option value="digital">Digital</option>
<option value="analog">Analog</option>
<option value="24h">24h</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
{$t('widget.show_weather') ?? 'Show Weather'}
</label>
{#if clockShowWeather}
<div class="grid grid-cols-2 gap-2">
<div>
<label class={labelClass}>Latitude</label>
<input type="text" bind:value={clockLatitude} class={inputClass} />
</div>
<div>
<label class={labelClass}>Longitude</label>
<input type="text" bind:value={clockLongitude} class={inputClass} />
</div>
</div>
{/if}
{:else if widgetType === 'system_stats'}
<div>
<label class={labelClass}>Source URL</label>
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>Source Type</label>
<select bind:value={sysStatsSourceType} class={inputClass}>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</label>
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</div>
{:else if widgetType === 'rss'}
<div>
<label class={labelClass}>Feed URL</label>
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>Max Items ({rssMaxItems})</label>
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
Show Summary
</label>
{:else if widgetType === 'calendar'}
<div>
<label class={labelClass}>iCal URLs</label>
{#each calendarUrlsRaw as cal, i}
<div class="mb-1 flex items-center gap-1">
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
<input type="color" bind:value={cal.color} class="h-8 w-8 cursor-pointer rounded border-0" />
{#if calendarUrlsRaw.length > 1}
<button type="button" onclick={() => removeCalendarUrl(i)} class="text-destructive hover:text-destructive/80">&times;</button>
{/if}
</div>
{/each}
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
</div>
<div>
<label class={labelClass}>Days Ahead ({calendarDaysAhead})</label>
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
</div>
{:else if widgetType === 'markdown'}
<div>
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
</div>
{:else if widgetType === 'metric'}
<div>
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>Source</label>
<select bind:value={metricSource} class={inputClass}>
<option value="static">Static</option>
<option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option>
</select>
</div>
{#if metricSource === 'static'}
<div>
<label class={labelClass}>Value</label>
<input type="text" bind:value={metricValue} class={inputClass} />
</div>
{:else if metricSource === 'json'}
<div>
<label class={labelClass}>URL</label>
<input type="url" bind:value={metricUrl} class={inputClass} />
</div>
<div>
<label class={labelClass}>JSON Path</label>
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
</div>
{:else if metricSource === 'prometheus'}
<div>
<label class={labelClass}>URL</label>
<input type="url" bind:value={metricUrl} class={inputClass} />
</div>
<div>
<label class={labelClass}>PromQL Query</label>
<input type="text" bind:value={metricQuery} class={inputClass} />
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label class={labelClass}>Unit</label>
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
</div>
<div>
<label class={labelClass}>Refresh ({metricRefreshInterval}s)</label>
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
</div>
</div>
{:else if widgetType === 'link_group'}
<div>
<label class={labelClass}>Links</label>
{#each linkGroupLinks as link, i}
<div class="mb-1 flex items-center gap-1">
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
<input type="text" bind:value={link.icon} placeholder="Icon" class="{inputClass} w-16" />
{#if linkGroupLinks.length > 1}
<button type="button" onclick={() => removeLinkGroupLink(i)} class="text-destructive hover:text-destructive/80">&times;</button>
{/if}
</div>
{/each}
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
Collapsible
</label>
{:else if widgetType === 'camera'}
<div>
<label class={labelClass}>Stream URL</label>
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</div>
<div>
<label class={labelClass}>Type</label>
<select bind:value={cameraType} class={inputClass}>
<option value="image">Image</option>
<option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option>
</select>
</div>
<div>
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)</label>
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
</div>
<div>
<label class={labelClass}>Aspect Ratio</label>
<select bind:value={cameraAspectRatio} class={inputClass}>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
</select>
</div>
{:else if widgetType === 'integration'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<option value="">Select app...</option>
{#each apps as app}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
<div>
<label class={labelClass}>Endpoint ID</label>
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
</div>
<div>
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)</label>
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
</div>
{/if}
</div>
<!-- Footer -->
<div class="mt-3 flex items-center justify-end gap-2 border-t border-border pt-3">
<button type="button" onclick={onCancel}
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent">
{$t('common.cancel') ?? 'Cancel'}
</button>
<button type="button" onclick={handleSave}
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
</button>
</div>
</div>