feat(manacore): tiling layout — resizable, splittable dashboard panels

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:43:19 +02:00
parent 5f9c2a600d
commit 1eb370eaaa
12 changed files with 996 additions and 83 deletions

View file

@ -0,0 +1,264 @@
<script lang="ts">
/**
* TilePanel — Renders a single widget in a tiling leaf panel.
* In edit mode, shows controls for split, change widget, and close.
*/
import { _ } from 'svelte-i18n';
import { Card } from '@manacore/shared-ui';
import type { TileLeaf } from '$lib/types/tiling';
import type { WidgetType } from '$lib/types/dashboard';
import { WIDGET_REGISTRY, getWidgetMeta } from '$lib/types/dashboard';
import { tilingStore } from '$lib/stores/tiling.svelte';
import { widgetComponents } from './widget-registry';
interface Props {
leaf: TileLeaf;
}
let { leaf }: Props = $props();
const WidgetComponent = $derived(widgetComponents[leaf.widgetType]);
const meta = $derived(getWidgetMeta(leaf.widgetType));
let showWidgetPicker = $state(false);
function handleChangeWidget(widgetType: WidgetType) {
tilingStore.setWidget(leaf.id, widgetType);
showWidgetPicker = false;
}
// Drag & drop for panel swapping
function handleDragStart(e: DragEvent) {
e.dataTransfer?.setData('text/plain', leaf.id);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const sourceId = e.dataTransfer?.getData('text/plain');
if (sourceId && sourceId !== leaf.id) {
tilingStore.swapPanels(sourceId, leaf.id);
}
}
</script>
<div
class="tile-panel"
draggable={tilingStore.isEditing ? 'true' : undefined}
ondragstart={tilingStore.isEditing ? handleDragStart : undefined}
ondragover={tilingStore.isEditing ? handleDragOver : undefined}
ondrop={tilingStore.isEditing ? handleDrop : undefined}
role="region"
aria-label={meta?.nameKey ? $_(meta.nameKey) : leaf.widgetType}
>
<Card class="relative h-full">
{#if tilingStore.isEditing}
<div class="tile-edit-overlay">
<div class="tile-edit-header">
<span class="tile-edit-title"
>{meta?.icon} {meta ? $_(meta.nameKey) : leaf.widgetType}</span
>
</div>
<div class="tile-edit-actions">
<!-- Split buttons -->
<button
type="button"
class="tile-edit-btn"
onclick={() => tilingStore.splitPanel(leaf.id, 'horizontal', 'quick-actions')}
title="Horizontal teilen"
>
⬌ Split H
</button>
<button
type="button"
class="tile-edit-btn"
onclick={() => tilingStore.splitPanel(leaf.id, 'vertical', 'quick-actions')}
title="Vertikal teilen"
>
⬍ Split V
</button>
<!-- Change widget -->
<button
type="button"
class="tile-edit-btn"
onclick={() => (showWidgetPicker = !showWidgetPicker)}
>
🔄 Widget
</button>
<!-- Close -->
<button
type="button"
class="tile-edit-btn tile-edit-btn-danger"
onclick={() => tilingStore.removePanel(leaf.id)}
title="Panel schließen"
>
</button>
</div>
{#if showWidgetPicker}
<div class="tile-widget-picker">
{#each WIDGET_REGISTRY as w}
<button
type="button"
class="tile-widget-option"
class:active={w.type === leaf.widgetType}
onclick={() => handleChangeWidget(w.type)}
>
{w.icon}
{$_(w.nameKey)}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="tile-panel-content" class:opacity-0={tilingStore.isEditing}>
{#if WidgetComponent}
<svelte:boundary>
<WidgetComponent />
{#snippet failed(error, reset)}
<div class="flex flex-col items-center justify-center p-6 text-center">
<div class="mb-2 text-2xl">⚠️</div>
<p class="mb-3 text-sm text-muted-foreground">
{(error as Error)?.message || 'Widget-Fehler'}
</p>
<button
type="button"
onclick={reset}
class="rounded-md bg-muted px-3 py-1 text-xs hover:bg-muted/80"
>
Erneut versuchen
</button>
</div>
{/snippet}
</svelte:boundary>
{:else}
<p class="p-4 text-muted-foreground">Unbekanntes Widget: {leaf.widgetType}</p>
{/if}
</div>
</Card>
</div>
<style>
.tile-panel {
height: 100%;
width: 100%;
min-height: 0;
min-width: 0;
}
.tile-panel-content {
height: 100%;
overflow: auto;
padding: 1rem;
}
.tile-edit-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
border-radius: 0.75rem;
border: 2px dashed var(--color-primary, #6366f1);
background: var(--color-background, #fff);
opacity: 0.95;
}
.tile-edit-header {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
cursor: grab;
}
.tile-edit-header:active {
cursor: grabbing;
}
.tile-edit-title {
font-size: 0.875rem;
font-weight: 600;
}
.tile-edit-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.75rem;
flex: 1;
}
.tile-edit-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
background: var(--color-surface, #f3f4f6);
color: var(--color-text, #374151);
transition: background 0.15s;
}
.tile-edit-btn:hover {
background: var(--color-surface-hover, #e5e7eb);
}
.tile-edit-btn-danger {
color: var(--color-destructive, #ef4444);
}
.tile-edit-btn-danger:hover {
background: var(--color-destructive, #ef4444);
color: white;
}
.tile-widget-picker {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25rem;
padding: 0.5rem;
max-height: 200px;
overflow-y: auto;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.tile-widget-option {
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.375rem;
font-size: 0.7rem;
text-align: left;
cursor: pointer;
background: transparent;
color: var(--color-text, #374151);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-widget-option:hover {
background: var(--color-surface-hover, #e5e7eb);
}
.tile-widget-option.active {
background: var(--color-primary, #6366f1);
color: white;
}
</style>

View file

@ -0,0 +1,186 @@
<script lang="ts">
/**
* TileResizeHandle — Draggable divider for tiling layout.
* Supports horizontal (col-resize) and vertical (row-resize) directions.
* Keyboard accessible: arrow keys (2%), shift+arrow (10%), Home to reset.
*/
interface Props {
direction: 'horizontal' | 'vertical';
ratio: number;
onResize: (ratio: number) => void;
onReset: () => void;
onDragStateChange?: (isDragging: boolean) => void;
}
let { direction, ratio, onResize, onReset, onDragStateChange }: Props = $props();
let isDragging = $state(false);
let handleRef: HTMLElement | undefined;
const MIN = 0.1;
const MAX = 0.9;
function clamp(v: number): number {
return Math.max(MIN, Math.min(MAX, v));
}
function setDragging(value: boolean) {
isDragging = value;
onDragStateChange?.(value);
}
function handleMouseDown(event: MouseEvent) {
event.preventDefault();
setDragging(true);
const container = handleRef?.parentElement;
if (!container) return;
const handleMouseMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
let newRatio: number;
if (direction === 'horizontal') {
newRatio = (e.clientX - rect.left) / rect.width;
} else {
newRatio = (e.clientY - rect.top) / rect.height;
}
onResize(clamp(newRatio));
};
const handleMouseUp = () => {
setDragging(false);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleTouchStart(event: TouchEvent) {
event.preventDefault();
setDragging(true);
const container = handleRef?.parentElement;
if (!container) return;
const handleTouchMove = (e: TouchEvent) => {
const touch = e.touches[0];
const rect = container.getBoundingClientRect();
let newRatio: number;
if (direction === 'horizontal') {
newRatio = (touch.clientX - rect.left) / rect.width;
} else {
newRatio = (touch.clientY - rect.top) / rect.height;
}
onResize(clamp(newRatio));
};
const handleTouchEnd = () => {
setDragging(false);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd);
}
function handleKeyDown(event: KeyboardEvent) {
const step = event.shiftKey ? 0.1 : 0.02;
const increase = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
const decrease = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
if (event.key === increase) {
event.preventDefault();
onResize(clamp(ratio + step));
} else if (event.key === decrease) {
event.preventDefault();
onResize(clamp(ratio - step));
} else if (event.key === 'Home') {
event.preventDefault();
onReset();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<div
bind:this={handleRef}
class="tile-resize-handle"
class:horizontal={direction === 'horizontal'}
class:vertical={direction === 'vertical'}
class:dragging={isDragging}
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
onkeydown={handleKeyDown}
ondblclick={onReset}
role="separator"
tabindex="0"
aria-orientation={direction}
aria-valuenow={Math.round(ratio * 100)}
aria-valuemin={Math.round(MIN * 100)}
aria-valuemax={Math.round(MAX * 100)}
aria-label="Panel-Teiler"
>
<div class="tile-resize-handle-bar"></div>
</div>
{#if isDragging}
<div class="tile-resize-overlay"></div>
{/if}
<style>
.tile-resize-handle {
flex: 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
user-select: none;
touch-action: none;
}
.tile-resize-handle.horizontal {
cursor: col-resize;
}
.tile-resize-handle.vertical {
cursor: row-resize;
}
.tile-resize-handle-bar {
border-radius: 3px;
background: var(--color-border, #e5e7eb);
transition: background 0.15s ease;
}
.tile-resize-handle.horizontal .tile-resize-handle-bar {
width: 4px;
height: 32px;
}
.tile-resize-handle.vertical .tile-resize-handle-bar {
width: 32px;
height: 4px;
}
.tile-resize-handle:hover .tile-resize-handle-bar,
.tile-resize-handle.dragging .tile-resize-handle-bar {
background: var(--color-primary, #6366f1);
}
.tile-resize-handle:focus-visible {
outline: 2px solid var(--color-primary, #6366f1);
outline-offset: -1px;
border-radius: 3px;
}
.tile-resize-overlay {
position: fixed;
inset: 0;
z-index: 50;
cursor: inherit;
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
/**
* TilingLayout — Recursive renderer for the tiling tree.
*
* Leaf → TilePanel (renders widget)
* Split → Flex container with two children and a resize handle between them.
*/
import type { TileNode } from '$lib/types/tiling';
import { tilingStore } from '$lib/stores/tiling.svelte';
import TilePanel from './TilePanel.svelte';
import TileResizeHandle from './TileResizeHandle.svelte';
import Self from './TilingLayout.svelte';
interface Props {
node: TileNode;
}
let { node }: Props = $props();
let isResizing = $state(false);
</script>
{#if node.type === 'leaf'}
<TilePanel leaf={node} />
{:else}
<div
class="tile-split"
class:tile-split-h={node.direction === 'horizontal'}
class:tile-split-v={node.direction === 'vertical'}
class:tile-split-resizing={isResizing}
>
<div class="tile-child" style:flex={node.ratio}>
<Self node={node.first} />
</div>
<TileResizeHandle
direction={node.direction}
ratio={node.ratio}
onResize={(r) => tilingStore.resizePanel(node.id, r)}
onReset={() => tilingStore.resizePanel(node.id, 0.5)}
onDragStateChange={(d) => (isResizing = d)}
/>
<div class="tile-child" style:flex={1 - node.ratio}>
<Self node={node.second} />
</div>
</div>
{/if}
<style>
.tile-split {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
}
.tile-split-h {
flex-direction: row;
}
.tile-split-v {
flex-direction: column;
}
.tile-child {
min-height: 0;
min-width: 0;
overflow: hidden;
}
.tile-split-resizing {
user-select: none;
}
</style>

View file

@ -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]);
</script>

View file

@ -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<WidgetType, Component> = {
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,
};

View file

@ -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(),
};

View file

@ -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,
},
];

View file

@ -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 ──────────────────────────────────────────────────

View file

@ -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<TileNode>(structuredClone(DEFAULT_TILING_ROOT));
let isEditing = $state(false);
let initialized = $state(false);
let persistTimeout: ReturnType<typeof setTimeout> | 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<LocalDashboardConfig> = {
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();
},
};

View file

@ -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;
}

View file

@ -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),
};
}

View file

@ -1,68 +1,102 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { Card, PageHeader } from '@manacore/shared-ui';
import { creditsService } from '$lib/api/credits';
import type { CreditBalance, CreditTransaction } from '$lib/api/credits';
import { PageHeader } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
import { tilingStore } from '$lib/stores/tiling.svelte';
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
import DashboardGrid from '$lib/components/dashboard/DashboardGrid.svelte';
import TilingLayout from '$lib/components/dashboard/TilingLayout.svelte';
import { collectLeaves } from '$lib/utils/tiling-tree';
import TilePanel from '$lib/components/dashboard/TilePanel.svelte';
let isMobile = $state(false);
onMount(async () => {
await dashboardStore.initialize();
await tilingStore.initialize();
isMobile = window.innerWidth < 768;
const handleResize = () => (isMobile = window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
function handleToggleEditing() {
dashboardStore.toggleEditing();
ManaCoreEvents.dashboardEditToggled(dashboardStore.isEditing);
tilingStore.toggleEditing();
ManaCoreEvents.dashboardEditToggled(tilingStore.isEditing);
}
const mobileLeaves = $derived(tilingStore.initialized ? collectLeaves(tilingStore.root) : []);
</script>
<div>
<div class="mb-6 flex items-center justify-between">
<div class="flex h-[calc(100vh-10rem)] flex-col">
<div class="mb-4 flex flex-shrink-0 items-center justify-between">
<PageHeader
title={$_('dashboard.title')}
description="{$_('dashboard.welcome')}, {authStore.user?.email || 'User'}"
size="lg"
/>
<button
type="button"
onclick={handleToggleEditing}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {dashboardStore.isEditing
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{#if dashboardStore.isEditing}
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 13l4 4L19 7" />
</svg>
{$_('dashboard.done')}
</span>
{:else}
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
{$_('dashboard.customize')}
</span>
<div class="flex items-center gap-2">
{#if tilingStore.isEditing}
<button
type="button"
onclick={() => tilingStore.resetToDefault()}
class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted/80"
>
Reset
</button>
{/if}
</button>
<button
type="button"
onclick={handleToggleEditing}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {tilingStore.isEditing
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{#if tilingStore.isEditing}
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 13l4 4L19 7" />
</svg>
{$_('dashboard.done')}
</span>
{:else}
<span class="flex items-center gap-2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
{$_('dashboard.customize')}
</span>
{/if}
</button>
</div>
</div>
<DashboardGrid />
{#if tilingStore.initialized}
<div class="min-h-0 flex-1">
{#if isMobile}
<!-- Mobile: stacked vertical layout -->
<div class="space-y-4 overflow-y-auto pb-8">
{#each mobileLeaves as leaf (leaf.id)}
<div class="h-72">
<TilePanel {leaf} />
</div>
{/each}
</div>
{:else}
<TilingLayout node={tilingStore.root} />
{/if}
</div>
{/if}
</div>