From 1eb370eaaadbea524806a05edef0b011e1474956 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 15:43:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(manacore):=20tiling=20layout=20=E2=80=94?= =?UTF-8?q?=20resizable,=20splittable=20dashboard=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed CSS Grid widget layout with a recursive tiling system using a binary tree data model. Each node is either a leaf (widget) or a split (horizontal/vertical) with a draggable resize handle. New components: - TilingLayout: Recursive renderer (leaf→TilePanel, split→flex+handle) - TilePanel: Widget wrapper with edit controls (split H/V, change, close) - TileResizeHandle: Draggable divider, H+V, keyboard accessible, 10-90% Architecture: - Binary tree model (TileNode = TileLeaf | TileSplit) - Immutable tree operations in tiling-tree.ts (splitLeaf, removeLeaf, etc.) - Tiling store with debounced IndexedDB persistence - Widget registry extracted from WidgetContainer for shared use - Mobile fallback: flattened vertical stack under 768px - Default: Clock | Tasks | Calendar (3 panels) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/components/dashboard/TilePanel.svelte | 264 ++++++++++++++++++ .../dashboard/TileResizeHandle.svelte | 186 ++++++++++++ .../components/dashboard/TilingLayout.svelte | 77 +++++ .../dashboard/WidgetContainer.svelte | 38 +-- .../components/dashboard/widget-registry.ts | 45 +++ .../apps/web/src/lib/config/default-tiling.ts | 38 +++ .../apps/web/src/lib/data/guest-seed.ts | 2 + .../apps/web/src/lib/data/local-store.ts | 3 + .../apps/web/src/lib/stores/tiling.svelte.ts | 128 +++++++++ .../manacore/apps/web/src/lib/types/tiling.ts | 35 +++ .../apps/web/src/lib/utils/tiling-tree.ts | 137 +++++++++ .../src/routes/(app)/dashboard/+page.svelte | 126 ++++++--- 12 files changed, 996 insertions(+), 83 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/components/dashboard/TilePanel.svelte create mode 100644 apps/manacore/apps/web/src/lib/components/dashboard/TileResizeHandle.svelte create mode 100644 apps/manacore/apps/web/src/lib/components/dashboard/TilingLayout.svelte create mode 100644 apps/manacore/apps/web/src/lib/components/dashboard/widget-registry.ts create mode 100644 apps/manacore/apps/web/src/lib/config/default-tiling.ts create mode 100644 apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/types/tiling.ts create mode 100644 apps/manacore/apps/web/src/lib/utils/tiling-tree.ts diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/TilePanel.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/TilePanel.svelte new file mode 100644 index 000000000..240f630ec --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/dashboard/TilePanel.svelte @@ -0,0 +1,264 @@ + + +
+ + {#if tilingStore.isEditing} +
+
+ {meta?.icon} {meta ? $_(meta.nameKey) : leaf.widgetType} +
+ +
+ + + + + + + + + +
+ + {#if showWidgetPicker} +
+ {#each WIDGET_REGISTRY as w} + + {/each} +
+ {/if} +
+ {/if} + +
+ {#if WidgetComponent} + + + {#snippet failed(error, reset)} +
+
⚠️
+

+ {(error as Error)?.message || 'Widget-Fehler'} +

+ +
+ {/snippet} +
+ {:else} +

Unbekanntes Widget: {leaf.widgetType}

+ {/if} +
+
+
+ + diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/TileResizeHandle.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/TileResizeHandle.svelte new file mode 100644 index 000000000..2a11b6230 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/dashboard/TileResizeHandle.svelte @@ -0,0 +1,186 @@ + + + + + +{#if isDragging} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/TilingLayout.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/TilingLayout.svelte new file mode 100644 index 000000000..bf3c39db8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/dashboard/TilingLayout.svelte @@ -0,0 +1,77 @@ + + +{#if node.type === 'leaf'} + +{:else} +
+
+ +
+ + tilingStore.resizePanel(node.id, r)} + onReset={() => tilingStore.resizePanel(node.id, 0.5)} + onDragStateChange={(d) => (isResizing = d)} + /> + +
+ +
+
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte index e67777cf4..0e7cdd904 100644 --- a/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte +++ b/apps/manacore/apps/web/src/lib/components/dashboard/WidgetContainer.svelte @@ -13,23 +13,7 @@ import { dashboardStore } from '$lib/stores/dashboard.svelte'; import { ManaCoreEvents } from '@manacore/shared-utils/analytics'; - // Widget components - import CreditsWidget from './widgets/CreditsWidget.svelte'; - import QuickActionsWidget from './widgets/QuickActionsWidget.svelte'; - import TransactionsWidget from './widgets/TransactionsWidget.svelte'; - import TasksTodayWidget from './widgets/TasksTodayWidget.svelte'; - import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte'; - import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte'; - import ChatRecentWidget from './widgets/ChatRecentWidget.svelte'; - import ContactsFavoritesWidget from './widgets/ContactsFavoritesWidget.svelte'; - import ZitareQuoteWidget from './widgets/ZitareQuoteWidget.svelte'; - import PictureRecentWidget from './widgets/PictureRecentWidget.svelte'; - import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte'; - import ClockTimersWidget from './widgets/ClockTimersWidget.svelte'; - import StorageUsageWidget from './widgets/StorageUsageWidget.svelte'; - import MukkeLibraryWidget from './widgets/MukkeLibraryWidget.svelte'; - import PresiDecksWidget from './widgets/PresiDecksWidget.svelte'; - import ContextDocsWidget from './widgets/ContextDocsWidget.svelte'; + import { widgetComponents } from './widget-registry'; interface Props { widget: WidgetConfig; @@ -57,26 +41,6 @@ dashboardStore.removeWidget(widget.id); } - // Widget component mapping - const widgetComponents = { - credits: CreditsWidget, - 'quick-actions': QuickActionsWidget, - transactions: TransactionsWidget, - 'tasks-today': TasksTodayWidget, - 'tasks-upcoming': TasksUpcomingWidget, - 'calendar-events': CalendarEventsWidget, - 'chat-recent': ChatRecentWidget, - 'contacts-favorites': ContactsFavoritesWidget, - 'zitare-quote': ZitareQuoteWidget, - 'picture-recent': PictureRecentWidget, - 'manadeck-progress': ManadeckProgressWidget, - 'clock-timers': ClockTimersWidget, - 'storage-usage': StorageUsageWidget, - 'mukke-library': MukkeLibraryWidget, - 'presi-decks': PresiDecksWidget, - 'context-docs': ContextDocsWidget, - } as const; - const WidgetComponent = $derived(widgetComponents[widget.type]); diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/widget-registry.ts b/apps/manacore/apps/web/src/lib/components/dashboard/widget-registry.ts new file mode 100644 index 000000000..123a16a53 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/components/dashboard/widget-registry.ts @@ -0,0 +1,45 @@ +/** + * Widget Component Registry + * + * Maps widget types to their Svelte components. + * Shared between WidgetContainer (grid layout) and TilePanel (tiling layout). + */ + +import type { WidgetType } from '$lib/types/dashboard'; +import type { Component } from 'svelte'; + +import CreditsWidget from './widgets/CreditsWidget.svelte'; +import QuickActionsWidget from './widgets/QuickActionsWidget.svelte'; +import TransactionsWidget from './widgets/TransactionsWidget.svelte'; +import TasksTodayWidget from './widgets/TasksTodayWidget.svelte'; +import TasksUpcomingWidget from './widgets/TasksUpcomingWidget.svelte'; +import CalendarEventsWidget from './widgets/CalendarEventsWidget.svelte'; +import ChatRecentWidget from './widgets/ChatRecentWidget.svelte'; +import ContactsFavoritesWidget from './widgets/ContactsFavoritesWidget.svelte'; +import ZitareQuoteWidget from './widgets/ZitareQuoteWidget.svelte'; +import PictureRecentWidget from './widgets/PictureRecentWidget.svelte'; +import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte'; +import ClockTimersWidget from './widgets/ClockTimersWidget.svelte'; +import StorageUsageWidget from './widgets/StorageUsageWidget.svelte'; +import MukkeLibraryWidget from './widgets/MukkeLibraryWidget.svelte'; +import PresiDecksWidget from './widgets/PresiDecksWidget.svelte'; +import ContextDocsWidget from './widgets/ContextDocsWidget.svelte'; + +export const widgetComponents: Record = { + credits: CreditsWidget, + 'quick-actions': QuickActionsWidget, + transactions: TransactionsWidget, + 'tasks-today': TasksTodayWidget, + 'tasks-upcoming': TasksUpcomingWidget, + 'calendar-events': CalendarEventsWidget, + 'chat-recent': ChatRecentWidget, + 'contacts-favorites': ContactsFavoritesWidget, + 'zitare-quote': ZitareQuoteWidget, + 'picture-recent': PictureRecentWidget, + 'manadeck-progress': ManadeckProgressWidget, + 'clock-timers': ClockTimersWidget, + 'storage-usage': StorageUsageWidget, + 'mukke-library': MukkeLibraryWidget, + 'presi-decks': PresiDecksWidget, + 'context-docs': ContextDocsWidget, +}; diff --git a/apps/manacore/apps/web/src/lib/config/default-tiling.ts b/apps/manacore/apps/web/src/lib/config/default-tiling.ts new file mode 100644 index 000000000..cb7185782 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/config/default-tiling.ts @@ -0,0 +1,38 @@ +/** + * Default Tiling Layout — 3 panels: Clock | Tasks | Calendar + */ + +import type { TileNode, TilingConfig } from '$lib/types/tiling'; + +export const DEFAULT_TILING_ROOT: TileNode = { + type: 'split', + id: 'root', + direction: 'horizontal', + ratio: 0.33, + first: { + type: 'leaf', + id: 'leaf-clock', + widgetType: 'clock-timers', + }, + second: { + type: 'split', + id: 'split-right', + direction: 'horizontal', + ratio: 0.5, + first: { + type: 'leaf', + id: 'leaf-tasks', + widgetType: 'tasks-today', + }, + second: { + type: 'leaf', + id: 'leaf-calendar', + widgetType: 'calendar-events', + }, + }, +}; + +export const DEFAULT_TILING_CONFIG: TilingConfig = { + root: DEFAULT_TILING_ROOT, + lastModified: new Date().toISOString(), +}; diff --git a/apps/manacore/apps/web/src/lib/data/guest-seed.ts b/apps/manacore/apps/web/src/lib/data/guest-seed.ts index e212a4b48..2f479b569 100644 --- a/apps/manacore/apps/web/src/lib/data/guest-seed.ts +++ b/apps/manacore/apps/web/src/lib/data/guest-seed.ts @@ -6,6 +6,7 @@ */ import type { LocalUserSettings, LocalDashboardConfig } from './local-store'; +import { DEFAULT_TILING_ROOT } from '$lib/config/default-tiling'; // ─── Default Settings ────────────────────────────────────── @@ -54,5 +55,6 @@ export const guestDashboardConfigs: LocalDashboardConfig[] = [ ], gridColumns: 12, lastModified: new Date().toISOString(), + tiling: DEFAULT_TILING_ROOT, }, ]; diff --git a/apps/manacore/apps/web/src/lib/data/local-store.ts b/apps/manacore/apps/web/src/lib/data/local-store.ts index 1caace183..076051aca 100644 --- a/apps/manacore/apps/web/src/lib/data/local-store.ts +++ b/apps/manacore/apps/web/src/lib/data/local-store.ts @@ -8,6 +8,7 @@ import { createLocalStore, type BaseRecord } from '@manacore/local-store'; import type { WidgetConfig } from '$lib/types/dashboard'; +import type { TileNode } from '$lib/types/tiling'; import { guestSettings, guestDashboardConfigs } from './guest-seed.js'; // ─── Types ────────────────────────────────────────────────── @@ -28,6 +29,8 @@ export interface LocalDashboardConfig extends BaseRecord { widgets: WidgetConfig[]; gridColumns: number; lastModified: string; + /** Tiling layout tree (binary tree of splits and leaves). */ + tiling?: TileNode; } // ─── Store ────────────────────────────────────────────────── diff --git a/apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts b/apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts new file mode 100644 index 000000000..dddafdb9f --- /dev/null +++ b/apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts @@ -0,0 +1,128 @@ +/** + * Tiling Store — Manages the tiling layout tree + * + * Persists to IndexedDB via dashboardCollection. All tree mutations + * are immutable (return new tree) to trigger Svelte 5 reactivity. + */ + +import { browser } from '$app/environment'; +import type { TileNode, TileLeaf } from '$lib/types/tiling'; +import type { WidgetType } from '$lib/types/dashboard'; +import { DEFAULT_TILING_ROOT } from '$lib/config/default-tiling'; +import { dashboardCollection, type LocalDashboardConfig } from '$lib/data/local-store'; +import * as tree from '$lib/utils/tiling-tree'; + +let root = $state(structuredClone(DEFAULT_TILING_ROOT)); +let isEditing = $state(false); +let initialized = $state(false); + +let persistTimeout: ReturnType | undefined; + +function schedulePersist() { + clearTimeout(persistTimeout); + persistTimeout = setTimeout(doPersist, 300); +} + +async function doPersist() { + if (!browser) return; + try { + const existing = await dashboardCollection.get('dashboard-default'); + const update: Partial = { + tiling: root, + lastModified: new Date().toISOString(), + }; + if (existing) { + await dashboardCollection.update('dashboard-default', update); + } else { + await dashboardCollection.insert({ + id: 'dashboard-default', + widgets: [], + gridColumns: 12, + tiling: root, + lastModified: new Date().toISOString(), + } as LocalDashboardConfig); + } + } catch (e) { + console.error('Failed to persist tiling layout:', e); + } +} + +export const tilingStore = { + get root() { + return root; + }, + get isEditing() { + return isEditing; + }, + get initialized() { + return initialized; + }, + get leaves(): TileLeaf[] { + return tree.collectLeaves(root); + }, + + async initialize() { + if (!browser || initialized) return; + + try { + const stored = await dashboardCollection.get('dashboard-default'); + if (stored?.tiling) { + root = stored.tiling as TileNode; + } + } catch (e) { + console.error('Failed to load tiling layout:', e); + } + + initialized = true; + }, + + startEditing() { + isEditing = true; + }, + + stopEditing() { + isEditing = false; + doPersist(); + }, + + toggleEditing() { + if (isEditing) { + this.stopEditing(); + } else { + this.startEditing(); + } + }, + + splitPanel(leafId: string, direction: 'horizontal' | 'vertical', widgetType: WidgetType) { + root = tree.splitLeaf(root, leafId, direction, widgetType); + schedulePersist(); + }, + + removePanel(leafId: string) { + const result = tree.removeLeaf(root, leafId); + if (result) { + root = result; + schedulePersist(); + } + }, + + swapPanels(leafIdA: string, leafIdB: string) { + root = tree.swapLeaves(root, leafIdA, leafIdB); + schedulePersist(); + }, + + resizePanel(splitId: string, ratio: number) { + root = tree.updateRatio(root, splitId, ratio); + schedulePersist(); + }, + + setWidget(leafId: string, widgetType: WidgetType) { + root = tree.setLeafWidget(root, leafId, widgetType); + schedulePersist(); + }, + + resetToDefault() { + root = structuredClone(DEFAULT_TILING_ROOT); + doPersist(); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/types/tiling.ts b/apps/manacore/apps/web/src/lib/types/tiling.ts new file mode 100644 index 000000000..55fb5608c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/types/tiling.ts @@ -0,0 +1,35 @@ +/** + * Tiling Layout Types + * + * Binary tree structure for resizable, splittable dashboard panels. + */ + +import type { WidgetType } from './dashboard'; + +/** A node in the tiling layout tree. */ +export type TileNode = TileLeaf | TileSplit; + +/** A leaf node containing a single widget. */ +export interface TileLeaf { + type: 'leaf'; + id: string; + widgetType: WidgetType; +} + +/** A split node with two children separated by a resize handle. */ +export interface TileSplit { + type: 'split'; + id: string; + /** 'horizontal' = side by side (left|right), 'vertical' = stacked (top/bottom). */ + direction: 'horizontal' | 'vertical'; + /** Position of divider, 0.0 to 1.0. */ + ratio: number; + first: TileNode; + second: TileNode; +} + +/** Serializable tiling configuration. */ +export interface TilingConfig { + root: TileNode; + lastModified: string; +} diff --git a/apps/manacore/apps/web/src/lib/utils/tiling-tree.ts b/apps/manacore/apps/web/src/lib/utils/tiling-tree.ts new file mode 100644 index 000000000..3587686b6 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/utils/tiling-tree.ts @@ -0,0 +1,137 @@ +/** + * Tiling Tree — Pure Functions + * + * Immutable tree operations for the tiling layout. + * Every mutation returns a new tree to trigger Svelte 5 reactivity. + */ + +import type { TileNode, TileLeaf, TileSplit } from '$lib/types/tiling'; +import type { WidgetType } from '$lib/types/dashboard'; + +export function generateId(): string { + return crypto.randomUUID().slice(0, 8); +} + +/** Deep clone a tree node. */ +export function cloneTree(node: TileNode): TileNode { + return structuredClone(node); +} + +/** Collect all leaf nodes in depth-first order. */ +export function collectLeaves(node: TileNode): TileLeaf[] { + if (node.type === 'leaf') return [node]; + return [...collectLeaves(node.first), ...collectLeaves(node.second)]; +} + +/** Find a node by ID. */ +export function findNode(root: TileNode, id: string): TileNode | null { + if (root.id === id) return root; + if (root.type === 'leaf') return null; + return findNode(root.first, id) ?? findNode(root.second, id); +} + +/** Find the parent split and child key for a given node ID. */ +export function findParent(root: TileNode, id: string): [TileSplit, 'first' | 'second'] | null { + if (root.type === 'leaf') return null; + if (root.first.id === id) return [root, 'first']; + if (root.second.id === id) return [root, 'second']; + return findParent(root.first, id) ?? findParent(root.second, id); +} + +/** Split a leaf into two panels. Returns a new tree. */ +export function splitLeaf( + root: TileNode, + leafId: string, + direction: 'horizontal' | 'vertical', + newWidgetType: WidgetType +): TileNode { + const tree = cloneTree(root); + const node = findNode(tree, leafId); + if (!node || node.type !== 'leaf') return tree; + + const newSplit: TileSplit = { + type: 'split', + id: generateId(), + direction, + ratio: 0.5, + first: { ...node }, + second: { + type: 'leaf', + id: generateId(), + widgetType: newWidgetType, + }, + }; + + return replaceNode(tree, leafId, newSplit); +} + +/** Remove a leaf. Its sibling takes the parent's place. Returns null if tree becomes empty. */ +export function removeLeaf(root: TileNode, leafId: string): TileNode | null { + if (root.type === 'leaf') { + return root.id === leafId ? null : root; + } + + const tree = cloneTree(root); + const parent = findParent(tree, leafId); + if (!parent) return tree; + + const [parentNode, childKey] = parent; + const siblingKey = childKey === 'first' ? 'second' : 'first'; + const sibling = parentNode[siblingKey]; + + // Replace parent with sibling in the grandparent + const grandparent = findParent(tree, parentNode.id); + if (!grandparent) { + // Parent is root — sibling becomes new root + return sibling; + } + + const [grandNode, grandKey] = grandparent; + grandNode[grandKey] = sibling; + return tree; +} + +/** Swap widget types between two leaves. Returns a new tree. */ +export function swapLeaves(root: TileNode, leafIdA: string, leafIdB: string): TileNode { + const tree = cloneTree(root); + const a = findNode(tree, leafIdA); + const b = findNode(tree, leafIdB); + if (!a || !b || a.type !== 'leaf' || b.type !== 'leaf') return tree; + + const temp = a.widgetType; + a.widgetType = b.widgetType; + b.widgetType = temp; + return tree; +} + +/** Update the split ratio. Returns a new tree. */ +export function updateRatio(root: TileNode, splitId: string, ratio: number): TileNode { + const tree = cloneTree(root); + const node = findNode(tree, splitId); + if (!node || node.type !== 'split') return tree; + + node.ratio = Math.max(0.1, Math.min(0.9, ratio)); + return tree; +} + +/** Change the widget type of a leaf. Returns a new tree. */ +export function setLeafWidget(root: TileNode, leafId: string, widgetType: WidgetType): TileNode { + const tree = cloneTree(root); + const node = findNode(tree, leafId); + if (!node || node.type !== 'leaf') return tree; + + node.widgetType = widgetType; + return tree; +} + +/** Replace a node by ID with a new node. */ +function replaceNode(root: TileNode, targetId: string, replacement: TileNode): TileNode { + if (root.id === targetId) return replacement; + if (root.type === 'leaf') return root; + + return { + ...root, + first: replaceNode(root.first, targetId, replacement), + second: replaceNode(root.second, targetId, replacement), + }; +} diff --git a/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte index b027f0a1b..f6041b8b5 100644 --- a/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/dashboard/+page.svelte @@ -1,68 +1,102 @@ -
-
+
+
- {/if} - + +
- + {#if tilingStore.initialized} +
+ {#if isMobile} + +
+ {#each mobileLeaves as leaf (leaf.id)} +
+ +
+ {/each} +
+ {:else} + + {/if} +
+ {/if}