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
This commit is contained in:
2026-04-10 19:04:54 +03:00
parent 65783e35d2
commit f559c93e19
4 changed files with 152 additions and 17 deletions
+16 -8
View File
@@ -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))
@@ -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<number | null>(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;
}
</script>
<div
class="group relative"
role="group"
onmouseenter={() => { hovered = true; }}
onmouseleave={() => { hovered = false; }}
onmouseleave={() => { hovered = false; showSizePicker = false; previewSpan = null; }}
>
{@render children()}
@@ -43,8 +55,25 @@
</div>
</div>
<!-- Top-right: edit + delete -->
<!-- Top-right: resize + edit + delete -->
<div class="absolute right-1.5 top-1.5 flex items-center gap-1">
<!-- Resize button -->
{#if onResize}
<button
type="button"
onclick={() => { showSizePicker = !showSizePicker; previewSpan = null; }}
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors
{showSizePicker ? 'bg-primary text-primary-foreground' : 'hover:bg-accent hover:text-foreground'}"
title={$t('widget.resize') ?? 'Resize'}
>
<!-- Columns icon -->
<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">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" /><path d="M15 3v18" />
</svg>
</button>
{/if}
<!-- Edit button -->
<button
type="button"
@@ -71,6 +100,48 @@
</svg>
</button>
</div>
<!-- Size picker popover -->
{#if showSizePicker && onResize}
<div class="absolute right-1.5 top-10 z-20 rounded-lg border border-border bg-card p-2 shadow-xl backdrop-blur-sm">
<div class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{$t('widget.width') ?? 'Width'}
</div>
<div class="flex flex-col gap-1">
{#each Array.from({ length: maxCols }, (_, i) => i + 1) as span}
{@const isActive = span === colSpan}
{@const isPreview = span === previewSpan}
<button
type="button"
onclick={() => handleSpanSelect(span)}
onmouseenter={() => { previewSpan = span; }}
onmouseleave={() => { previewSpan = null; }}
class="flex items-center gap-2 rounded-md px-2 py-1 text-left transition-colors
{isActive
? 'bg-primary/15 text-primary ring-1 ring-primary/30'
: isPreview
? 'bg-accent text-foreground'
: 'text-foreground hover:bg-accent'}"
>
<!-- Visual block representation -->
<div class="flex gap-px">
{#each Array(maxCols) as _, ci}
<div
class="h-2.5 w-3 rounded-[2px] transition-colors
{ci < span
? isActive ? 'bg-primary' : 'bg-foreground/50'
: 'bg-border'}"
></div>
{/each}
</div>
<span class="text-xs tabular-nums">
{span === maxCols ? ($t('widget.full_width') ?? 'Full') : `${span}/${maxCols}`}
</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
+60 -4
View File
@@ -41,8 +41,17 @@
let showTypePicker = $state(false);
let addingWidgetType = $state<string | null>(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<string, number> = {
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<number, string> = {
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}
<div class={gridClass}>
{#each widgets as widget (widget.id)}
{@const isFullWidth = fullWidthTypes.has(widget.type)}
<div class={isFullWidth ? fullWidthClass : ''}>
{@const span = getEffectiveColSpan(widget)}
{@const spanClass = getColSpanClass(span)}
<div class={spanClass}>
{#if editMode.active}
{#if editingWidgetId === widget.id}
<!-- Inline config editor -->
@@ -133,8 +186,11 @@
{:else}
<WidgetEditOverlay
widgetId={widget.id}
colSpan={span}
{maxCols}
onEdit={handleEditWidget}
onDelete={handleDeleteWidget}
onResize={handleResizeWidget}
>
<WidgetContainer>
<WidgetRenderer {widget} {allApps} {cardSize} />
+2 -2
View File
@@ -59,8 +59,6 @@ class EditModeStore {
boardId = $state<string | null>(null);
changeset = $state<Changeset>(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 {