mono/packages/ui/src/modules/layout/GenericCanvasEdit.tsx
2026-03-21 20:18:25 +01:00

410 lines
19 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Grid3X3 } from 'lucide-react';
import { useLayout } from '@/modules/layout/LayoutContext';
import { LayoutContainer } from './LayoutContainer';
import FlexibleContainerRenderer from './FlexibleContainerRenderer';
import { WidgetPalette } from './WidgetPalette';
import { uploadAndCreatePicture } from '@/lib/uploadUtils';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { translate } from '@/i18n';
export interface GenericCanvasEditProps {
pageId: string;
pageName: string;
isEditMode?: boolean;
showControls?: boolean;
className?: string;
selectedWidgetId?: string | null;
selectedWidgetIds?: Set<string>;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
initialLayout?: any;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
newlyAddedWidgetId?: string | null;
contextVariables?: Record<string, any>;
pageContext?: Record<string, any>;
onSave?: () => Promise<void | boolean>;
selectionBreadcrumb?: React.ReactNode;
}
const GenericCanvasEdit: React.FC<GenericCanvasEditProps> = ({
pageId,
pageName,
isEditMode = false,
showControls = true,
className = '',
selectedWidgetId,
selectedWidgetIds,
onSelectWidget,
selectedContainerId: propSelectedContainerId,
onSelectContainer: propOnSelectContainer,
initialLayout,
editingWidgetId,
onEditWidget,
newlyAddedWidgetId,
contextVariables,
pageContext,
onSave,
selectionBreadcrumb
}) => {
const {
loadedPages,
loadPageLayout,
hydratePageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
updatePageContainerColumns,
updatePageContainerSettings,
addPageContainer,
addFlexPageContainer,
removePageContainer,
movePageContainer,
isLoading
} = useLayout();
const layout = loadedPages.get(pageId);
// Load the page layout on mount or hydrate from prop
useEffect(() => {
if (initialLayout && !layout) {
console.log(`[GenericCanvasEdit HYDRATE] Initial hydration for "${pageId}"`, {
widgetCount: initialLayout.containers?.reduce((sum: number, c: any) => sum + (c.widgets?.length || 0), 0),
updatedAt: initialLayout.updatedAt
});
hydratePageLayout(pageId, initialLayout);
return;
}
// Re-hydrate if initialLayout was updated externally (e.g. page content reloaded)
if (initialLayout && layout && initialLayout.updatedAt && layout.updatedAt
&& initialLayout.updatedAt > layout.updatedAt) {
console.log(`[GenericCanvasEdit HYDRATE] RE-HYDRATING "${pageId}" (stale)`, {
initialUpdatedAt: initialLayout.updatedAt,
layoutUpdatedAt: layout.updatedAt,
initialWidgets: initialLayout.containers?.reduce((sum: number, c: any) => sum + (c.widgets?.length || 0), 0),
currentWidgets: layout.containers?.reduce((sum: number, c: any) => sum + (c.widgets?.length || 0), 0)
});
hydratePageLayout(pageId, initialLayout);
return;
}
if (!layout) {
console.log(`[GenericCanvasEdit HYDRATE] Loading from API for "${pageId}"`);
loadPageLayout(pageId, pageName);
}
}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]);
const [internalSelectedContainer, setInternalSelectedContainer] = useState<string | null>(null);
const selectedContainer = propSelectedContainerId !== undefined ? propSelectedContainerId : internalSelectedContainer;
const setSelectedContainer = (id: string | null, pageId?: string) => {
if (propOnSelectContainer) {
propOnSelectContainer(id, pageId);
} else {
setInternalSelectedContainer(id);
}
};
const [showWidgetPalette, setShowWidgetPalette] = useState(false);
const [targetContainerId, setTargetContainerId] = useState<string | null>(null);
const [targetColumn, setTargetColumn] = useState<number | undefined>(undefined);
const [targetRowId, setTargetRowId] = useState<string | undefined>(undefined);
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, innerPageId?: string) => {
setSelectedContainer(containerId, innerPageId);
};
const handleAddWidget = (containerId: string, columnIndex?: number, rowId?: string) => {
setTargetContainerId(containerId);
setTargetColumn(columnIndex);
setTargetRowId(rowId);
setShowWidgetPalette(true);
};
const handleWidgetAdd = async (widgetId: string) => {
if (targetContainerId) {
try {
await addWidgetToPage(pageId, targetContainerId, widgetId, targetColumn, targetRowId);
} catch (error) {
console.error('Failed to add widget:', error);
}
}
setShowWidgetPalette(false);
setTargetContainerId(null);
setTargetColumn(undefined);
setTargetRowId(undefined);
};
const handleCanvasClick = () => {
if (isEditMode) {
setSelectedContainer(null);
}
};
// Drag and Drop Handler for Image Files
const handleFilesDrop = async (files: File[], targetContainerId?: string, targetColumn?: number) => {
if (!targetContainerId) return;
const toastId = toast.loading(translate('Uploading images...'));
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
toast.error(translate('You must be logged in to upload images'), { id: toastId });
return;
}
for (const file of files) {
// Upload and create record
const picture = await uploadAndCreatePicture(file, user.id);
if (picture) {
// Create widget with initial props
await addWidgetToPage(pageId, targetContainerId, 'photo-card', targetColumn, undefined, {
pictureId: picture.id
});
}
}
toast.success(translate('Images added to page'), { id: toastId });
} catch (error) {
console.error('Failed to process dropped files:', error);
toast.error(translate('Failed to upload images'), { id: toastId });
}
};
const totalWidgets = layout.containers.reduce((total, container) => {
const getContainerWidgetCount = (cont: any): number => {
let count = cont.widgets.length;
if (cont.children) {
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="">
{/* 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>
{selectionBreadcrumb}
</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) => {
// Dispatch to the correct renderer based on container type
if (container.type === 'flex-container') {
return (
<FlexibleContainerRenderer
key={container.id}
container={container}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainer}
onSelect={handleSelectContainer}
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
selectedWidgetIds={selectedWidgetIds}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
contextVariables={contextVariables}
pageContext={pageContext}
onRemoveWidget={async (widgetId) => {
try {
await removeWidgetFromPage(pageId, widgetId);
} catch (error) {
console.error('Failed to remove widget:', error);
}
}}
onRemoveContainer={async (containerId) => {
try {
await removePageContainer(pageId, containerId);
} catch (error) {
console.error('Failed to remove 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}
/>
);
}
// Default: LayoutContainer
return (
<LayoutContainer
key={container.id}
container={container}
isEditMode={isEditMode}
pageId={pageId}
selectedContainerId={selectedContainer}
onSelect={handleSelectContainer}
onAddWidget={handleAddWidget}
isCompactMode={className.includes('p-0')}
selectedWidgetId={selectedWidgetId}
selectedWidgetIds={selectedWidgetIds}
onSelectWidget={onSelectWidget}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
newlyAddedWidgetId={newlyAddedWidgetId}
contextVariables={contextVariables}
pageContext={pageContext}
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);
}
}}
onFilesDrop={(files, targetColumn) => handleFilesDrop(files, container.id, targetColumn)}
/>
);
})}
{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 mb-4">
<T>Add a container to start building your layout</T>
</p>
<div className="flex items-center justify-center gap-2">
<button
className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
onClick={async () => {
try { await addPageContainer(pageId); } catch (e) { console.error(e); }
}}
>
+ Container
</button>
<button
className="px-3 py-1.5 text-sm bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
onClick={async () => {
try { await addFlexPageContainer(pageId); } catch (e) { console.error(e); }
}}
>
+ Flexible Container
</button>
</div>
</>
) : (
<>
<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 default GenericCanvasEdit;