From f559c93e19e92e461e62dcba7ba039e97083b285 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 10 Apr 2026 19:04:54 +0300 Subject: [PATCH] feat: widget column span resizing with visual size picker - Add per-widget colSpan stored in config JSON (no DB migration) - Replace hardcoded full-width types with configurable span - Add visual size picker popover in edit mode overlay - Merge widget config updates for temp widgets in Board changeset --- src/lib/components/board/Board.svelte | 24 ++++-- .../widget/WidgetEditOverlay.svelte | 77 ++++++++++++++++++- src/lib/components/widget/WidgetGrid.svelte | 64 ++++++++++++++- src/lib/stores/editMode.svelte.ts | 4 +- 4 files changed, 152 insertions(+), 17 deletions(-) diff --git a/src/lib/components/board/Board.svelte b/src/lib/components/board/Board.svelte index 19120b8..0e631aa 100644 --- a/src/lib/components/board/Board.svelte +++ b/src/lib/components/board/Board.svelte @@ -77,14 +77,22 @@ const allSections = [...updated, ...tempSections].map((s) => { const addedWidgets = editMode.changeset.widgetAdds .filter((w) => w.sectionId === s.id) - .map((w) => ({ - id: w.tempId, - type: w.type, - order: w.order, - config: w.config, - appId: w.appId ?? null, - app: null - })); + .map((w) => { + const configUpdates = editMode.changeset.widgetUpdates.get(w.tempId); + let config = w.config; + if (configUpdates) { + const currentConfig = JSON.parse(config || '{}'); + config = JSON.stringify({ ...currentConfig, ...configUpdates }); + } + return { + id: w.tempId, + type: w.type, + order: w.order, + config, + appId: w.appId ?? null, + app: null + }; + }); const existingWidgets = s.widgets .filter((w) => !editMode.changeset.widgetDeletes.has(w.id)) diff --git a/src/lib/components/widget/WidgetEditOverlay.svelte b/src/lib/components/widget/WidgetEditOverlay.svelte index f3be218..a5cbdd8 100644 --- a/src/lib/components/widget/WidgetEditOverlay.svelte +++ b/src/lib/components/widget/WidgetEditOverlay.svelte @@ -5,27 +5,39 @@ interface Props { widgetId: string; + colSpan?: number; + maxCols?: number; onEdit: (widgetId: string) => void; onDelete: (widgetId: string) => void; + onResize?: (widgetId: string, delta: number) => void; children: Snippet; } - let { widgetId, onEdit, onDelete, children }: Props = $props(); + let { widgetId, colSpan = 1, maxCols = 4, onEdit, onDelete, onResize, children }: Props = $props(); let showDeleteConfirm = $state(false); let hovered = $state(false); + let showSizePicker = $state(false); + let previewSpan = $state(null); function handleDelete() { onDelete(widgetId); showDeleteConfirm = false; } + + function handleSpanSelect(span: number) { + if (!onResize) return; + const delta = span - colSpan; + if (delta !== 0) onResize(widgetId, delta); + showSizePicker = false; + }
{ hovered = true; }} - onmouseleave={() => { hovered = false; }} + onmouseleave={() => { hovered = false; showSizePicker = false; previewSpan = null; }} > {@render children()} @@ -43,8 +55,25 @@
- +
+ + {#if onResize} + + {/if} +
+ + + {#if showSizePicker && onResize} +
+
+ {$t('widget.width') ?? 'Width'} +
+
+ {#each Array.from({ length: maxCols }, (_, i) => i + 1) as span} + {@const isActive = span === colSpan} + {@const isPreview = span === previewSpan} + + {/each} +
+
+ {/if} {/if} diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index fc135d8..ff1506f 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -41,8 +41,17 @@ let showTypePicker = $state(false); let addingWidgetType = $state(null); - // Widgets that should span full width - const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']); + // Widget types that default to full width when no colSpan is set + const defaultFullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']); + + // Max columns at the largest breakpoint for each card size + const maxColsByCardSize: Record = { + compact: 6, + medium: 4, + large: 3 + }; + + const maxCols = $derived(maxColsByCardSize[cardSize] ?? 4); // Grid column classes based on card size const gridClass = $derived.by(() => { @@ -67,6 +76,49 @@ } }); + /** + * Resolve the effective colSpan for a widget. + * - Reads `colSpan` from config JSON (number or "full") + * - Falls back to "full" for default-full-width types, 1 otherwise + */ + function getEffectiveColSpan(widget: WidgetData): number { + const config = getWidgetConfig(widget); + const raw = config.colSpan; + + if (raw === 'full') return maxCols; + if (typeof raw === 'number' && raw >= 1) return Math.min(raw, maxCols); + + // No colSpan in config — use legacy defaults + return defaultFullWidthTypes.has(widget.type) ? maxCols : 1; + } + + // Explicit col-span class map — Tailwind needs static class names for purging + const lgColSpanClasses: Record = { + 2: 'col-span-2 lg:col-span-2', + 3: 'col-span-2 sm:col-span-3 lg:col-span-3', + 4: 'col-span-2 sm:col-span-3 lg:col-span-4', + 5: 'col-span-2 sm:col-span-3 md:col-span-4 lg:col-span-5', + }; + + /** + * Build a responsive col-span class string. + * Maps the logical span to appropriate breakpoint classes. + */ + function getColSpanClass(span: number): string { + if (span <= 1) return ''; + if (span >= maxCols) return fullWidthClass; + return lgColSpanClasses[span] ?? fullWidthClass; + } + + function handleResizeWidget(widgetId: string, delta: number) { + const widget = widgets.find((w) => w.id === widgetId); + if (!widget) return; + const current = getEffectiveColSpan(widget); + const next = Math.max(1, Math.min(maxCols, current + delta)); + const value: string | number = next >= maxCols ? 'full' : next; + editMode.updateWidget(widgetId, { colSpan: value }); + } + function handleEditWidget(widgetId: string) { editingWidgetId = widgetId; } @@ -117,8 +169,9 @@ {:else}
{#each widgets as widget (widget.id)} - {@const isFullWidth = fullWidthTypes.has(widget.type)} -
+ {@const span = getEffectiveColSpan(widget)} + {@const spanClass = getColSpanClass(span)} +
{#if editMode.active} {#if editingWidgetId === widget.id} @@ -133,8 +186,11 @@ {:else} diff --git a/src/lib/stores/editMode.svelte.ts b/src/lib/stores/editMode.svelte.ts index 4e105ed..0fb9910 100644 --- a/src/lib/stores/editMode.svelte.ts +++ b/src/lib/stores/editMode.svelte.ts @@ -59,8 +59,6 @@ class EditModeStore { boardId = $state(null); changeset = $state(createEmptyChangeset()); - dirty = $derived(this.changeCount > 0); - changeCount = $derived.by(() => { const cs = this.changeset; return ( @@ -76,6 +74,8 @@ class EditModeStore { ); }); + dirty = $derived(this.changeCount > 0); + // --- Mode control --- enterEditMode(boardId: string): void {