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:
@@ -77,14 +77,22 @@
|
||||
const allSections = [...updated, ...tempSections].map((s) => {
|
||||
const addedWidgets = editMode.changeset.widgetAdds
|
||||
.filter((w) => w.sectionId === s.id)
|
||||
.map((w) => ({
|
||||
.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: w.config,
|
||||
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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user