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:
@@ -2,6 +2,8 @@
|
||||
import SectionHeader from './SectionHeader.svelte';
|
||||
import SectionCollapsible from './SectionCollapsible.svelte';
|
||||
import WidgetGrid from '$lib/components/widget/WidgetGrid.svelte';
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
import type { CardSize } from '$lib/utils/constants.js';
|
||||
|
||||
interface WidgetData {
|
||||
id: string;
|
||||
@@ -20,8 +22,6 @@
|
||||
} | null;
|
||||
}
|
||||
|
||||
import type { CardSize } from '$lib/utils/constants.js';
|
||||
|
||||
interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -58,17 +58,24 @@
|
||||
let expanded = $state(section.isExpandedByDefault);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/30 shadow-sm">
|
||||
<div class="rounded-xl border border-border bg-card/30 shadow-sm {editMode.active ? 'ring-1 ring-primary/10' : ''}">
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
icon={section.icon}
|
||||
{expanded}
|
||||
onToggle={() => (expanded = !expanded)}
|
||||
widgetCount={section.widgets.length}
|
||||
/>
|
||||
|
||||
<SectionCollapsible {expanded}>
|
||||
<div class="px-4 pb-4">
|
||||
<WidgetGrid widgets={section.widgets} {allApps} cardSize={effectiveCardSize} />
|
||||
<WidgetGrid
|
||||
widgets={section.widgets}
|
||||
sectionId={section.id}
|
||||
{allApps}
|
||||
cardSize={effectiveCardSize}
|
||||
/>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
widgetCount?: number;
|
||||
}
|
||||
|
||||
let { title, icon, expanded, onToggle }: Props = $props();
|
||||
let { sectionId, title, icon, expanded, onToggle, widgetCount = 0 }: Props = $props();
|
||||
|
||||
let editingTitle = $state(false);
|
||||
let editTitle = $state('');
|
||||
let editIcon = $state('');
|
||||
let showDeleteConfirm = $state(false);
|
||||
let titleInput: HTMLInputElement | undefined = $state();
|
||||
let editContainerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function getTitle() { return title; }
|
||||
function getIcon() { return icon; }
|
||||
|
||||
function startEditTitle() {
|
||||
editTitle = getTitle();
|
||||
editIcon = getIcon() ?? '';
|
||||
editingTitle = true;
|
||||
// Focus after render
|
||||
setTimeout(() => titleInput?.focus(), 0);
|
||||
}
|
||||
|
||||
function saveTitleEdit() {
|
||||
const trimmed = editTitle.trim();
|
||||
if (trimmed) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (trimmed !== getTitle()) updates.title = trimmed;
|
||||
const newIcon = editIcon.trim() || null;
|
||||
if (newIcon !== getIcon()) updates.icon = newIcon;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
editMode.updateSection(sectionId, updates);
|
||||
}
|
||||
}
|
||||
editingTitle = false;
|
||||
}
|
||||
|
||||
function handleEditBlur(e: FocusEvent) {
|
||||
// Don't close if focus moved to another input within the edit container
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related && editContainerEl?.contains(related)) return;
|
||||
saveTitleEdit();
|
||||
}
|
||||
|
||||
function cancelTitleEdit() {
|
||||
editingTitle = false;
|
||||
}
|
||||
|
||||
function handleTitleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveTitleEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelTitleEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
editMode.deleteSection(sectionId);
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3 text-left transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||
class:rotate-90={expanded}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
|
||||
{#if icon}
|
||||
<DynamicIcon name={icon} size={18} />
|
||||
<div class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3">
|
||||
{#if editMode.active}
|
||||
<!-- Edit mode: drag handle -->
|
||||
<div class="cursor-grab text-muted-foreground" title="Drag to reorder">
|
||||
<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">
|
||||
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
|
||||
<circle cx="9" cy="19" r="1" /><circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="font-medium text-foreground">{title}</span>
|
||||
</button>
|
||||
<!-- Expand/collapse toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
class="transition-colors hover:bg-accent/30 rounded p-0.5"
|
||||
aria-label={expanded ? 'Collapse section' : 'Expand section'}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||
class:rotate-90={expanded}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if editMode.active && editingTitle}
|
||||
<!-- Inline title editor -->
|
||||
<div class="flex flex-1 items-center gap-1" bind:this={editContainerEl}>
|
||||
<input
|
||||
bind:this={titleInput}
|
||||
type="text"
|
||||
bind:value={editTitle}
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={handleEditBlur}
|
||||
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
/>
|
||||
<IconPickerButton
|
||||
value={editIcon}
|
||||
onchange={(v) => { editIcon = v; }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Display title -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={editMode.active ? startEditTitle : onToggle}
|
||||
class="flex flex-1 items-center gap-2 text-left transition-colors hover:bg-accent/30 rounded px-1"
|
||||
>
|
||||
{#if icon}
|
||||
<DynamicIcon name={icon} size={18} />
|
||||
{/if}
|
||||
<span class="font-medium text-foreground">{title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editMode.active}
|
||||
<!-- Edit mode actions -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#if !editingTitle}
|
||||
<!-- Edit title button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditTitle}
|
||||
class="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title={$t('common.edit') ?? 'Edit'}
|
||||
>
|
||||
<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">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete section -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showDeleteConfirm = true; }}
|
||||
class="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title={$t('common.delete') ?? 'Delete'}
|
||||
>
|
||||
<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">
|
||||
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<ConfirmDialog
|
||||
title={$t('board.delete_section_title') ?? 'Delete Section'}
|
||||
message={($t('board.delete_section_confirm') ?? 'Are you sure you want to delete this section and its {count} widgets?').replace('{count}', String(widgetCount))}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { showDeleteConfirm = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user