1106 lines
34 KiB
TypeScript
1106 lines
34 KiB
TypeScript
import { getLayout, createLayout, updateLayout } from './client-layouts';
|
|
import { widgetRegistry } from '@/lib/widgetRegistry';
|
|
import { updatePage } from '@/modules/pages/client-pages';
|
|
|
|
export interface WidgetInstance {
|
|
id: string;
|
|
widgetId: string; // References the widget registry ID
|
|
props?: Record<string, any>;
|
|
order?: number;
|
|
// Flexible Container placement (only used inside FlexibleContainer)
|
|
rowId?: string;
|
|
column?: number;
|
|
// Tracks which snippet this widget was instantiated from
|
|
snippetId?: string;
|
|
}
|
|
|
|
export interface LayoutContainer {
|
|
id: string;
|
|
type: 'container';
|
|
columns: number;
|
|
gap: number;
|
|
widgets: WidgetInstance[];
|
|
children: LayoutContainer[];
|
|
order?: number;
|
|
settings?: {
|
|
collapsible?: boolean;
|
|
collapsed?: boolean;
|
|
title?: string;
|
|
showTitle?: boolean;
|
|
customClassName?: string;
|
|
enabled?: boolean;
|
|
};
|
|
}
|
|
|
|
// Flexible Container: row-based layout with adjustable columns
|
|
export interface ColumnDef {
|
|
width: number;
|
|
unit: 'fr' | 'px' | 'rem' | '%';
|
|
minWidth?: number;
|
|
}
|
|
|
|
export interface RowDef {
|
|
id: string;
|
|
columns: ColumnDef[];
|
|
gap?: number;
|
|
sizing?: 'constrained' | 'unconstrained';
|
|
cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[];
|
|
padding?: string; // Tailwind padding class e.g. 'p-2', 'p-4'
|
|
}
|
|
|
|
export interface FlexibleContainer {
|
|
id: string;
|
|
type: 'flex-container';
|
|
rows: RowDef[];
|
|
widgets: WidgetInstance[];
|
|
gap: number;
|
|
order?: number;
|
|
settings?: LayoutContainer['settings'];
|
|
}
|
|
|
|
export type AnyContainer = LayoutContainer | FlexibleContainer;
|
|
|
|
export interface PageLayout {
|
|
id: string;
|
|
name: string;
|
|
containers: AnyContainer[];
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
loadedBundles?: string[];
|
|
rootTemplate?: string;
|
|
}
|
|
|
|
export interface RootLayoutData {
|
|
pages: Record<string, PageLayout>;
|
|
version: string;
|
|
lastUpdated: number;
|
|
}
|
|
|
|
|
|
|
|
export class UnifiedLayoutManager {
|
|
private static readonly VERSION = '1.0.0';
|
|
|
|
// Type guards
|
|
static isLayoutContainer(c: AnyContainer): c is LayoutContainer {
|
|
return c.type === 'container';
|
|
}
|
|
|
|
static isFlexibleContainer(c: AnyContainer): c is FlexibleContainer {
|
|
return c.type === 'flex-container';
|
|
}
|
|
|
|
// Helper: get LayoutContainers only (for methods that only operate on old containers)
|
|
static getLayoutContainers(containers: AnyContainer[]): LayoutContainer[] {
|
|
return containers.filter((c): c is LayoutContainer => c.type === 'container');
|
|
}
|
|
|
|
// Generate unique IDs
|
|
static generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
static generateContainerId(): string {
|
|
return `container-${this.generateId()}`;
|
|
}
|
|
|
|
static generateWidgetId(): string {
|
|
return `widget-${this.generateId()}`;
|
|
}
|
|
|
|
static generateRowId(): string {
|
|
return `row-${this.generateId()}`;
|
|
}
|
|
|
|
// Create a standalone widget instance (for Command pattern)
|
|
static createWidgetInstance(widgetId: string): WidgetInstance {
|
|
let defaultProps = {};
|
|
const widgetDef = widgetRegistry.get(widgetId);
|
|
if (widgetDef && widgetDef.metadata.defaultProps) {
|
|
defaultProps = { ...widgetDef.metadata.defaultProps };
|
|
}
|
|
|
|
return {
|
|
id: this.generateWidgetId(),
|
|
widgetId,
|
|
props: defaultProps,
|
|
order: 0
|
|
};
|
|
}
|
|
|
|
// Get all widgets in a layout (recursive, supports both container types)
|
|
static getAllWidgets(layout: PageLayout): WidgetInstance[] {
|
|
const widgets: WidgetInstance[] = [];
|
|
const collect = (containers: AnyContainer[]) => {
|
|
containers.forEach(c => {
|
|
widgets.push(...c.widgets);
|
|
if (this.isLayoutContainer(c)) {
|
|
collect(c.children);
|
|
}
|
|
});
|
|
};
|
|
collect(layout.containers);
|
|
return widgets;
|
|
}
|
|
|
|
// Load root data from storage (database-only, no localStorage)
|
|
static async loadRootData(pageId?: string): Promise<RootLayoutData> {
|
|
if (!pageId) {
|
|
return { pages: {}, version: this.VERSION, lastUpdated: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const isPage = pageId.startsWith('page-');
|
|
const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-');
|
|
if (isPage) {
|
|
const actualId = pageId.replace('page-', '');
|
|
const { fetchPageDetailsById } = await import('@/modules/pages/client-pages');
|
|
const data = await fetchPageDetailsById(actualId);
|
|
|
|
if (data && data.page && data.page.content) {
|
|
let content = data.page.content;
|
|
if (typeof content === 'string') {
|
|
try { content = JSON.parse(content); } catch (e) { /* ignore */ }
|
|
}
|
|
// Ensure it has the structure we expect, or wrap it
|
|
// If content is just the PageLayout object
|
|
if ((content as any).id && (content as any).containers) {
|
|
return {
|
|
pages: { [pageId]: content as PageLayout },
|
|
version: this.VERSION,
|
|
lastUpdated: Date.now()
|
|
};
|
|
}
|
|
return content as RootLayoutData;
|
|
}
|
|
} else if (isLayout) {
|
|
const layoutId = pageId.replace(/^(layout-|tabs-)/, '');
|
|
try {
|
|
const { data } = await getLayout(layoutId);
|
|
const layoutJson = data?.layout_json;
|
|
|
|
if (layoutJson) {
|
|
return {
|
|
pages: { [pageId]: layoutJson as PageLayout },
|
|
version: this.VERSION,
|
|
lastUpdated: Date.now()
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.warn('Layout not found or failed to load:', layoutId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load layouts from database:', error);
|
|
}
|
|
|
|
// Return default structure
|
|
return {
|
|
pages: {},
|
|
version: this.VERSION,
|
|
lastUpdated: Date.now()
|
|
};
|
|
}
|
|
|
|
// Save root data to storage (database-only, no localStorage)
|
|
static async saveRootData(data: RootLayoutData, pageId?: string, metadata?: Record<string, any>): Promise<void> {
|
|
if (!pageId) return;
|
|
|
|
try {
|
|
const isPage = pageId.startsWith('page-');
|
|
const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-');
|
|
|
|
if (isPage) {
|
|
const actualId = pageId.replace('page-', '');
|
|
await updatePage(actualId, {
|
|
content: data,
|
|
updated_at: new Date().toISOString(),
|
|
...(metadata || {})
|
|
});
|
|
} else if (isLayout) {
|
|
const layoutJson = data.pages?.[pageId];
|
|
if (layoutJson) {
|
|
const layoutId = pageId.replace(/^(layout-|tabs-)/, '');
|
|
const layoutData = {
|
|
id: layoutId,
|
|
layout_json: layoutJson,
|
|
meta: metadata,
|
|
name: metadata?.title || `Layout ${pageId}`
|
|
};
|
|
|
|
// Upsert logic
|
|
try {
|
|
let exists = false;
|
|
try {
|
|
await getLayout(layoutId);
|
|
exists = true;
|
|
} catch (e) {
|
|
// Assuming 404 or failure means we should try create
|
|
}
|
|
|
|
if (exists) {
|
|
await updateLayout(layoutId, layoutData);
|
|
} else {
|
|
await createLayout(layoutData);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save layout via API', error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save layouts to database:', error);
|
|
}
|
|
}
|
|
|
|
|
|
// Manual save to API
|
|
static async saveToApi(data?: RootLayoutData): Promise<boolean> {
|
|
try {
|
|
const dataToSave = data || await this.loadRootData();
|
|
dataToSave.lastUpdated = Date.now();
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Failed to save layouts to API:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get or create page layout (database-only)
|
|
static async getPageLayout(pageId: string, defaultName?: string): Promise<PageLayout> {
|
|
// Load from database
|
|
const rootData = await this.loadRootData(pageId);
|
|
|
|
if (!rootData.pages[pageId]) {
|
|
// Create new page layout
|
|
rootData.pages[pageId] = {
|
|
id: pageId,
|
|
name: defaultName || `Page ${pageId}`,
|
|
containers: [
|
|
{
|
|
id: this.generateContainerId(),
|
|
type: 'container',
|
|
columns: 1,
|
|
gap: 16,
|
|
widgets: [],
|
|
children: [],
|
|
order: 0
|
|
}
|
|
],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now()
|
|
};
|
|
|
|
// Save the new layout to database immediately
|
|
await this.savePageLayout(rootData.pages[pageId]);
|
|
}
|
|
|
|
return rootData.pages[pageId];
|
|
}
|
|
|
|
// Save page layout (database-only)
|
|
static async savePageLayout(layout: PageLayout, metadata?: Record<string, any>): Promise<void> {
|
|
layout.updatedAt = Date.now();
|
|
|
|
// Create a minimal RootLayoutData for just this page
|
|
const pageData: RootLayoutData = {
|
|
pages: { [layout.id]: layout },
|
|
version: this.VERSION,
|
|
lastUpdated: Date.now()
|
|
};
|
|
|
|
// Save directly to database
|
|
await this.saveRootData(pageData, layout.id, metadata);
|
|
}
|
|
|
|
// Find container by ID in layout (recursive, supports both container types)
|
|
static findContainer(containers: AnyContainer[], containerId: string): AnyContainer | null {
|
|
for (const container of containers) {
|
|
if (container.id === containerId) {
|
|
return container;
|
|
}
|
|
if (this.isLayoutContainer(container)) {
|
|
const found = this.findContainer(container.children, containerId);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Find a LayoutContainer specifically (narrows type)
|
|
static findLayoutContainer(containers: AnyContainer[], containerId: string): LayoutContainer | null {
|
|
const found = this.findContainer(containers, containerId);
|
|
return found && this.isLayoutContainer(found) ? found : null;
|
|
}
|
|
|
|
// Find a FlexibleContainer specifically (narrows type)
|
|
static findFlexContainer(containers: AnyContainer[], containerId: string): FlexibleContainer | null {
|
|
const found = this.findContainer(containers, containerId);
|
|
return found && this.isFlexibleContainer(found) ? found : null;
|
|
}
|
|
|
|
// Calculate insertion index based on column target
|
|
static calculateWidgetInsertionIndex(container: LayoutContainer, targetColumn?: number): number {
|
|
let order = container.widgets.length;
|
|
|
|
if (targetColumn !== undefined && targetColumn >= 0 && targetColumn < container.columns) {
|
|
const occupiedPositionsInColumn = container.widgets
|
|
.map((_, index) => index)
|
|
.filter(index => index % container.columns === targetColumn);
|
|
|
|
let targetRow = 0;
|
|
while (occupiedPositionsInColumn.includes(targetRow * container.columns + targetColumn)) {
|
|
targetRow++;
|
|
}
|
|
|
|
order = targetRow * container.columns + targetColumn;
|
|
|
|
if (order > container.widgets.length) {
|
|
order = container.widgets.length;
|
|
}
|
|
}
|
|
return order;
|
|
}
|
|
|
|
// Add widget to specific container
|
|
static addWidgetToContainer(
|
|
layout: PageLayout,
|
|
containerId: string,
|
|
widgetId: string,
|
|
targetColumn?: number
|
|
): WidgetInstance {
|
|
const container = this.findContainer(layout.containers, containerId);
|
|
|
|
if (!container) {
|
|
throw new Error(`Container ${containerId} not found`);
|
|
}
|
|
|
|
let order = container.widgets.length;
|
|
|
|
// Column targeting only applies to LayoutContainer
|
|
if (this.isLayoutContainer(container) && targetColumn !== undefined && targetColumn >= 0 && targetColumn < container.columns) {
|
|
const occupiedPositionsInColumn = container.widgets
|
|
.map((_, index) => index)
|
|
.filter(index => index % container.columns === targetColumn);
|
|
|
|
let targetRow = 0;
|
|
while (occupiedPositionsInColumn.includes(targetRow * container.columns + targetColumn)) {
|
|
targetRow++;
|
|
}
|
|
|
|
order = targetRow * container.columns + targetColumn;
|
|
|
|
if (order > container.widgets.length) {
|
|
order = container.widgets.length;
|
|
}
|
|
}
|
|
|
|
// Get default props from registry if available
|
|
let defaultProps = {};
|
|
const widgetDef = widgetRegistry.get(widgetId);
|
|
if (widgetDef && widgetDef.metadata.defaultProps) {
|
|
defaultProps = { ...widgetDef.metadata.defaultProps };
|
|
}
|
|
|
|
const newWidget: WidgetInstance = {
|
|
id: this.generateWidgetId(),
|
|
widgetId,
|
|
props: defaultProps,
|
|
order
|
|
};
|
|
|
|
container.widgets.splice(order, 0, newWidget);
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
|
|
return newWidget;
|
|
}
|
|
|
|
|
|
|
|
// Remove widget from container
|
|
static removeWidgetFromContainer(layout: PageLayout, widgetInstanceId: string): boolean {
|
|
const removeFromContainers = (containers: AnyContainer[]): boolean => {
|
|
for (const container of containers) {
|
|
const widgetIndex = container.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (widgetIndex >= 0) {
|
|
container.widgets.splice(widgetIndex, 1);
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(container) && removeFromContainers(container.children)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return removeFromContainers(layout.containers);
|
|
}
|
|
|
|
|
|
|
|
// Update widget props
|
|
static updateWidgetProps(layout: PageLayout, widgetInstanceId: string, props: Record<string, any>): boolean {
|
|
const findAndUpdateWidget = (containers: AnyContainer[]): boolean => {
|
|
for (const container of containers) {
|
|
const widget = container.widgets.find(w => w.id === widgetInstanceId);
|
|
if (widget) {
|
|
widget.props = { ...widget.props, ...props };
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(container) && findAndUpdateWidget(container.children)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return findAndUpdateWidget(layout.containers);
|
|
}
|
|
|
|
// Rename widget (change instance ID)
|
|
static renameWidget(layout: PageLayout, oldId: string, newId: string): boolean {
|
|
// 1. Check if newId already exists
|
|
const findWidget = (containers: AnyContainer[]): boolean => {
|
|
for (const container of containers) {
|
|
if (container.widgets.some(w => w.id === newId)) {
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(container) && findWidget(container.children)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (findWidget(layout.containers)) {
|
|
console.warn(`Cannot rename widget: ID ${newId} already exists`);
|
|
return false;
|
|
}
|
|
|
|
// 2. Find and rename
|
|
const findAndRename = (containers: AnyContainer[]): boolean => {
|
|
for (const container of containers) {
|
|
const widget = container.widgets.find(w => w.id === oldId);
|
|
if (widget) {
|
|
widget.id = newId;
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(container) && findAndRename(container.children)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return findAndRename(layout.containers);
|
|
}
|
|
|
|
|
|
|
|
// Update container columns (LayoutContainer only)
|
|
static updateContainerColumns(layout: PageLayout, containerId: string, columns: number): boolean {
|
|
const container = this.findLayoutContainer(layout.containers, containerId);
|
|
|
|
if (container) {
|
|
container.columns = Math.max(1, Math.min(12, columns));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
// Update container gap
|
|
static async updateContainerGap(pageId: string, containerId: string, gap: number): Promise<void> {
|
|
const layout = await this.getPageLayout(pageId);
|
|
const container = this.findContainer(layout.containers, containerId);
|
|
|
|
if (container) {
|
|
container.gap = Math.max(0, gap);
|
|
await this.savePageLayout(layout);
|
|
}
|
|
}
|
|
|
|
// Update container settings
|
|
static updateContainerSettings(layout: PageLayout, containerId: string, settings: Partial<LayoutContainer['settings']>): boolean {
|
|
const container = this.findContainer(layout.containers, containerId);
|
|
|
|
if (container) {
|
|
container.settings = { ...container.settings, ...settings };
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
// Add new container
|
|
static addContainer(
|
|
layout: PageLayout,
|
|
parentContainerId?: string,
|
|
columns: number = 1
|
|
): LayoutContainer {
|
|
const newContainer: LayoutContainer = {
|
|
id: this.generateContainerId(),
|
|
type: 'container',
|
|
columns,
|
|
gap: 16,
|
|
widgets: [],
|
|
children: [],
|
|
order: 0
|
|
};
|
|
|
|
if (parentContainerId) {
|
|
const parentContainer = this.findLayoutContainer(layout.containers, parentContainerId);
|
|
if (parentContainer) {
|
|
newContainer.order = parentContainer.children.length;
|
|
parentContainer.children.push(newContainer);
|
|
}
|
|
} else {
|
|
newContainer.order = layout.containers.length;
|
|
layout.containers.push(newContainer);
|
|
}
|
|
|
|
return newContainer;
|
|
}
|
|
|
|
// Add new FlexibleContainer
|
|
static addFlexibleContainer(
|
|
layout: PageLayout,
|
|
): FlexibleContainer {
|
|
const newContainer: FlexibleContainer = {
|
|
id: this.generateContainerId(),
|
|
type: 'flex-container',
|
|
rows: [{
|
|
id: this.generateRowId(),
|
|
columns: [{ width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }],
|
|
sizing: 'constrained',
|
|
}],
|
|
widgets: [],
|
|
gap: 16,
|
|
order: layout.containers.length
|
|
};
|
|
|
|
layout.containers.push(newContainer);
|
|
return newContainer;
|
|
}
|
|
|
|
|
|
|
|
// Remove container
|
|
static removeContainer(layout: PageLayout, containerId: string, allowRemoveLastContainer: boolean = false): boolean {
|
|
// Don't allow removing the last root container (unless explicitly allowed)
|
|
if (!allowRemoveLastContainer && layout.containers.length === 1 && layout.containers[0].id === containerId) {
|
|
return false;
|
|
}
|
|
|
|
// Remove from root containers
|
|
const originalLength = layout.containers.length;
|
|
layout.containers = layout.containers.filter(c => c.id !== containerId);
|
|
|
|
// Remove from nested containers (recursive)
|
|
const removeFromChildren = (containers: AnyContainer[]) => {
|
|
containers.forEach(container => {
|
|
if (this.isLayoutContainer(container)) {
|
|
container.children = container.children.filter(c => c.id !== containerId);
|
|
removeFromChildren(container.children);
|
|
}
|
|
});
|
|
};
|
|
|
|
removeFromChildren(layout.containers);
|
|
|
|
// Return true if something was removed
|
|
return layout.containers.length !== originalLength;
|
|
}
|
|
|
|
// Move a root-level container up or down
|
|
static moveRootContainer(layout: PageLayout, containerId: string, direction: 'up' | 'down'): boolean {
|
|
const containerIndex = layout.containers.findIndex(c => c.id === containerId);
|
|
|
|
if (containerIndex === -1) {
|
|
return false; // Container not found at root level
|
|
}
|
|
|
|
const targetIndex = direction === 'up' ? containerIndex - 1 : containerIndex + 1;
|
|
|
|
// Check if the move is within the bounds of the array
|
|
if (targetIndex < 0 || targetIndex >= layout.containers.length) {
|
|
return false;
|
|
}
|
|
|
|
// Swap the container with its target
|
|
const temp = layout.containers[containerIndex];
|
|
layout.containers[containerIndex] = layout.containers[targetIndex];
|
|
layout.containers[targetIndex] = temp;
|
|
|
|
// After swapping, update the order property for all root containers
|
|
layout.containers.forEach((container, index) => {
|
|
container.order = index;
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
// Move widget in any direction within its container (grid-aware)
|
|
static moveWidgetInContainer(
|
|
layout: PageLayout,
|
|
widgetInstanceId: string,
|
|
direction: 'up' | 'down' | 'left' | 'right'
|
|
): boolean {
|
|
|
|
// Find the widget and its container (LayoutContainer only — grid movement is LC-specific)
|
|
const findWidgetContainer = (containers: AnyContainer[]): LayoutContainer | null => {
|
|
for (const container of containers) {
|
|
if (this.isLayoutContainer(container)) {
|
|
if (container.widgets.some(w => w.id === widgetInstanceId)) {
|
|
return container;
|
|
}
|
|
const found = findWidgetContainer(container.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const container = findWidgetContainer(layout.containers);
|
|
if (!container) return false;
|
|
|
|
const widgetIndex = container.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (widgetIndex === -1) return false;
|
|
|
|
const { columns } = container;
|
|
const totalWidgets = container.widgets.length;
|
|
|
|
let targetIndex = -1;
|
|
|
|
switch (direction) {
|
|
case 'up':
|
|
targetIndex = widgetIndex - columns;
|
|
break;
|
|
case 'down':
|
|
targetIndex = widgetIndex + columns;
|
|
break;
|
|
case 'left':
|
|
// Prevent wrapping from first to last column
|
|
if (widgetIndex % columns !== 0) {
|
|
targetIndex = widgetIndex - 1;
|
|
}
|
|
break;
|
|
case 'right':
|
|
// Prevent wrapping from last to first column
|
|
if ((widgetIndex + 1) % columns !== 0) {
|
|
targetIndex = widgetIndex + 1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Check if target is valid
|
|
if (targetIndex >= 0 && targetIndex < totalWidgets) {
|
|
// Perform the swap
|
|
[container.widgets[widgetIndex], container.widgets[targetIndex]] =
|
|
[container.widgets[targetIndex], container.widgets[widgetIndex]];
|
|
|
|
// Update order values
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Move widget directionally within a FlexibleContainer
|
|
static moveFlexWidget(
|
|
layout: PageLayout,
|
|
widgetInstanceId: string,
|
|
direction: 'up' | 'down' | 'left' | 'right'
|
|
): boolean {
|
|
// Find the widget and its flex container
|
|
const findWidgetFlexContainer = (containers: AnyContainer[]): FlexibleContainer | null => {
|
|
for (const container of containers) {
|
|
if (this.isFlexibleContainer(container)) {
|
|
if (container.widgets.some(w => w.id === widgetInstanceId)) {
|
|
return container;
|
|
}
|
|
}
|
|
if (this.isLayoutContainer(container)) {
|
|
const found = findWidgetFlexContainer(container.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const container = findWidgetFlexContainer(layout.containers);
|
|
if (!container) return false;
|
|
|
|
const widget = container.widgets.find(w => w.id === widgetInstanceId);
|
|
if (!widget || !widget.rowId || widget.column === undefined) return false;
|
|
|
|
const rowIndex = container.rows.findIndex(r => r.id === widget.rowId);
|
|
if (rowIndex === -1) return false;
|
|
|
|
const currentRow = container.rows[rowIndex];
|
|
|
|
switch (direction) {
|
|
case 'left': {
|
|
if (widget.column <= 0) return false;
|
|
widget.column -= 1;
|
|
return true;
|
|
}
|
|
case 'right': {
|
|
if (widget.column >= currentRow.columns.length - 1) return false;
|
|
widget.column += 1;
|
|
return true;
|
|
}
|
|
case 'up': {
|
|
if (rowIndex <= 0) return false;
|
|
const targetRow = container.rows[rowIndex - 1];
|
|
// Clamp column to target row's column count
|
|
widget.rowId = targetRow.id;
|
|
widget.column = Math.min(widget.column, targetRow.columns.length - 1);
|
|
return true;
|
|
}
|
|
case 'down': {
|
|
if (rowIndex >= container.rows.length - 1) return false;
|
|
const targetRow = container.rows[rowIndex + 1];
|
|
widget.rowId = targetRow.id;
|
|
widget.column = Math.min(widget.column, targetRow.columns.length - 1);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Drag-and-drop: move a widget to a specific target location (cross-container)
|
|
// Returns the original position for undo, or null if the move failed.
|
|
static moveWidgetToTarget(
|
|
layout: PageLayout,
|
|
widgetInstanceId: string,
|
|
target: { containerId: string; index?: number; rowId?: string; column?: number }
|
|
): { containerId: string; index: number; rowId?: string; column?: number } | null {
|
|
// Find widget and source container
|
|
let sourceContainer: AnyContainer | null = null;
|
|
let widget: WidgetInstance | null = null;
|
|
let sourceIndex = -1;
|
|
|
|
const findWidget = (containers: AnyContainer[]): boolean => {
|
|
for (const c of containers) {
|
|
const idx = c.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (idx >= 0) {
|
|
sourceContainer = c;
|
|
widget = c.widgets[idx];
|
|
sourceIndex = idx;
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(c) && findWidget(c.children)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (!findWidget(layout.containers) || !widget || !sourceContainer) return null;
|
|
|
|
// Capture original position for undo
|
|
const original: { containerId: string; index: number; rowId?: string; column?: number } = {
|
|
containerId: sourceContainer.id,
|
|
index: sourceIndex,
|
|
rowId: (widget as WidgetInstance).rowId,
|
|
column: (widget as WidgetInstance).column,
|
|
};
|
|
|
|
// Remove from source
|
|
sourceContainer.widgets.splice(sourceIndex, 1);
|
|
sourceContainer.widgets.forEach((w, i) => w.order = i);
|
|
|
|
// Find target container
|
|
const targetContainer = this.findContainer(layout.containers, target.containerId);
|
|
if (!targetContainer) return null;
|
|
|
|
// Assign flex placement or clear it
|
|
const w = widget as WidgetInstance;
|
|
if (target.rowId !== undefined) {
|
|
w.rowId = target.rowId;
|
|
w.column = target.column ?? 0;
|
|
} else {
|
|
// Moving to a regular container — clear flex props
|
|
delete w.rowId;
|
|
delete w.column;
|
|
}
|
|
|
|
// Insert at target
|
|
const insertIndex = target.index !== undefined
|
|
? Math.min(target.index, targetContainer.widgets.length)
|
|
: targetContainer.widgets.length;
|
|
targetContainer.widgets.splice(insertIndex, 0, w);
|
|
targetContainer.widgets.forEach((wi, i) => wi.order = i);
|
|
|
|
return original;
|
|
}
|
|
|
|
// Get grid position of a widget (row, column)
|
|
static getWidgetGridPosition(
|
|
layout: PageLayout,
|
|
widgetInstanceId: string
|
|
): { row: number; col: number; containerColumns: number } | null {
|
|
// Find the widget and its container (LayoutContainer only)
|
|
const findWidgetContainer = (containers: AnyContainer[]): LayoutContainer | null => {
|
|
for (const container of containers) {
|
|
if (this.isLayoutContainer(container)) {
|
|
if (container.widgets.some(w => w.id === widgetInstanceId)) {
|
|
return container;
|
|
}
|
|
const found = findWidgetContainer(container.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const container = findWidgetContainer(layout.containers);
|
|
if (!container) return null;
|
|
|
|
const widgetIndex = container.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (widgetIndex === -1) return null;
|
|
|
|
const row = Math.floor(widgetIndex / container.columns);
|
|
const col = widgetIndex % container.columns;
|
|
|
|
return { row, col, containerColumns: container.columns };
|
|
}
|
|
|
|
// Move widget to specific grid position (row, column)
|
|
static moveWidgetToGridPosition(
|
|
layout: PageLayout,
|
|
widgetInstanceId: string,
|
|
targetRow: number,
|
|
targetCol: number
|
|
): boolean {
|
|
// Find the widget and its container (LayoutContainer only)
|
|
const findWidgetContainer = (containers: AnyContainer[]): LayoutContainer | null => {
|
|
for (const container of containers) {
|
|
if (this.isLayoutContainer(container)) {
|
|
if (container.widgets.some(w => w.id === widgetInstanceId)) {
|
|
return container;
|
|
}
|
|
const found = findWidgetContainer(container.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const container = findWidgetContainer(layout.containers);
|
|
if (!container) return false;
|
|
|
|
const widgetIndex = container.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (widgetIndex === -1) return false;
|
|
|
|
// Validate target position
|
|
if (targetRow < 0 || targetCol < 0 || targetCol >= container.columns) {
|
|
return false;
|
|
}
|
|
|
|
const targetIndex = targetRow * container.columns + targetCol;
|
|
|
|
// Don't move to the same position
|
|
if (targetIndex === widgetIndex) {
|
|
return false;
|
|
}
|
|
|
|
// For positions beyond current widget array, we can extend the array
|
|
// But we need to ensure we don't create gaps
|
|
if (targetIndex >= container.widgets.length) {
|
|
// Only allow if it's the next logical position (no gaps)
|
|
if (targetIndex > container.widgets.length) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Move the widget
|
|
const widget = container.widgets.splice(widgetIndex, 1)[0];
|
|
|
|
if (targetIndex >= container.widgets.length) {
|
|
// Append at the end
|
|
container.widgets.push(widget);
|
|
} else {
|
|
// Insert at target position
|
|
container.widgets.splice(targetIndex, 0, widget);
|
|
}
|
|
|
|
// Update order values
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
// Move widget within container or between containers (existing method for drag-drop)
|
|
static async moveWidget(
|
|
pageId: string,
|
|
widgetInstanceId: string,
|
|
targetContainerId: string,
|
|
newOrder: number
|
|
): Promise<void> {
|
|
const layout = await this.getPageLayout(pageId);
|
|
|
|
// First, remove widget from current location
|
|
let widget: WidgetInstance | null = null;
|
|
const removeFromContainers = (containers: AnyContainer[]): boolean => {
|
|
for (const container of containers) {
|
|
const widgetIndex = container.widgets.findIndex(w => w.id === widgetInstanceId);
|
|
if (widgetIndex >= 0) {
|
|
widget = container.widgets.splice(widgetIndex, 1)[0];
|
|
// Reorder remaining widgets
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
return true;
|
|
}
|
|
if (this.isLayoutContainer(container) && removeFromContainers(container.children)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const wasRemoved = removeFromContainers(layout.containers);
|
|
if (wasRemoved && widget) {
|
|
// Add to target container
|
|
const targetContainer = this.findContainer(layout.containers, targetContainerId);
|
|
if (targetContainer) {
|
|
const widgetToMove = widget as WidgetInstance;
|
|
widgetToMove.order = Math.max(0, Math.min(newOrder, targetContainer.widgets.length));
|
|
targetContainer.widgets.splice(widgetToMove.order, 0, widgetToMove);
|
|
// Reorder all widgets in target container
|
|
targetContainer.widgets.forEach((w, i) => w.order = i);
|
|
}
|
|
}
|
|
|
|
await this.savePageLayout(layout);
|
|
}
|
|
|
|
// Get container widget count (including nested)
|
|
static getContainerWidgetCount(container: AnyContainer): number {
|
|
let count = container.widgets.length;
|
|
if (this.isLayoutContainer(container)) {
|
|
container.children.forEach(child => {
|
|
count += this.getContainerWidgetCount(child);
|
|
});
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// Export layout to JSON
|
|
// Export layout to JSON
|
|
static async exportPageLayout(pageId: string): Promise<string> {
|
|
const layout = await this.getPageLayout(pageId);
|
|
return JSON.stringify(layout, null, 2);
|
|
}
|
|
|
|
// Parse layout from JSON (Pure, no side effects)
|
|
static parseLayoutJSON(jsonData: string, targetPageId?: string): PageLayout {
|
|
try {
|
|
const parsedData = JSON.parse(jsonData) as PageLayout;
|
|
|
|
// Basic validation
|
|
if (!parsedData.id || !parsedData.containers) {
|
|
throw new Error('Invalid layout data for import.');
|
|
}
|
|
|
|
// Check for widget count for debugging
|
|
let widgetCount = 0;
|
|
const countWidgets = (containers: AnyContainer[]) => {
|
|
containers.forEach(c => {
|
|
widgetCount += c.widgets.length;
|
|
if (this.isLayoutContainer(c)) countWidgets(c.children);
|
|
});
|
|
};
|
|
countWidgets(parsedData.containers);
|
|
|
|
// Sanitization: Ensure all widget IDs are unique within the imported layout
|
|
const seenIds = new Set<string>();
|
|
const sanitizeIds = (containers: AnyContainer[]) => {
|
|
containers.forEach(container => {
|
|
container.widgets.forEach(widget => {
|
|
if (seenIds.has(widget.id)) {
|
|
const newId = this.generateWidgetId();
|
|
console.warn(`[ULM] Found duplicate widget ID ${widget.id} during import. Regenerating to ${newId}`);
|
|
widget.id = newId;
|
|
}
|
|
seenIds.add(widget.id);
|
|
});
|
|
if (this.isLayoutContainer(container)) sanitizeIds(container.children);
|
|
});
|
|
};
|
|
sanitizeIds(parsedData.containers);
|
|
|
|
// Ensure the ID in the JSON matches the target pageId if provided
|
|
if (targetPageId && parsedData.id !== targetPageId) {
|
|
console.warn(`[ULM] Mismatch between target pageId (${targetPageId}) and imported ID (${parsedData.id}). Overwriting ID.`);
|
|
parsedData.id = targetPageId;
|
|
}
|
|
|
|
return parsedData;
|
|
} catch (e) {
|
|
console.error("Failed to parse layout JSON", e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Import layout from JSON (Saves to DB)
|
|
static async importPageLayout(pageId: string, jsonData: string): Promise<PageLayout> {
|
|
try {
|
|
const parsedData = this.parseLayoutJSON(jsonData, pageId);
|
|
|
|
// Load the existing root data
|
|
const rootData = await this.loadRootData(pageId);
|
|
|
|
// Update the specific page layout
|
|
rootData.pages[pageId] = parsedData;
|
|
rootData.lastUpdated = Date.now();
|
|
|
|
// Save the updated root data
|
|
await this.saveRootData(rootData, pageId);
|
|
|
|
return parsedData;
|
|
} catch (error) {
|
|
console.error(`[ULM] Error importing page layout for ${pageId}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Clean up invalid widgets from layout
|
|
static async cleanupInvalidWidgets(pageId: string, validWidgetIds: Set<string>): Promise<void> {
|
|
const layout = await this.getPageLayout(pageId);
|
|
let hasChanges = false;
|
|
|
|
const cleanContainers = (containers: AnyContainer[]) => {
|
|
containers.forEach(container => {
|
|
const originalLength = container.widgets.length;
|
|
container.widgets = container.widgets.filter(widget => validWidgetIds.has(widget.widgetId));
|
|
if (container.widgets.length !== originalLength) {
|
|
hasChanges = true;
|
|
container.widgets.forEach((w, i) => w.order = i);
|
|
}
|
|
if (this.isLayoutContainer(container)) cleanContainers(container.children);
|
|
});
|
|
};
|
|
|
|
cleanContainers(layout.containers);
|
|
|
|
if (hasChanges) {
|
|
await this.savePageLayout(layout);
|
|
}
|
|
}
|
|
|
|
// Convenience methods for common layouts
|
|
static async getPlaygroundLayout(): Promise<PageLayout> {
|
|
return await this.getPageLayout('playground-layout', 'Playground Layout');
|
|
}
|
|
|
|
static async getDashboardLayout(): Promise<PageLayout> {
|
|
return await this.getPageLayout('dashboard-layout', 'Dashboard Layout');
|
|
}
|
|
} |