mono/packages/ui/src/components/widgets/TabsWidget.tsx
2026-03-21 20:18:25 +01:00

228 lines
9.1 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { GenericCanvas } from '@/modules/layout/GenericCanvas';
import { cn } from '@/lib/utils';
import { T } from '@/i18n';
import * as LucideIcons from 'lucide-react';
import { useLayout } from '@/modules/layout/LayoutContext';
import { PageLayout } from '@/modules/layout/LayoutManager';
export interface TabDefinition {
id: string;
label: string;
layoutId: string;
icon?: string;
layoutData?: PageLayout;
}
interface TabsWidgetProps {
widgetInstanceId: string;
tabs?: TabDefinition[];
activeTabId?: string;
orientation?: 'horizontal' | 'vertical';
tabBarPosition?: 'top' | 'bottom' | 'left' | 'right';
className?: string; // Container classes
tabBarClassName?: string; // Tab bar specific classes
contentClassName?: string; // Content area classes
isEditMode?: boolean;
onPropsChange: (props: Record<string, any>) => void;
selectedWidgetId?: string | null;
onSelectWidget?: (id: string, pageId?: string) => void;
onSelectContainer?: (containerId: string | null, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (id: string | null) => void;
contextVariables?: Record<string, any>;
}
const TabsWidget: React.FC<TabsWidgetProps> = ({
widgetInstanceId,
tabs = [],
activeTabId,
orientation = 'horizontal',
tabBarPosition = 'top',
className = '',
tabBarClassName = '',
contentClassName = '',
isEditMode = false,
onPropsChange,
selectedWidgetId,
onSelectWidget,
onSelectContainer,
editingWidgetId,
onEditWidget,
contextVariables,
}) => {
const [currentTabId, setCurrentTabId] = useState<string | undefined>(activeTabId);
const { loadedPages, addPageContainer, hydratePageLayout } = useLayout();
// Effect to ensure we have a valid currentTabId
useEffect(() => {
if (tabs.length > 0) {
if (!currentTabId || !tabs.find(t => t.id === currentTabId)) {
setCurrentTabId(tabs[0].id);
}
} else {
setCurrentTabId(undefined);
}
}, [tabs, currentTabId]);
// Effect to ensure at least one container exists in the tab layout
useEffect(() => {
if (currentTabId && isEditMode) {
const tab = tabs.find(t => t.id === currentTabId);
if (tab) {
const currentLayout = loadedPages.get(tab.layoutId);
// Check if layout is loaded but has no containers
if (currentLayout && currentLayout.containers.length === 0) {
addPageContainer(tab.layoutId).catch(console.error);
}
}
}
}, [currentTabId, tabs, loadedPages, isEditMode, addPageContainer]);
// Effect to sync prop activeTabId if it changes externally
useEffect(() => {
if (activeTabId && tabs.find(t => t.id === activeTabId)) {
setCurrentTabId(activeTabId);
}
}, [activeTabId, tabs]);
const handleTabClick = (tabId: string) => {
setCurrentTabId(tabId);
// Optionally persist selection?
// onPropsChange({ activeTabId: tabId });
// Usually tabs reset on reload unless specifically desired.
// Let's keep it local state for now unless user demands persistence.
};
const currentTab = tabs.find(t => t.id === currentTabId);
// Sync Layout Data back to props — sync ALL tabs, not just current
useEffect(() => {
if (!isEditMode) return;
let changed = false;
const newTabs = tabs.map(t => {
const layout = loadedPages.get(t.layoutId);
if (layout) {
// Guard: never overwrite stored data that has widgets with an empty layout
// This prevents the race condition where loadPageLayout creates empty defaults
const storedWidgetCount = t.layoutData?.containers?.reduce((sum: number, c: any) => sum + (c.widgets?.length || 0), 0) || 0;
const liveWidgetCount = layout.containers?.reduce((sum: number, c: any) => sum + (c.widgets?.length || 0), 0) || 0;
if (liveWidgetCount === 0 && storedWidgetCount > 0) {
hydratePageLayout(t.layoutId, t.layoutData!);
return t;
}
const propTimestamp = t.layoutData?.updatedAt || 0;
if (layout.updatedAt > propTimestamp) {
const layoutChanged = JSON.stringify(layout) !== JSON.stringify(t.layoutData);
if (layoutChanged) {
changed = true;
return { ...t, layoutData: layout };
}
}
}
return t;
});
if (changed) {
console.log('[TabsWidget SYNC-BACK] Calling onPropsChange with updated tabs', Date.now());
onPropsChange({ tabs: newTabs });
} else {
console.log('[TabsWidget SYNC-BACK] No changes detected');
}
}, [loadedPages, isEditMode, onPropsChange, tabs, hydratePageLayout]);
const renderIcon = (iconName?: string) => {
if (!iconName) return null;
const Icon = (LucideIcons as any)[iconName];
return Icon ? <Icon className="w-4 h-4 mr-2" /> : null;
};
const isVertical = tabBarPosition === 'left' || tabBarPosition === 'right';
const flexDirection = (() => {
switch (tabBarPosition) {
case 'left': return 'flex-row';
case 'right': return 'flex-row-reverse';
case 'bottom': return 'flex-col-reverse';
case 'top':
default: return 'flex-col';
}
})();
const tabBarClasses = cn(
"flex gap-1 overflow-auto scrollbar-hide bg-slate-100 dark:bg-slate-800/50 p-1 rounded-t-md",
isVertical ? "flex-col w-48 min-w-[12rem]" : "flex-row w-full",
tabBarClassName
);
const tabButtonClasses = (isActive: boolean) => cn(
"flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
isActive
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-slate-600 dark:text-slate-400 hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
isVertical ? "w-full justify-start" : "flex-1 justify-center"
);
if (tabs.length === 0) {
return (
<div className="flex items-center justify-center p-8 border-2 border-dashed border-slate-300 dark:border-slate-700 rounded-lg">
<div className="text-center text-slate-500">
<LucideIcons.Layers className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p><T>No tabs configured.</T></p>
{isEditMode && <p className="text-xs mt-1"><T>Add tabs in widget settings.</T></p>}
</div>
</div>
);
}
return (
<div className={cn("flex w-full h-full min-h-[300px]", flexDirection, className)}>
{/* Tab Bar */}
<div className={tabBarClasses}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={tabButtonClasses(tab.id === currentTabId)}
>
{renderIcon(tab.icon)}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className={cn("flex-1 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800 rounded-b-md relative overflow-hidden", contentClassName)}>
{currentTab ? (
<GenericCanvas
key={currentTab.layoutId} // Important: force remount so GenericCanvas loads new pageId
pageId={currentTab.layoutId}
pageName={currentTab.label}
isEditMode={isEditMode}
showControls={false} // Tabs usually hide nested canvas controls to look cleaner
initialLayout={currentTab.layoutData} // Hydrate from embedded data
className="p-4"
selectedWidgetId={selectedWidgetId}
onSelectWidget={(id, pId) => {
onSelectWidget?.(id, pId);
}}
onSelectContainer={(id, pId) => {
onSelectContainer?.(id, pId);
}}
editingWidgetId={editingWidgetId}
onEditWidget={onEditWidget}
contextVariables={contextVariables}
/>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
<T>Select a tab</T>
</div>
)}
</div>
</div>
);
};
export default TabsWidget;