mono/packages/ui/src/components/hmi/LayoutContainer.tsx

661 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Plus, Minus, Grid3X3, Trash2, Settings, ArrowUp, ArrowDown } from 'lucide-react';
import { LayoutContainer as LayoutContainerType, WidgetInstance } from '@/lib/unifiedLayoutManager';
import { widgetRegistry } from '@/lib/widgetRegistry';
import { WidgetSettingsManager } from '@/components/widgets/WidgetSettingsManager';
import { WidgetMovementControls } from '@/components/widgets/WidgetMovementControls';
import { useLayout } from '@/contexts/LayoutContext';
import CollapsibleSection from '@/components/CollapsibleSection';
import { ContainerSettingsManager } from '@/components/containers/ContainerSettingsManager';
interface LayoutContainerProps {
container: LayoutContainerType;
isEditMode: boolean;
pageId: string;
selectedContainerId?: string | null;
onSelect?: (containerId: string) => void;
onAddWidget?: (containerId: string, targetColumn?: number) => void;
onRemoveWidget?: (widgetInstanceId: string) => void;
onMoveWidget?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
onUpdateColumns?: (containerId: string, columns: number) => void;
onUpdateSettings?: (containerId: string, settings: Partial<LayoutContainerType['settings']>) => void;
onAddContainer?: (parentContainerId: string) => void;
onRemoveContainer?: (containerId: string) => void;
onMoveContainer?: (containerId: string, direction: 'up' | 'down') => void;
canMoveContainerUp?: boolean;
canMoveContainerDown?: boolean;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
depth?: number;
isCompactMode?: boolean;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null;
}
const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
container,
isEditMode,
pageId,
selectedContainerId,
onSelect,
onAddWidget,
onRemoveWidget,
onMoveWidget,
onUpdateColumns,
onUpdateSettings,
onAddContainer,
onRemoveContainer,
onMoveContainer,
canMoveContainerUp,
canMoveContainerDown,
selectedWidgetId,
onSelectWidget,
depth = 0,
isCompactMode = false,
editingWidgetId,
onEditWidget,
newlyAddedWidgetId,
}) => {
const maxDepth = 3; // Limit nesting depth
const canNest = depth < maxDepth;
const isSelected = selectedContainerId === container.id;
const [showContainerSettings, setShowContainerSettings] = useState(false);
// Generate responsive grid classes based on container.columns
const getGridClasses = (columns: number) => {
const baseClass = "grid gap-4"; // Always grid with gap
// Mobile: always 1 column, Desktop: respect container.columns
switch (columns) {
case 1: return `${baseClass} grid-cols-1`;
case 2: return `${baseClass} grid-cols-1 md:grid-cols-2`;
case 3: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3`;
case 4: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-4`;
case 5: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5`;
case 6: return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6`;
default:
// For 7+ columns, use a more conservative approach
return `${baseClass} grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-${Math.min(columns, 12)}`;
}
};
const gridClasses = getGridClasses(container.columns);
// Extract container content rendering logic
const renderContainerContent = () => (
<>
{/* Grid Column Indicators (only in edit mode when selected) */}
{isEditMode && isSelected && container.widgets.length === 0 && container.children.length === 0 && (
<>
{Array.from({ length: container.columns }, (_, i) => (
<div
key={i}
className="min-h-[80px] flex items-center justify-center text-blue-500 dark:text-blue-400 text-sm cursor-pointer hover:bg-blue-100/20 dark:hover:bg-blue-800/20 transition-colors"
onDoubleClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, i);
}}
title={`Double-click to add widget to column ${i + 1}`}
>
Col {i + 1}
</div>
))}
</>
)}
{/* Render Widgets */}
{container.widgets
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((widget, index) => (
<WidgetItem
key={widget.id}
widget={widget}
isEditMode={isEditMode}
pageId={pageId}
isSelected={selectedWidgetId === widget.id}
onSelect={() => onSelectWidget?.(widget.id)}
canMoveUp={index > 0}
canMoveDown={index < container.widgets.length - 1}
onRemove={onRemoveWidget}
onMove={onMoveWidget}
isEditing={editingWidgetId === widget.id}
onEditWidget={onEditWidget}
/>
))}
{/* Add Widget Buttons - one per column for non-empty containers (in edit mode) */}
{isEditMode && container.widgets.length > 0 && container.children.length === 0 && (
<>
{Array.from({ length: container.columns }, (_, colIndex) => (
<div
key={`add-widget-${colIndex}`}
className="flex items-center justify-center min-h-[60px] rounded-lg hover:border-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
onDoubleClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
title={`Click to add widget to column ${colIndex + 1}`}
>
<div className="text-center text-slate-500 dark:text-slate-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors">
<Plus className="h-5 w-5 mx-auto mb-1" />
<p className="text-xs">Add Widget</p>
{container.columns > 1 && <p className="text-xs opacity-60">Col {colIndex + 1}</p>}
</div>
</div>
))}
</>
)}
{/* Render Nested Containers */}
{container.children
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((childContainer) => (
<div key={childContainer.id} className="col-span-full">
<LayoutContainer
container={childContainer}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainerId}
onSelect={onSelect}
onAddWidget={onAddWidget}
onRemoveWidget={onRemoveWidget}
onMoveWidget={onMoveWidget}
onUpdateColumns={onUpdateColumns}
onUpdateSettings={onUpdateSettings}
onAddContainer={onAddContainer}
onRemoveContainer={onRemoveContainer}
onMoveContainer={onMoveContainer}
canMoveContainerUp={canMoveContainerUp}
canMoveContainerDown={canMoveContainerDown}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
depth={depth + 1}
isCompactMode={isCompactMode}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
/>
</div>
))}
{/* Empty State - only show when not showing column indicators */}
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
<div
className={cn(
"col-span-full flex items-center justify-center min-h-[80px] text-slate-500 dark:text-slate-400",
isEditMode && "cursor-pointer hover:bg-slate-100/20 dark:hover:bg-slate-800/20 transition-colors"
)}
onDoubleClick={isEditMode ? (e) => {
e.stopPropagation();
onSelect?.(container.id);
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
} : undefined}
title={isEditMode ? "Double-click to add widget" : undefined}
>
{isEditMode ? (
<div className="text-center">
<Plus className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Double-click to add widgets</p>
</div>
) : (
<p className="text-sm"></p>
)}
</div>
)}
</>
);
return (
<div className="space-y-0">
{/* Edit Mode Controls */}
{isEditMode && (
<div className={cn(
"text-white px-2 sm:px-3 py-1 rounded-t-lg text-xs overflow-hidden",
isSelected ? "bg-blue-500" : "bg-slate-500"
)}>
{/* Responsive layout: title and buttons wrap on small screens */}
<div className="flex items-center justify-between gap-x-2 gap-y-1 min-w-0 flex-wrap">
<div className="flex items-center gap-1 min-w-0 flex-grow">
<Grid3X3 className="h-3 w-3 shrink-0" />
<span className="truncate text-xs font-semibold">
{container.settings?.showTitle && container.settings?.title
? container.settings.title
: `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
{(container.settings?.collapsible || container.settings?.showTitle) && (
<span className="ml-1 opacity-75 font-normal"></span>
)}
</span>
</div>
{/* Minimalist button row - wraps and justifies to the end */}
<div className="flex items-center gap-0.5 flex-wrap justify-end">
{/* Move controls for root containers */}
{depth === 0 && (
<div className="flex items-center gap-0.5 mr-1 border-r border-white/20 pr-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMoveContainer?.(container.id, 'up');
}}
disabled={!canMoveContainerUp}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Move container up"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMoveContainer?.(container.id, 'down');
}}
disabled={!canMoveContainerDown}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Move container down"
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
)}
{/* Column controls */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateColumns?.(container.id, container.columns - 1);
}}
disabled={container.columns <= 1}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Decrease columns"
>
<Minus className="h-3 w-3" />
</Button>
<span className="min-w-[12px] text-center text-xs font-medium px-1">{container.columns}</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateColumns?.(container.id, container.columns + 1);
}}
disabled={container.columns >= 12}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Increase columns"
>
<Plus className="h-3 w-3" />
</Button>
{/* Add widget button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0 ml-1"
title="Add widget"
>
<Plus className="h-3 w-3" />
</Button>
{/* Add nested container button */}
{canNest && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddContainer?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Add nested container"
>
<Grid3X3 className="h-3 w-3" />
</Button>
)}
{/* Container settings button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowContainerSettings(true);
}}
className="h-4 px-1 text-white hover:bg-white/20 shrink-0"
title="Container settings"
>
<Settings className="h-3 w-3" />
</Button>
{/* Remove container button */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemoveContainer?.(container.id);
}}
className="h-4 px-1 text-white hover:bg-red-400 shrink-0"
title="Remove container"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
{/* Container Content */}
<div
className={cn(
"relative transition-all duration-200 min-w-0 overflow-hidden",
isEditMode ? "rounded-b-lg" : "rounded-lg",
isEditMode && "hover:border-blue-300 cursor-pointer",
isSelected && isEditMode && "border-blue-500 bg-blue-50/20 dark:bg-blue-900/20",
!isSelected && isEditMode && "border-slate-300/50 dark:border-white/20",
!isEditMode && "border-transparent"
)}
onClick={(e) => {
e.stopPropagation();
if (isEditMode) {
onSelect?.(container.id);
}
}}
>
{container.settings?.collapsible ? (
<CollapsibleSection
title={
container.settings?.showTitle
? (container.settings?.title || `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`)
: `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`
}
initiallyOpen={!container.settings?.collapsed}
storageKey={`container-${container.id}-collapsed`}
className="border-0 rounded-none shadow-none bg-transparent"
minimal={true}
>
<div
className={cn(
isCompactMode ? "p-1 sm:p-2 min-h-[80px]" : isEditMode ? "p-2 min-h-[120px]" : "min-h-[120px]",
gridClasses,
isEditMode && isSelected && "bg-blue-50/10 dark:bg-blue-900/10"
)}
style={{
gap: `${container.gap}px`,
}}
>
{renderContainerContent()}
</div>
</CollapsibleSection>
) : (
<div>
{/* Title for non-collapsible containers */}
{container.settings?.showTitle && (
<div className="px-4 pt-3 pb-1 border-b border-slate-300/30 dark:border-white/10">
<h3 className="text-sm font-medium text-slate-700 dark:text-white">
{container.settings?.title || `Container (${container.columns} col${container.columns !== 1 ? 's' : ''})`}
</h3>
</div>
)}
<div
className={cn(
isCompactMode ? "p-1 sm:p-2 min-h-[80px]" : isEditMode ? "p-2 min-h-[120px]" : "min-h-[120px]",
gridClasses,
isEditMode && isSelected && "bg-blue-50/10 dark:bg-blue-900/10"
)}
style={{
gap: `${container.gap}px`,
}}
>
{renderContainerContent()}
</div>
</div>
)}
</div>
{/* Container Settings Dialog */}
{showContainerSettings && (
<ContainerSettingsManager
isOpen={showContainerSettings}
onClose={() => setShowContainerSettings(false)}
onSave={(settings) => {
onUpdateSettings?.(container.id, settings);
setShowContainerSettings(false);
}}
currentSettings={container.settings}
containerInfo={{
id: container.id,
columns: container.columns,
}}
/>
)}
</div>
);
};
interface WidgetItemProps {
widget: WidgetInstance;
isEditMode: boolean;
pageId: string;
canMoveUp: boolean;
canMoveDown: boolean;
onRemove?: (widgetInstanceId: string) => void;
onMove?: (widgetInstanceId: string, direction: 'up' | 'down' | 'left' | 'right') => void;
isSelected?: boolean;
onSelect?: () => void;
isEditing?: boolean;
onEditWidget?: (widgetId: string | null) => void;
isNew?: boolean;
}
const WidgetItem: React.FC<WidgetItemProps> = ({
widget,
isEditMode,
pageId,
canMoveUp,
canMoveDown,
onRemove,
onMove,
isSelected,
onSelect,
isEditing,
onEditWidget,
isNew
}) => {
const widgetDefinition = widgetRegistry.get(widget.widgetId);
const { updateWidgetProps, renameWidget } = useLayout();
// Internal state removed in favor of controlled state
// const [showSettingsModal, setShowSettingsModal] = useState(false);
// pageId is now passed as a prop from the parent component
if (!widgetDefinition) {
return (
<div className="relative group border-red-500 bg-red-100 dark:bg-red-900/20 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">
Widget "{widget.widgetId}" not found in registry
</p>
{isEditMode && (
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemove?.(widget.id);
}}
title="Remove invalid widget"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
}
const WidgetComponent = widgetDefinition.component;
const handleSettingsSave = async (settings: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, settings);
} catch (error) {
console.error('Failed to save widget settings:', error);
}
};
const handleSettingsCancel = () => {
if (isNew) {
// If it's a new widget and the user cancels settings, remove it
onRemove?.(widget.id);
}
onEditWidget?.(null); // Close the settings modal
};
// Handle Enabled State
const isEnabled = widget.props?.enabled !== false; // Default to true
if (!isEnabled && !isEditMode) {
return null;
}
return (
<div className={cn(
"relative group",
!isEnabled && "opacity-50 grayscale transition-all hover:grayscale-0"
)} id={`widget-item-${widget.id}`}>
{/* Edit Mode Controls */}
{isEditMode && (
<>
{/* Widget Info Overlay */}
<div
className="absolute top-0 left-0 right-0 bg-green-500/90 text-white text-xs px-2 py-1 rounded-t-lg z-10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onSelect?.();
}}
>
<div className="flex items-center justify-between">
<span>{widgetDefinition.metadata.name}</span>
<div className="flex items-center gap-1">
{/* Settings Gear Icon - For widgets with configSchema */}
{widgetDefinition.metadata.configSchema && (
<Button
size="icon"
variant="ghost"
className="h-4 w-4 text-white hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
// Open settings modal via prop
onEditWidget?.(widget.id);
}}
title="Widget settings"
>
<Settings className="h-2 w-2" />
</Button>
)}
{/* Remove Button */}
<Button
size="icon"
variant="ghost"
className="h-4 w-4 text-white hover:bg-white/20"
onClick={(e) => {
e.stopPropagation();
onRemove?.(widget.id);
}}
title="Remove widget"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
</div>
{/* Move Controls - Cross Pattern (Only show on hover or selection) */}
<div className={cn(
"absolute top-8 left-2 z-10 transition-opacity duration-200",
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<WidgetMovementControls
onMove={(direction) => onMove?.(widget.id, direction)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
</div>
</>
)}
{/* Widget Content - With selection wrapper */}
<div
className={cn(
"w-full bg-white dark:bg-slate-800 overflow-hidden rounded-lg transition-all duration-200",
widget.props?.customClassName || `widget-${widget.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
`widget-type-${widget.widgetId}`,
// Selection Visuals & Margins
isEditMode && "border-2",
isEditMode && isSelected ? "border-blue-500 ring-4 ring-blue-500/10 shadow-lg z-10" : "border-transparent",
isEditMode && !isSelected && "hover:border-blue-300 dark:hover:border-blue-700",
// Margin between header/content - applied via padding on this wrapper or margin on content?
// Using padding-top on wrapper to separate from title bar overlay
isEditMode && "pt-8" // Space for title bar
)}
onClick={(e) => {
if (isEditMode) {
e.preventDefault(); // Prevent focus stealing if clicking background
e.stopPropagation();
onSelect?.();
}
}}
>
<WidgetComponent
{...(widget.props || {})}
widgetInstanceId={widget.id}
widgetDefId={widget.widgetId}
isEditMode={isEditMode}
onPropsChange={async (newProps: Record<string, any>) => {
try {
await updateWidgetProps(pageId, widget.id, newProps);
} catch (error) {
console.error('Failed to update widget props:', error);
}
}}
/>
</div>
{/* Generic Settings Modal */}
{
widgetDefinition.metadata.configSchema && isEditing && (
<WidgetSettingsManager
isOpen={!!isEditing} // coerce to boolean although it should be boolean | undefined from comparison
onClose={() => onEditWidget?.(null)}
widgetDefinition={widgetDefinition}
currentProps={widget.props || {}}
onSave={handleSettingsSave}
/>
)
}
</div >
);
};
// Export without memoization to ensure reliable updates (Selection highlighting fix)
export const LayoutContainer = LayoutContainerComponent;