mono/packages/ui/src/components/hmi/GenericCanvas.tsx
2026-02-02 00:12:16 +01:00

408 lines
14 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Download, Upload, Grid3X3 } from 'lucide-react';
import { useLayout } from '@/contexts/LayoutContext';
import { LayoutContainer } from './LayoutContainer';
import { WidgetPalette } from './WidgetPalette';
interface GenericCanvasProps {
pageId: string;
pageName: string;
isEditMode?: boolean;
showControls?: boolean;
className?: string;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
initialLayout?: any;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
pageId,
pageName,
isEditMode = false,
showControls = true,
className = '',
selectedWidgetId,
onSelectWidget,
initialLayout
}) => {
const {
loadedPages,
loadPageLayout,
hydratePageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
updatePageContainerColumns,
updatePageContainerSettings,
addPageContainer,
removePageContainer,
movePageContainer,
exportPageLayout,
importPageLayout,
saveToApi,
isLoading
} = useLayout();
const layout = loadedPages.get(pageId);
// Load the page layout on mount
// Load the page layout on mount or hydrate from prop
useEffect(() => {
if (initialLayout && !layout) {
hydratePageLayout(pageId, initialLayout);
return;
}
if (!layout) {
loadPageLayout(pageId, pageName);
}
}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
if (isLoading || !layout) {
return (
<div className={`flex items-center justify-center min-h-[400px] ${className}`}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-slate-500 dark:text-slate-400">Loading {pageName.toLowerCase()}...</p>
</div>
</div>
);
}
const handleSelectContainer = (containerId: string) => {
setSelectedContainer(containerId);
};
const handleAddWidget = (containerId: string, columnIndex?: number) => {
setTargetContainerId(containerId);
setTargetColumn(columnIndex);
setShowWidgetPalette(true);
};
const handleWidgetAdd = async (widgetId: string) => {
if (targetContainerId) {
try {
await addWidgetToPage(pageId, targetContainerId, widgetId, targetColumn);
} catch (error) {
console.error('Failed to add widget:', error);
}
}
setShowWidgetPalette(false);
setTargetContainerId(null);
setTargetColumn(undefined);
};
const handleCanvasClick = () => {
if (isEditMode) {
setSelectedContainer(null);
}
};
const handleExportLayout = async () => {
try {
const jsonData = await exportPageLayout(pageId);
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${layout.name.toLowerCase().replace(/\s+/g, '-')}-layout.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export layout:', error);
}
};
const handleImportLayout = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const jsonData = e.target?.result as string;
await importPageLayout(pageId, jsonData);
setSelectedContainer(null);
} catch (error) {
console.error('Failed to import layout:', error);
alert('Failed to import layout. Please check the file format.');
}
};
reader.readAsText(file);
}
};
input.click();
};
const handleSaveToApi = async () => {
if (isSaving) return;
setIsSaving(true);
setSaveStatus('idle');
try {
const success = await saveToApi();
if (success) {
setSaveStatus('success');
setTimeout(() => setSaveStatus('idle'), 2000); // Clear success status after 2s
} else {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000); // Clear error status after 3s
}
} catch (error) {
console.error('Failed to save to API:', error);
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} finally {
setIsSaving(false);
}
};
const totalWidgets = layout.containers.reduce((total, container) => {
const getContainerWidgetCount = (cont: any): number => {
let count = cont.widgets.length;
cont.children.forEach((child: any) => {
count += getContainerWidgetCount(child);
});
return count;
};
return total + getContainerWidgetCount(container);
}, 0);
if (!isEditMode && totalWidgets === 0) {
return null;
}
return (
<div className={`${className.includes('p-0') ? 'space-y-2' : 'space-y-6'} ${className}`}>
{/* Header with Controls */}
{showControls && (
<div className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold glass-text">
{layout.name}
</h2>
</div>
{/* Edit Mode Controls */}
{isEditMode && (
<div className="flex items-center gap-2 flex-wrap justify-end">
<Button
onClick={async () => {
try {
await addPageContainer(pageId);
} catch (error) {
console.error('Failed to add container:', error);
}
}}
size="sm"
className="glass-button status-gradient-connected text-white border-0"
title="Add container"
>
<Grid3X3 className="h-4 w-4 mr-2" />
<T>Add Container</T>
</Button>
<Button
onClick={handleSaveToApi}
size="sm"
disabled={isSaving}
className={`glass-button ${saveStatus === 'success'
? 'bg-green-500 text-white'
: saveStatus === 'error'
? 'bg-red-500 text-white'
: 'bg-blue-500 text-white'
}`}
title="Save layout to server"
>
{isSaving ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
<T>Saving...</T>
</>
) : saveStatus === 'success' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<T>Saved!</T>
</>
) : saveStatus === 'error' ? (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<T>Failed</T>
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12" />
</svg>
<T>Save</T>
</>
)}
</Button>
<Button
onClick={handleImportLayout}
size="sm"
className="glass-button"
title="Import layout"
>
<Upload className="h-4 w-4 mr-2" />
<T>Import</T>
</Button>
<Button
onClick={handleExportLayout}
size="sm"
className="glass-button"
title="Export layout"
>
<Download className="h-4 w-4 mr-2" />
<T>Export</T>
</Button>
</div>
)}
</div>
{/* Layout Info */}
<div className="mt-3 pt-3 border-t border-slate-300/30 dark:border-white/10">
<p className="text-xs text-slate-500 dark:text-slate-400">
<T>Containers</T>: {layout.containers.length} | <T>Widgets</T>: {totalWidgets} | <T>Last updated</T>: {new Date(layout.updatedAt).toLocaleString()}
</p>
</div>
</div>
)}
{/* Container Canvas */}
<div
className={`${className.includes('p-0') ? 'space-y-2' : 'space-y-4'}`}
onClick={handleCanvasClick}
>
{layout.containers
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((container, index, array) => (
<LayoutContainer
key={container.id}
container={container}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainer}
onSelect={handleSelectContainer}
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
onSelectWidget={onSelectWidget}
onRemoveWidget={async (widgetId) => {
try {
await removeWidgetFromPage(pageId, widgetId);
} catch (error) {
console.error('Failed to remove widget:', error);
}
}}
onMoveWidget={async (widgetId, direction) => {
try {
await moveWidgetInPage(pageId, widgetId, direction);
} catch (error) {
console.error('Failed to move widget:', error);
}
}}
onUpdateColumns={async (containerId, columns) => {
try {
await updatePageContainerColumns(pageId, containerId, columns);
} catch (error) {
console.error('Failed to update columns:', error);
}
}}
onUpdateSettings={async (containerId, settings) => {
try {
await updatePageContainerSettings(pageId, containerId, settings);
} catch (error) {
console.error('Failed to update settings:', error);
}
}}
onAddContainer={async (parentId) => {
try {
await addPageContainer(pageId, parentId);
} catch (error) {
console.error('Failed to add container:', error);
}
}}
onMoveContainer={async (containerId, direction) => {
try {
await movePageContainer(pageId, containerId, direction);
} catch (error) {
console.error('Failed to move container:', error);
}
}}
canMoveContainerUp={index > 0}
canMoveContainerDown={index < array.length - 1}
onRemoveContainer={async (containerId) => {
try {
await removePageContainer(pageId, containerId);
} catch (error) {
console.error('Failed to remove container:', error);
}
}}
/>
))}
{layout.containers.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
{isEditMode ? (
<>
<Grid3X3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">
<T>No containers yet</T>
</p>
<p className="text-sm">
<T>Click "Add Container" to start building your layout</T>
</p>
</>
) : (
<>
<p className="text-lg font-medium mb-2">
<T>Empty Layout</T>
</p>
<p className="text-sm">
<T>Switch to edit mode to add containers</T>
</p>
</>
)}
</div>
)}
</div>
{/* Widget Palette Modal */}
<WidgetPalette
isVisible={showWidgetPalette}
onClose={() => {
setShowWidgetPalette(false);
setTargetContainerId(null);
}}
onWidgetAdd={handleWidgetAdd}
/>
</div>
);
};
export const GenericCanvas = GenericCanvasComponent;