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,75 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { fade, scale } from 'svelte/transition';
interface Props {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
title,
message,
confirmLabel,
cancelLabel,
destructive = true,
onConfirm,
onCancel
}: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onCancel();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/30 backdrop-blur-sm"
role="button"
tabindex="-1"
onclick={onCancel}
onkeydown={(e) => e.key === 'Enter' && onCancel()}
transition:fade={{ duration: 120 }}
>
<!-- Dialog -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
onclick={(e) => e.stopPropagation()}
transition:scale={{ start: 0.95, duration: 150 }}
role="alertdialog"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<h2 id="confirm-dialog-title" class="mb-2 text-base font-semibold text-foreground">{title}</h2>
<p id="confirm-dialog-message" class="mb-5 text-sm text-muted-foreground">{message}</p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
onclick={onCancel}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
{cancelLabel ?? ($t('common.cancel') ?? 'Cancel')}
</button>
<button
type="button"
onclick={onConfirm}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors
{destructive
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
>
{confirmLabel ?? ($t('common.delete') ?? 'Delete')}
</button>
</div>
</div>
</div>
@@ -0,0 +1,169 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import DynamicIcon from './DynamicIcon.svelte';
import { tick } from 'svelte';
interface Props {
value: string;
onchange: (value: string) => void;
placeholder?: string;
size?: 'sm' | 'md';
}
let { value = $bindable(), onchange, placeholder, size = 'md' }: Props = $props();
let open = $state(false);
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
// Common lucide icon names for quick selection
const commonIcons = [
'home', 'star', 'heart', 'bookmark', 'folder', 'file', 'settings', 'search',
'user', 'users', 'mail', 'phone', 'calendar', 'clock', 'camera', 'image',
'music', 'video', 'monitor', 'smartphone', 'tablet', 'laptop', 'server', 'database',
'cloud', 'globe', 'map', 'compass', 'navigation', 'flag', 'tag', 'hash',
'lock', 'unlock', 'shield', 'key', 'eye', 'bell', 'megaphone', 'message-circle',
'terminal', 'code', 'git-branch', 'git-commit', 'cpu', 'hard-drive', 'wifi', 'bluetooth',
'battery', 'zap', 'sun', 'moon', 'thermometer', 'droplet', 'wind', 'umbrella',
'shopping-cart', 'credit-card', 'dollar-sign', 'trending-up', 'bar-chart', 'pie-chart',
'activity', 'award', 'target', 'crosshair', 'layers', 'layout', 'grid', 'list',
'package', 'box', 'archive', 'trash', 'download', 'upload', 'link', 'external-link',
'check', 'x', 'plus', 'minus', 'alert-circle', 'info', 'help-circle', 'alert-triangle',
'play', 'pause', 'skip-forward', 'volume-2', 'headphones', 'radio', 'tv', 'film',
'book', 'book-open', 'clipboard', 'pen-tool', 'edit', 'scissors', 'copy', 'save',
'printer', 'share', 'send', 'inbox', 'paperclip', 'at-sign', 'rss', 'wifi',
'rocket', 'flame', 'sparkles', 'wand', 'palette', 'brush', 'pipette', 'ruler',
'truck', 'car', 'bike', 'plane', 'ship', 'train', 'bus', 'building',
'gamepad', 'puzzle', 'dice-1', 'trophy', 'medal', 'crown', 'gem', 'gift'
];
const filteredIcons = $derived(
query.trim()
? commonIcons.filter((name) => name.includes(query.toLowerCase()))
: commonIcons
);
function toggleOpen() {
open = !open;
if (open) {
query = '';
tick().then(() => searchInput?.focus());
}
}
function selectIcon(name: string) {
value = name;
onchange(name);
open = false;
}
function clearIcon() {
value = '';
onchange('');
open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
function handleClickOutside(e: MouseEvent) {
if (open && containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative" bind:this={containerEl}>
<!-- Trigger button -->
<button
type="button"
onclick={toggleOpen}
class="flex items-center gap-1.5 rounded-lg border border-input bg-background transition-colors hover:bg-accent
{size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'}"
title={$t('app.icon') ?? 'Select icon'}
>
{#if value}
<DynamicIcon name={value} size={size === 'sm' ? 14 : 18} />
<span class="text-xs text-muted-foreground">{value}</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width={size === 'sm' ? 14 : 18} height={size === 'sm' ? 14 : 18} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
<span class="text-xs text-muted-foreground">{placeholder ?? ($t('app.icon') ?? 'Icon')}</span>
{/if}
</button>
<!-- Popover rendered as fixed overlay to avoid layout overflow -->
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50"
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}
>
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-2xl">
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" 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">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
bind:this={searchInput}
type="text"
bind:value={query}
placeholder={$t('common.search') ?? 'Search icons...'}
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
/>
</div>
<!-- Clear button -->
{#if value}
<button
type="button"
onclick={clearIcon}
class="mb-2 flex w-full items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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>
{$t('common.clear') ?? 'Clear icon'}
</button>
{/if}
<!-- Icon grid -->
<div class="max-h-48 overflow-y-auto">
{#if filteredIcons.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
{:else}
<div class="grid grid-cols-8 gap-0.5">
{#each filteredIcons as iconName}
<button
type="button"
onclick={() => selectIcon(iconName)}
class="flex items-center justify-center rounded-lg p-1.5 transition-colors hover:bg-accent
{value === iconName ? 'bg-primary/10 text-primary ring-1 ring-primary/30' : 'text-foreground'}"
title={iconName}
>
<DynamicIcon name={iconName} size={16} />
</button>
{/each}
</div>
{/if}
</div>
<!-- Manual input fallback -->
<div class="mt-2 border-t border-border pt-2">
<input
type="text"
value={value}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; value = v; onchange(v); }}
placeholder={$t('app.icon_manual') ?? 'Or type icon name...'}
class="w-full rounded-lg border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
/>
</div>
</div>
</div>
{/if}
</div>