mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
5f9c2a600d
commit
1eb370eaaa
12 changed files with 996 additions and 83 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
38
apps/manacore/apps/web/src/lib/config/default-tiling.ts
Normal file
38
apps/manacore/apps/web/src/lib/config/default-tiling.ts
Normal 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(),
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
128
apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts
Normal file
128
apps/manacore/apps/web/src/lib/stores/tiling.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
35
apps/manacore/apps/web/src/lib/types/tiling.ts
Normal file
35
apps/manacore/apps/web/src/lib/types/tiling.ts
Normal 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;
|
||||
}
|
||||
137
apps/manacore/apps/web/src/lib/utils/tiling-tree.ts
Normal file
137
apps/manacore/apps/web/src/lib/utils/tiling-tree.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,39 +1,57 @@
|
|||
<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"
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
onclick={handleToggleEditing}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {dashboardStore.isEditing
|
||||
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 dashboardStore.isEditing}
|
||||
{#if tilingStore.isEditing}
|
||||
<span class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
|
|
@ -63,6 +81,22 @@
|
|||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DashboardGrid />
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue