diff --git a/frontend/src/components/reportEditor/ResizablePanel.tsx b/frontend/src/components/reportEditor/ResizablePanel.tsx index 4cc850a..17ca18f 100644 --- a/frontend/src/components/reportEditor/ResizablePanel.tsx +++ b/frontend/src/components/reportEditor/ResizablePanel.tsx @@ -1,4 +1,10 @@ -import { useState, useRef, useCallback, useEffect, type ReactNode } from "react"; +import { + useState, + useRef, + useCallback, + useEffect, + type ReactNode, +} from "react"; import { Box, IconButton, Tooltip, Typography, alpha } from "@mui/material"; import { ChevronLeft as CollapseLeftIcon, @@ -6,13 +12,8 @@ import { DragIndicator as DragIcon, } from "@mui/icons-material"; -export interface PanelConfig { - id: string; - width: number; - collapsed: boolean; - position: "left" | "right"; - order: number; -} +// Data transfer type for panel drag and drop +export const PANEL_DRAG_TYPE = "application/x-panel-id"; interface ResizablePanelProps { /** Unique panel identifier */ @@ -21,32 +22,24 @@ interface ResizablePanelProps { title: string; /** Icon shown when panel is collapsed */ icon: ReactNode; - /** Panel position - determines collapse button direction and resize handle position */ + /** Panel position - determines collapse button direction */ position: "left" | "right"; - /** Current width in pixels */ - width: number; - /** Minimum width when expanded */ - minWidth?: number; - /** Maximum width when expanded */ - maxWidth?: number; + /** Flex value for vertical sizing (default 1) */ + flex?: number; /** Whether the panel is currently collapsed */ collapsed: boolean; /** Panel width when collapsed (icon strip) */ collapsedWidth?: number; - /** Callback when width changes during resize */ - onWidthChange: (width: number) => void; + /** Callback when flex changes during vertical resize */ + onFlexChange?: (flex: number) => void; /** Callback when collapse state changes */ onToggleCollapse: () => void; /** Panel content */ children: ReactNode; /** Optional badge content (e.g., count) */ badge?: ReactNode; - /** Enable drag handle for reordering */ - draggable?: boolean; - /** Drag start handler */ - onDragStart?: (e: React.DragEvent) => void; - /** Drag end handler */ - onDragEnd?: (e: React.DragEvent) => void; + /** Whether this is the last panel in the sidebar (no bottom resize handle) */ + isLast?: boolean; } export default function ResizablePanel({ @@ -54,53 +47,68 @@ export default function ResizablePanel({ title, icon, position, - width, - minWidth = 200, - maxWidth = 500, + flex = 1, collapsed, collapsedWidth = 44, - onWidthChange, + onFlexChange, onToggleCollapse, children, badge, - draggable = false, - onDragStart, - onDragEnd, + isLast = false, }: ResizablePanelProps) { - const [isResizing, setIsResizing] = useState(false); + const [isResizingHeight, setIsResizingHeight] = useState(false); + const [isDragging, setIsDragging] = useState(false); const panelRef = useRef(null); - const startXRef = useRef(0); - const startWidthRef = useRef(width); + const startYRef = useRef(0); + const startHeightRef = useRef(0); + const startFlexRef = useRef(flex); - const CollapseIcon = position === "left" ? CollapseLeftIcon : CollapseRightIcon; + const CollapseIcon = + position === "left" ? CollapseLeftIcon : CollapseRightIcon; const ExpandIcon = position === "left" ? CollapseRightIcon : CollapseLeftIcon; - // Handle mouse down on resize handle - const handleResizeStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - startXRef.current = e.clientX; - startWidthRef.current = width; + // Handle drag start - make header draggable + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.dataTransfer.setData(PANEL_DRAG_TYPE, id); + e.dataTransfer.effectAllowed = "move"; + setIsDragging(true); }, - [width] + [id], ); - // Handle mouse move during resize + // Handle drag end + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + // Handle mouse down on vertical resize handle + const handleResizeHeightStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (!panelRef.current) return; + setIsResizingHeight(true); + startYRef.current = e.clientY; + startHeightRef.current = panelRef.current.offsetHeight; + startFlexRef.current = flex; + }, + [flex], + ); + + // Handle mouse move during vertical resize useEffect(() => { - if (!isResizing) return; + if (!isResizingHeight || !onFlexChange) return; const handleMouseMove = (e: MouseEvent) => { - const delta = position === "left" - ? e.clientX - startXRef.current - : startXRef.current - e.clientX; - - const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidthRef.current + delta)); - onWidthChange(newWidth); + const deltaY = e.clientY - startYRef.current; + const heightRatio = + (startHeightRef.current + deltaY) / startHeightRef.current; + const newFlex = Math.max(0.2, startFlexRef.current * heightRatio); + onFlexChange(newFlex); }; const handleMouseUp = () => { - setIsResizing(false); + setIsResizingHeight(false); }; document.addEventListener("mousemove", handleMouseMove); @@ -110,34 +118,50 @@ export default function ResizablePanel({ document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [isResizing, position, minWidth, maxWidth, onWidthChange]); + }, [isResizingHeight, onFlexChange]); - // Collapsed state - show icon strip + // Collapsed state - show icon strip (also draggable) if (collapsed) { return ( alpha(theme.palette.primary.main, 0.03), - borderLeft: position === "right" ? 1 : 0, - borderRight: position === "left" ? 1 : 0, + borderBottom: 1, borderColor: "divider", py: 1, - flexShrink: 0, + flexShrink: 1, + flexGrow: flex, + cursor: "grab", + opacity: isDragging ? 0.5 : 1, + transition: "opacity 0.2s", + "&:active": { + cursor: "grabbing", + }, }} > {/* Expand button */} - + { + e.stopPropagation(); + onToggleCollapse(); + }} sx={{ mb: 1, bgcolor: (theme) => alpha(theme.palette.primary.main, 0.08), @@ -151,21 +175,19 @@ export default function ResizablePanel({ {/* Panel icon with tooltip */} - + alpha(theme.palette.primary.main, 0.08), - }, }} - onClick={onToggleCollapse} > {title} + + {/* Drag indicator at bottom */} + + + ); } @@ -228,63 +250,26 @@ export default function ResizablePanel({ ref={panelRef} data-panel-id={id} sx={{ - width, - minWidth, - maxWidth, - height: "100%", + width: "100%", + flex: flex, + minHeight: 120, display: "flex", flexDirection: "column", - borderLeft: position === "right" ? 1 : 0, - borderRight: position === "left" ? 1 : 0, + borderBottom: 1, borderColor: "divider", bgcolor: "background.paper", position: "relative", - flexShrink: 0, + flexShrink: 1, + flexGrow: flex, + opacity: isDragging ? 0.5 : 1, + transition: "opacity 0.2s", }} > - {/* Resize handle */} - - - - - {/* Header with collapse button */} + {/* Header with collapse button - DRAGGABLE */} alpha(theme.palette.primary.main, 0.03), minHeight: 40, + cursor: "grab", + userSelect: "none", + "&:active": { + cursor: "grabbing", + }, }} > - - {/* Drag handle */} - {draggable && ( + + {/* Drag handle indicator */} + - )} - {icon} + + + {icon} + - + { + e.stopPropagation(); + onToggleCollapse(); + }} + sx={{ flexShrink: 0 }} + > - {/* Content - no internal scrollbar, content must handle its own overflow */} + {/* Content */} {children} + + {/* Vertical resize handle (only show if not last panel and onFlexChange is provided) */} + {!isLast && onFlexChange && ( + + + + )} ); } diff --git a/frontend/src/components/reportEditor/SidebarDropZone.tsx b/frontend/src/components/reportEditor/SidebarDropZone.tsx new file mode 100644 index 0000000..d78423f --- /dev/null +++ b/frontend/src/components/reportEditor/SidebarDropZone.tsx @@ -0,0 +1,324 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type ReactNode, +} from "react"; +import { Box, alpha } from "@mui/material"; +import { PANEL_DRAG_TYPE } from "./ResizablePanel"; + +interface SidebarDropZoneProps { + /** Sidebar position */ + position: "left" | "right"; + /** Sidebar width in pixels */ + width: number; + /** Minimum width */ + minWidth?: number; + /** Maximum width */ + maxWidth?: number; + /** Callback when width changes */ + onWidthChange: (width: number) => void; + /** Children (panels) */ + children: ReactNode; + /** Callback when a panel is dropped */ + onPanelDrop: ( + panelId: string, + targetPosition: "left" | "right", + targetIndex: number, + ) => void; + /** Current panel IDs in this sidebar (for calculating drop index) */ + panelIds: string[]; +} + +export default function SidebarDropZone({ + position, + width, + minWidth = 180, + maxWidth = 500, + onWidthChange, + children, + onPanelDrop, + panelIds, +}: SidebarDropZoneProps) { + const [isDragOver, setIsDragOver] = useState(false); + const [dropIndex, setDropIndex] = useState(null); + const [isResizing, setIsResizing] = useState(false); + const startXRef = useRef(0); + const startWidthRef = useRef(width); + + // Handle horizontal resize + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + startXRef.current = e.clientX; + startWidthRef.current = width; + }, + [width], + ); + + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const delta = + position === "left" + ? e.clientX - startXRef.current + : startXRef.current - e.clientX; + + const newWidth = Math.min( + maxWidth, + Math.max(minWidth, startWidthRef.current + delta), + ); + onWidthChange(newWidth); + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing, position, minWidth, maxWidth, onWidthChange]); + + // Handle drag over + const handleDragOver = useCallback( + (e: React.DragEvent) => { + // Only accept panel drags + if (!e.dataTransfer.types.includes(PANEL_DRAG_TYPE)) { + return; + } + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setIsDragOver(true); + + // Calculate drop index based on mouse position + const container = e.currentTarget as HTMLElement; + const rect = container.getBoundingClientRect(); + const mouseY = e.clientY - rect.top; + + // Find which panel slot the mouse is over + const panelElements = container.querySelectorAll("[data-panel-id]"); + let newDropIndex = panelIds.length; // Default to end + + panelElements.forEach((el, index) => { + const panelRect = el.getBoundingClientRect(); + const panelMiddle = panelRect.top + panelRect.height / 2 - rect.top; + + if (mouseY < panelMiddle) { + newDropIndex = Math.min(newDropIndex, index); + } + }); + + setDropIndex(newDropIndex); + }, + [panelIds.length], + ); + + // Handle drag leave + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Only reset if leaving the container entirely + const relatedTarget = e.relatedTarget as HTMLElement; + if (!e.currentTarget.contains(relatedTarget)) { + setIsDragOver(false); + setDropIndex(null); + } + }, []); + + // Handle drop + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + const panelId = e.dataTransfer.getData(PANEL_DRAG_TYPE); + console.log( + "[SidebarDropZone] Drop:", + panelId, + "position:", + position, + "dropIndex:", + dropIndex, + ); + if (!panelId) return; + + // Calculate final drop index + const finalIndex = dropIndex ?? panelIds.length; + + // If dropping in same sidebar, adjust index if needed + const currentIndex = panelIds.indexOf(panelId); + let adjustedIndex = finalIndex; + + if (currentIndex !== -1 && currentIndex < finalIndex) { + // If moving down in same sidebar, subtract 1 because the panel will be removed first + adjustedIndex = finalIndex - 1; + } + + onPanelDrop(panelId, position, adjustedIndex); + setDropIndex(null); + }, + [dropIndex, panelIds, position, onPanelDrop], + ); + + // Calculate drop indicator position + const getDropIndicatorTop = useCallback(() => { + if (dropIndex === null) return 0; + + // We need to find the panel elements in the DOM + const panelElements = document.querySelectorAll( + `[data-sidebar="${position}"] [data-panel-id]`, + ); + + if (dropIndex === 0) return 0; + + if (dropIndex >= panelElements.length) { + // Drop at end - position after last panel + const lastPanel = panelElements[panelElements.length - 1]; + if (lastPanel) { + const container = lastPanel.parentElement; + if (container) { + const rect = lastPanel.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return rect.bottom - containerRect.top; + } + } + return "auto"; + } + + const targetPanel = panelElements[dropIndex]; + if (targetPanel) { + const container = targetPanel.parentElement; + if (container) { + const rect = targetPanel.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return rect.top - containerRect.top; + } + } + return 0; + }, [dropIndex, position]); + + const hasNoPanels = panelIds.length === 0; + + return ( + alpha(theme.palette.primary.main, 0.05), + }), + transition: "background-color 0.2s", + }} + > + {/* Horizontal resize handle */} + + + + + {/* Drop indicator line */} + {isDragOver && dropIndex !== null && !hasNoPanels && ( + + `0 0 8px 2px ${alpha(theme.palette.primary.main, 0.4)}`, + }} + /> + )} + + {/* Panels */} + {children} + + {/* Empty state drop zone (when sidebar has no panels) */} + {hasNoPanels && ( + alpha(theme.palette.primary.main, 0.1) + : "transparent", + }} + > + Trascina qui + + )} + + ); +} diff --git a/frontend/src/hooks/usePanelLayout.ts b/frontend/src/hooks/usePanelLayout.ts index 8d8cc48..fe1a431 100644 --- a/frontend/src/hooks/usePanelLayout.ts +++ b/frontend/src/hooks/usePanelLayout.ts @@ -3,50 +3,103 @@ import { useLocalStorage } from "./useLocalStorage"; export interface PanelState { id: string; - width: number; + /** Flex value for vertical sizing (1 = equal share, 2 = double share, etc.) */ + flex: number; collapsed: boolean; position: "left" | "right"; order: number; } +export interface SidebarWidths { + left: number; + right: number; +} + export interface PanelLayoutConfig { panels: PanelState[]; + sidebarWidths: SidebarWidths; version: number; } const STORAGE_KEY = "apollinare-report-editor-panels"; -const CONFIG_VERSION = 1; +const CONFIG_VERSION = 3; // Bumped version for sidebar widths + +// Default sidebar widths +const defaultSidebarWidths: SidebarWidths = { + left: 250, + right: 300, +}; // Default panel configuration const defaultConfig: PanelLayoutConfig = { version: CONFIG_VERSION, + sidebarWidths: defaultSidebarWidths, panels: [ - { id: "pages", width: 220, collapsed: false, position: "left", order: 0 }, - { id: "data", width: 280, collapsed: false, position: "right", order: 0 }, - { id: "properties", width: 280, collapsed: false, position: "right", order: 1 }, + { + id: "pages", + flex: 1, + collapsed: false, + position: "left", + order: 0, + }, + { + id: "data", + flex: 1, + collapsed: false, + position: "right", + order: 0, + }, + { + id: "properties", + flex: 1, + collapsed: false, + position: "right", + order: 1, + }, ], }; export function usePanelLayout() { - const [config, setConfig] = useLocalStorage( + const [config, setConfig, clearConfig] = useLocalStorage( STORAGE_KEY, - defaultConfig + defaultConfig, ); // Ensure config is up to date (handle version migrations) const normalizedConfig = useMemo(() => { - if (!config || config.version !== CONFIG_VERSION) { + if (!config) { return defaultConfig; } - return config; - }, [config]); + + // If version mismatch, migrate or reset + if (config.version !== CONFIG_VERSION) { + console.log( + "[usePanelLayout] Version mismatch, resetting to default config", + ); + // Clear old config and return default + clearConfig(); + return defaultConfig; + } + + // Ensure all panels have flex property + const migratedPanels = config.panels.map((p) => ({ + ...p, + flex: p.flex ?? 1, + })); + + return { + ...config, + sidebarWidths: config.sidebarWidths ?? defaultSidebarWidths, + panels: migratedPanels, + }; + }, [config, clearConfig]); // Get panel state by id const getPanelState = useCallback( (panelId: string): PanelState | undefined => { return normalizedConfig.panels.find((p) => p.id === panelId); }, - [normalizedConfig.panels] + [normalizedConfig.panels], ); // Update a single panel's state @@ -55,11 +108,11 @@ export function usePanelLayout() { setConfig((prev) => ({ ...prev, panels: prev.panels.map((p) => - p.id === panelId ? { ...p, ...updates } : p + p.id === panelId ? { ...p, ...updates } : p, ), })); }, - [setConfig] + [setConfig], ); // Toggle panel collapse state @@ -68,48 +121,110 @@ export function usePanelLayout() { setConfig((prev) => ({ ...prev, panels: prev.panels.map((p) => - p.id === panelId ? { ...p, collapsed: !p.collapsed } : p + p.id === panelId ? { ...p, collapsed: !p.collapsed } : p, ), })); }, - [setConfig] + [setConfig], ); - // Update panel width - const setPanelWidth = useCallback( - (panelId: string, width: number) => { + // Update sidebar width + const setSidebarWidth = useCallback( + (position: "left" | "right", width: number) => { + setConfig((prev) => ({ + ...prev, + sidebarWidths: { + ...prev.sidebarWidths, + [position]: Math.max(180, Math.min(500, width)), + }, + })); + }, + [setConfig], + ); + + // Update panel flex value + const setPanelFlex = useCallback( + (panelId: string, flex: number) => { setConfig((prev) => ({ ...prev, panels: prev.panels.map((p) => - p.id === panelId ? { ...p, width } : p + p.id === panelId ? { ...p, flex: Math.max(0.2, flex) } : p, ), })); }, - [setConfig] + [setConfig], ); // Move panel to a different position (left/right sidebar) + // Auto-redistributes flex values so all panels in the sidebar share space equally const movePanelToPosition = useCallback( (panelId: string, newPosition: "left" | "right", newOrder?: number) => { setConfig((prev) => { + const movingPanel = prev.panels.find((p) => p.id === panelId); + if (!movingPanel) return prev; + + const isMovingToNewSidebar = movingPanel.position !== newPosition; + + // Get panels in the target position (excluding the moving panel) const panelsInNewPosition = prev.panels.filter( - (p) => p.position === newPosition && p.id !== panelId + (p) => p.position === newPosition && p.id !== panelId, + ); + + // Calculate new order + const maxOrder = + panelsInNewPosition.length > 0 + ? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1 + : 0; + const finalOrder = newOrder ?? maxOrder; + + // All panels get flex: 1 for equal distribution + const equalFlex = 1; + + // Get panels in the old position (excluding the moving panel) for rebalancing + const panelsInOldPosition = prev.panels.filter( + (p) => p.position === movingPanel.position && p.id !== panelId, ); - const maxOrder = panelsInNewPosition.length > 0 - ? Math.max(...panelsInNewPosition.map((p) => p.order)) + 1 - : 0; return { ...prev, - panels: prev.panels.map((p) => - p.id === panelId - ? { ...p, position: newPosition, order: newOrder ?? maxOrder } - : p - ), + panels: prev.panels.map((p) => { + // The panel being moved + if (p.id === panelId) { + return { + ...p, + position: newPosition, + order: finalOrder, + flex: equalFlex, + }; + } + + // Panels in the target sidebar: reset to equal flex + if (p.position === newPosition && isMovingToNewSidebar) { + return { ...p, flex: equalFlex }; + } + + // Panels in the old sidebar (after panel leaves): reset to equal flex + if ( + isMovingToNewSidebar && + p.position === movingPanel.position && + panelsInOldPosition.length > 0 + ) { + return { ...p, flex: equalFlex }; + } + + // Adjust order for panels in target position if inserting at specific index + if (p.position === newPosition && newOrder !== undefined) { + if (p.order >= newOrder) { + return { ...p, order: p.order + 1 }; + } + } + + return p; + }), }; }); }, - [setConfig] + [setConfig], ); // Reorder panels within a sidebar @@ -124,7 +239,7 @@ export function usePanelLayout() { }), })); }, - [setConfig] + [setConfig], ); // Get panels for a specific position, sorted by order @@ -134,7 +249,15 @@ export function usePanelLayout() { .filter((p) => p.position === position) .sort((a, b) => a.order - b.order); }, - [normalizedConfig.panels] + [normalizedConfig.panels], + ); + + // Get sidebar width + const getSidebarWidth = useCallback( + (position: "left" | "right"): number => { + return normalizedConfig.sidebarWidths[position]; + }, + [normalizedConfig.sidebarWidths], ); // Reset to default configuration @@ -147,7 +270,9 @@ export function usePanelLayout() { getPanelState, updatePanel, togglePanelCollapse, - setPanelWidth, + setSidebarWidth, + getSidebarWidth, + setPanelFlex, movePanelToPosition, reorderPanels, getPanelsForPosition, diff --git a/frontend/src/pages/ReportEditorPage.tsx b/frontend/src/pages/ReportEditorPage.tsx index bee3385..29b4962 100644 --- a/frontend/src/pages/ReportEditorPage.tsx +++ b/frontend/src/pages/ReportEditorPage.tsx @@ -64,6 +64,7 @@ import ImageUploadDialog, { } from "../components/reportEditor/ImageUploadDialog"; import PageNavigator from "../components/reportEditor/PageNavigator"; import ResizablePanel from "../components/reportEditor/ResizablePanel"; +import SidebarDropZone from "../components/reportEditor/SidebarDropZone"; import { reportTemplateService, reportFontService, @@ -149,6 +150,18 @@ export default function ReportEditorPage() { // Panel layout configuration (persisted to localStorage) const panelLayout = usePanelLayout(); + // Handle panel drag and drop between sidebars + const handlePanelDrop = useCallback( + ( + panelId: string, + targetPosition: "left" | "right", + targetIndex: number, + ) => { + panelLayout.movePanelToPosition(panelId, targetPosition, targetIndex); + }, + [panelLayout], + ); + // UI state const [saveDialog, setSaveDialog] = useState(false); const [previewDialog, setPreviewDialog] = useState(false); @@ -1901,88 +1914,96 @@ export default function ReportEditorPage() { {/* Main Editor Area */} {/* Left Sidebar - Panels on left side */} - {!isMobile && ( - - {panelLayout.getPanelsForPosition("left").map((panelState) => { - if (panelState.id === "pages") { - return ( - } - position="left" - width={panelState.width} - minWidth={180} - maxWidth={350} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - badge={template.pages.length} - > - {renderPageNavigator(true)} - - ); - } - if (panelState.id === "data") { - return ( - } - position="left" - width={panelState.width} - minWidth={220} - maxWidth={400} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - badge={ - selectedDatasets.length > 0 - ? selectedDatasets.length - : undefined - } - > - {renderDataBindingPanel(true)} - - ); - } - if (panelState.id === "properties") { - return ( - } - position="left" - width={panelState.width} - minWidth={220} - maxWidth={400} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - > - {renderPropertiesPanel(true)} - - ); - } - return null; - })} - - )} + {!isMobile && + (() => { + const leftPanels = panelLayout.getPanelsForPosition("left"); + return ( + panelLayout.setSidebarWidth("left", w)} + onPanelDrop={handlePanelDrop} + panelIds={leftPanels.map((p) => p.id)} + > + {leftPanels.map((panelState, index) => { + const isLast = index === leftPanels.length - 1; + if (panelState.id === "pages") { + return ( + } + position="left" + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + badge={template.pages.length} + isLast={isLast} + > + {renderPageNavigator(true)} + + ); + } + if (panelState.id === "data") { + return ( + } + position="left" + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + badge={ + selectedDatasets.length > 0 + ? selectedDatasets.length + : undefined + } + isLast={isLast} + > + {renderDataBindingPanel(true)} + + ); + } + if (panelState.id === "properties") { + return ( + } + position="left" + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + isLast={isLast} + > + {renderPropertiesPanel(true)} + + ); + } + return null; + })} + + ); + })()} {/* Center Area - Canvas Container (flex: 1 to take remaining space, centers the canvas) */} {/* Right Sidebar - Panels on right side */} - {!isMobile && ( - - {panelLayout.getPanelsForPosition("right").map((panelState) => { - if (panelState.id === "pages") { - return ( - } - position="right" - width={panelState.width} - minWidth={180} - maxWidth={350} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - badge={template.pages.length} - > - {renderPageNavigator(true)} - - ); - } - if (panelState.id === "data") { - return ( - } - position="right" - width={panelState.width} - minWidth={220} - maxWidth={400} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - badge={ - selectedDatasets.length > 0 - ? selectedDatasets.length - : undefined - } - > - {renderDataBindingPanel(true)} - - ); - } - if (panelState.id === "properties") { - return ( - } - position="right" - width={panelState.width} - minWidth={220} - maxWidth={400} - collapsed={panelState.collapsed} - onWidthChange={(w) => - panelLayout.setPanelWidth(panelState.id, w) - } - onToggleCollapse={() => - panelLayout.togglePanelCollapse(panelState.id) - } - > - {renderPropertiesPanel(true)} - - ); - } - return null; - })} - - )} + {!isMobile && + (() => { + const rightPanels = panelLayout.getPanelsForPosition("right"); + return ( + panelLayout.setSidebarWidth("right", w)} + onPanelDrop={handlePanelDrop} + panelIds={rightPanels.map((p) => p.id)} + > + {rightPanels.map((panelState, index) => { + const isLast = index === rightPanels.length - 1; + if (panelState.id === "pages") { + return ( + } + position={panelState.position} + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + badge={template.pages.length} + isLast={isLast} + > + {renderPageNavigator(true)} + + ); + } + if (panelState.id === "data") { + return ( + } + position={panelState.position} + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + badge={ + selectedDatasets.length > 0 + ? selectedDatasets.length + : undefined + } + isLast={isLast} + > + {renderDataBindingPanel(true)} + + ); + } + if (panelState.id === "properties") { + return ( + } + position={panelState.position} + flex={panelState.flex} + collapsed={panelState.collapsed} + onFlexChange={(f) => + panelLayout.setPanelFlex(panelState.id, f) + } + onToggleCollapse={() => + panelLayout.togglePanelCollapse(panelState.id) + } + isLast={isLast} + > + {renderPropertiesPanel(true)} + + ); + } + return null; + })} + + ); + })()} {/* Mobile Bottom Navigation */} diff --git a/src/Apollinare.API/apollinare.db-shm b/src/Apollinare.API/apollinare.db-shm new file mode 100644 index 0000000..8d59635 Binary files /dev/null and b/src/Apollinare.API/apollinare.db-shm differ diff --git a/src/Apollinare.API/apollinare.db-wal b/src/Apollinare.API/apollinare.db-wal new file mode 100644 index 0000000..bcce40c Binary files /dev/null and b/src/Apollinare.API/apollinare.db-wal differ