39948-vm/frontend/src/pages/constructor.tsx

1722 lines
60 KiB
TypeScript

import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import BaseButton from '../components/BaseButton';
import CanvasBackground from '../components/Constructor/CanvasBackground';
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
import CanvasElementComponent from '../components/Constructor/CanvasElement';
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
import { BackdropPortalProvider } from '../components/BackdropPortal';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { usePageNavigation } from '../hooks/usePageNavigation';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonObject } from '../lib/parseJson';
import {
resolveNavigationTarget,
hasPlayableTransition,
getNavigationDirection,
isBackNavigation,
} from '../lib/navigationHelpers';
import {
mergeElementWithDefaults,
createLocalId,
normalizeAppearDelaySec,
normalizeAppearDurationSec,
ELEMENT_TYPE_LABELS,
getNavigationButtonKind,
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isMediaElementType,
isVideoPlayerElementType,
clamp,
} from '../lib/elementDefaults';
import type {
CanvasElementType,
CanvasElement,
ConstructorSchema,
ConstructorAsset as ProjectAsset,
EditorMenuItem,
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
} from '../types/constructor';
import type { TourPage } from '../types/entities';
// Constructor-specific hooks
import {
useCanvasElapsedTime,
isElementVisibleAtTime,
} from '../hooks/useCanvasElapsedTime';
import {
useMediaDurationProbe,
buildDurationProbeTargets,
} from '../hooks/useMediaDurationProbe';
import { useIconPreload } from '../hooks/useIconPreload';
import { useOutsideClick } from '../hooks/useOutsideClick';
import { useDraggable } from '../hooks/useDraggable';
import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag';
import { useTransitionPreview } from '../hooks/useTransitionPreview';
import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
import { useConstructorElements } from '../hooks/useConstructorElements';
import { usePageBackground } from '../hooks/usePageBackground';
import { useConstructorData } from '../hooks/useConstructorData';
import { useAssetOptions } from '../hooks/useAssetOptions';
import { useTransitionCreation } from '../hooks/useTransitionCreation';
import {
ConstructorProvider,
type ConstructorContextValue,
type NavigationElementType,
} from '../context/ConstructorContext';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config';
// Constructor helpers (extracted utilities)
import {
getAssetLabel,
getAssetSourceValue,
isBackgroundImageAsset,
} from '../lib/constructorHelpers';
// TourPage type is imported from '../types/entities'
// NavigationElementType is imported from '../context/ConstructorContext'
type ConstructorPageProps = {
mode?: 'constructor' | 'element_edit';
};
type ConstructorInteractionMode = 'edit' | 'interact';
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
const labelByType = ELEMENT_TYPE_LABELS;
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const isElementEditMode = mode === 'element_edit';
const projectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const pageElementsListHref = useMemo(() => {
if (!projectId)
return '/project-element-defaults/project-element-defaults-list';
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
}, [projectId]);
const pageIdFromRoute = useMemo(() => {
const value = router.query.pageId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.pageId]);
const elementIdFromRoute = useMemo(() => {
const value = router.query.elementId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.elementId]);
// Use React Query for data fetching (replaces manual loadData)
const {
project,
pages,
pageLinks,
allPagesPreloadElements,
assets,
uiElementDefaultsByType,
projectName,
isLoading: isDataLoading,
isError: isDataError,
error: dataError,
refetch: refetchData,
} = useConstructorData({
projectId,
isAuthReady,
});
// Canvas scale for responsive UI elements and letterbox mode
const { cssVars: canvasCssVars, letterboxStyles } = useCanvasScale({
designWidth: project?.design_width,
designHeight: project?.design_height,
});
// Page navigation with history tracking via shared hook
const {
currentPageId: activePageId,
pageHistory,
applyPageSelection,
getNavigationContext,
setCurrentPageId: setActivePageId,
} = usePageNavigation({
pages,
trackHistory: true,
});
// Consolidated page background state (replaces 8 separate useState hooks)
const {
background: pageBackground,
setBackground: setPageBackground,
updateFromPage: updateBackgroundFromPage,
setImageUrl: setBackgroundImageUrl,
setVideoUrl: setBackgroundVideoUrl,
setAudioUrl: setBackgroundAudioUrl,
setVideoSettings: setBackgroundVideoSettings,
// Legacy compatibility values for components that expect flat props
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
} = usePageBackground();
const [selectedMenuItem, setSelectedMenuItem] =
useState<EditorMenuItem>('none');
// Transition preview state managed by useTransitionPreview hook (below)
// Combined loading state: initial auth check + data loading
const [isInitializing, setIsInitializing] = useState(true);
const isLoading = isInitializing || isDataLoading;
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
const [newTransitionName, setNewTransitionName] = useState('');
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [constructorInteractionMode, setConstructorInteractionMode] =
useState<ConstructorInteractionMode>('edit');
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
const [elementEditorTab, setElementEditorTab] = useState<
'general' | 'css' | 'effects'
>('general');
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement;
initialIndex: number;
} | null>(null);
// Track background ready state for smooth video transition completion
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
return ['navigation_next', 'navigation_prev'];
}, []);
// Element CRUD operations via useConstructorElements hook
const {
elements,
setElements,
selectedElementId,
selectedElement,
selectElement,
clearSelection,
addElement,
updateSelectedElement,
removeSelectedElement,
galleryCards,
galleryInfoSpans,
carouselSlides,
updateElementPosition,
normalizeNavigationType,
} = useConstructorElements({
initialElements: [],
elementDefaultsByType: uiElementDefaultsByType,
allowedNavigationTypes,
initialSelectedElementId: elementIdFromRoute,
onElementSelected: useCallback(() => {
setSelectedMenuItem('none');
}, []),
onSelectionCleared: useCallback(() => {
setSelectedMenuItem('none');
}, []),
onElementAdded: useCallback(() => {
setSuccessMessage('Element added. Drag it to set position.');
setErrorMessage('');
}, []),
onElementRemoved: useCallback(() => {
setSuccessMessage('Element removed.');
}, []),
});
// Check if any element has full-width carousel mode enabled
const hasFullWidthCarousel = useMemo(
() => elements.some((el) => el.carouselFullWidth === true),
[elements],
);
// Draggable panels using useDraggable hook
const {
position: constructorControlsPosition,
onDragStart: onConstructorControlsDragStart,
} = useDraggable({
initialPosition: { x: 20, y: 20 },
elementWidth: 460,
elementHeight: 64,
});
const { position: menuPosition, onDragStart: onMenuDragStart } = useDraggable(
{
initialPosition: { x: 9999, y: 10 }, // Top right corner (x will be clamped)
elementWidth: 240,
elementHeight: 60,
},
);
const { position: editorPosition, onDragStart: onElementEditorDragStart } =
useDraggable({
initialPosition: { x: 0, y: 72 },
elementWidth: isEditorCollapsed ? 260 : 380,
elementHeight: 60,
});
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
const didSetInitialCanvasFocus = useRef(false);
const selectedElementIdRef = useRef<string>('');
selectedElementIdRef.current = selectedElementId;
const activePage = useMemo(
() => pages.find((item) => item.id === activePageId) || null,
[activePageId, pages],
);
// Transition preview state management
const {
preview: transitionPreview,
pendingPageId: pendingNavigationPageId,
openPreview: openTransitionPreviewForElement,
openPreviewWithTarget,
closePreview: closeTransitionPreview,
} = useTransitionPreview({
isNavigationElementType,
onError: setErrorMessage,
});
// Canvas elapsed time for element visibility timing
const { elapsedSec: canvasElapsedSec } = useCanvasElapsedTime({
pageId: activePageId,
enabled: !isLoading,
});
// Element dragging with percentage positioning
const { onElementDragStart, cancelDrag: cancelElementDrag } =
useCanvasElementDrag({
canvasRef,
onPositionChange: updateElementPosition,
enabled: constructorInteractionMode === 'edit',
});
// Preload orchestrator for better DX when previewing pages
// Preloads neighbor page assets and transition videos
// Uses allPagesPreloadElements (extracted in loadData) for proper neighbor preloading
const preloadOrchestrator = usePreloadOrchestrator({
pages: pages.map((p) => ({
id: p.id,
background_image_url: p.background_image_url,
background_video_url: p.background_video_url,
background_audio_url: p.background_audio_url,
})),
pageLinks,
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading
currentPageId: activePageId,
enabled: !isLoading && !!activePageId,
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
});
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage;
// Use shared background transition hook for direct navigation clearing and crossfade
// Crossfade starts automatically when new background is ready
const { isFadingIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
// Video transition overlay removal - instant (no fade) when background is ready
// Uses double RAF to ensure browser has painted the new background before removing overlay
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
// Wait for paint cycle to complete before removing overlay
// Double RAF ensures the new background is fully rendered
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
closeTransitionPreview();
setPendingTransitionComplete(false);
});
});
}
}, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]);
// Handle background ready state for pages without any background
useEffect(() => {
// Only mark ready immediately if there's no background media at all.
// For pages with image or video, CanvasBackground will call onBackgroundReady
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
if (!activePage?.background_image_url && !activePage?.background_video_url) {
setIsBackgroundReady(true);
}
}, [activePage?.background_image_url, activePage?.background_video_url]);
// Reset pending state when starting a new transition
useEffect(() => {
if (transitionPreview) {
setPendingTransitionComplete(false);
}
}, [transitionPreview]);
// Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => {
// Mark this page as initialized to prevent redundant effect calls
if (page) {
lastInitializedPageIdRef.current = page.id;
}
// Update consolidated background state (replaces 8 separate setters)
updateBackgroundFromPage(page);
// Use hook to resolve and set blob URLs for display
// Fade starts automatically when new background is ready (crossfade effect)
await pageSwitchToPage(
page
? {
id: page.id,
background_image_url: page.background_image_url,
background_video_url: page.background_video_url,
background_audio_url: page.background_audio_url,
}
: null,
() => {
if (page) {
// Use applyPageSelection for proper history management (pops on back)
applyPageSelection(page.id, isBack);
}
},
);
},
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
);
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseMode,
reverseVideoUrl: transitionPreview.reverseVideoUrl
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
: undefined,
durationSec: transitionPreview.durationSec,
targetPageId: pendingNavigationPageId || undefined,
displayName: transitionPreview.title,
isBack: transitionPreview.isBack, // Pass through for history management
}
: null,
onComplete: async (targetPageId, isBack) => {
const video = transitionVideoRef.current;
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId) || null;
// Use switchToPage which resolves blob URLs via usePageSwitch
// Pass isBack flag for proper history management (pops on back)
await switchToPage(targetPage, isBack ?? false);
clearSelection();
setSelectedMenuItem('none');
setErrorMessage('');
setIsBackgroundReady(false);
// Signal that transition video completed - wait for background to load
// Overlay will be removed instantly when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else {
video?.removeAttribute('src');
video?.load();
closeTransitionPreview();
setPendingTransitionComplete(false);
}
},
timeouts: {
playbackStartMs: 3000,
hardTimeoutMs: 45000,
},
features: {
useBlobUrl: true,
preDecodeImages: false, // We handle image loading via usePageSwitch
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
},
});
const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'tooltip',
'description',
];
const urls = elements
.filter(
(element) =>
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
)
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
.filter(Boolean);
return Array.from(new Set(urls));
}, [elements]);
// Icon preloading for smooth rendering
const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({
iconUrls: iconPreloadTargets,
enabled: !isLoading,
});
// Use the useAssetOptions hook to derive memoized asset options
const assetOptions = useAssetOptions({ assets });
// Media duration probing with caching
const durationProbeTargets = useMemo(
() =>
buildDurationProbeTargets({
backgroundVideoUrl,
backgroundAudioUrl,
selectedElement,
newTransitionVideoUrl,
elements,
isMediaElementType,
isVideoPlayerElementType,
isNavigationElementType,
}),
[
backgroundAudioUrl,
backgroundVideoUrl,
elements,
newTransitionVideoUrl,
selectedElement,
],
);
const { getDuration, getDurationNote, durationBySource } =
useMediaDurationProbe({
targets: durationProbeTargets,
});
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl);
const selectedMediaDurationNote = useMemo(() => {
if (!selectedElement || !isMediaElementType(selectedElement.type)) {
return 'Duration: unknown';
}
return getDurationNote(selectedElement.mediaUrl || '');
}, [getDurationNote, selectedElement]);
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
const selectedTransitionDurationNote = useMemo(() => {
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
return 'Duration: unknown';
}
return getDurationNote(selectedElement.transitionVideoUrl || '');
}, [getDurationNote, selectedElement]);
useEffect(() => {
if (newTransitionVideoUrl) return;
if (!assetOptions.transitionVideo.length) return;
setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value);
}, [newTransitionVideoUrl, assetOptions.transitionVideo]);
useEffect(() => {
setElements((prev) => {
let hasChanges = false;
const next = prev.map((element) => {
if (!isNavigationElementType(element.type)) return element;
const resolvedDuration = getDuration(element.transitionVideoUrl || '');
const nextDuration =
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
? Number(resolvedDuration)
: undefined;
if (element.transitionDurationSec === nextDuration) return element;
hasChanges = true;
return {
...element,
transitionDurationSec: nextDuration,
};
});
return hasChanges ? next : prev;
});
}, [getDuration]);
// Handle initial page selection when pages are loaded
const prevPagesLengthRef = useRef(0);
useEffect(() => {
// Only set initial page when pages first load (not on every change)
if (pages.length > 0 && prevPagesLengthRef.current === 0) {
const defaultPageId = pageIdFromRoute || pages[0]?.id || '';
// Use applyPageSelection to set initial page and history
applyPageSelection(defaultPageId, false);
setIsMenuOpen(false);
setIsInitializing(false);
}
prevPagesLengthRef.current = pages.length;
}, [pages, pageIdFromRoute, applyPageSelection]);
// Handle empty project case - mark initialized when data loads with no pages
// This allows the "Create First Page" button to show
useEffect(() => {
if (!isDataLoading && pages.length === 0) {
setIsInitializing(false);
}
}, [isDataLoading, pages.length]);
// Handle query errors
useEffect(() => {
if (isDataError && dataError) {
const message = dataError.message || 'Failed to load constructor data.';
logger.error('Failed to load constructor data:', dataError);
setErrorMessage(message);
}
}, [isDataError, dataError]);
// Refetch wrapper that preserves current page
const handleReload = useCallback(async () => {
const currentPageId = activePageId;
await refetchData();
// After refetch, restore the active page if it still exists
// This is handled by the pages effect above
if (currentPageId && pages.some((p) => p.id === currentPageId)) {
setActivePageId(currentPageId);
}
}, [activePageId, pages, refetchData]);
// Page actions (save, create page, save to stage)
const {
isSaving,
isSavingToStage,
isCreatingPage,
isCreatingTransition,
saveConstructor,
saveToStage,
createPage,
createTransition,
} = useConstructorPageActions({
projectId,
project,
pages,
activePage,
activePageId,
elements,
pageBackground,
onReload: handleReload,
onSetActivePageId: setActivePageId,
onSetMenuOpen: setIsMenuOpen,
onError: setErrorMessage,
onSuccess: setSuccessMessage,
});
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
const token =
sessionStorage.getItem('token') || localStorage.getItem('token');
if (!token) {
setIsAuthReady(false);
setErrorMessage('Please sign in to continue.');
router.replace('/login');
return;
}
setIsAuthReady(true);
}, [router]);
useEffect(() => {
if (!router.isReady) return;
if (projectId) return;
router.replace(
isElementEditMode
? '/project-element-defaults/project-element-defaults-list'
: '/projects/projects-list',
);
}, [isElementEditMode, projectId, router]);
// React Query handles data fetching automatically based on projectId and isAuthReady
// Panel initial positions are handled by useDraggable hooks
useEffect(() => {
if (!router.isReady || !isAuthReady || isLoading) return;
if (didSetInitialCanvasFocus.current) return;
if (!canvasRef.current) return;
didSetInitialCanvasFocus.current = true;
requestAnimationFrame(() => {
canvasRef.current?.focus({ preventScroll: true });
});
}, [isAuthReady, isLoading, router.isReady]);
useEffect(() => {
if (!activePage) {
setElements([]);
clearSelection();
updateBackgroundFromPage(null);
return;
}
const schema = parseJsonObject<ConstructorSchema>(
activePage.ui_schema_json,
{},
);
const normalizedElements = Array.isArray(schema.elements)
? schema.elements
.filter(
(item) =>
item && item.type && labelByType[item.type as CanvasElementType],
)
.map((item) => {
const elementType = item.type as CanvasElementType;
const normalizedElement: CanvasElement = {
...item,
id: String(item.id || createLocalId()),
label:
typeof item.label === 'string' && item.label.trim()
? item.label
: labelByType[elementType],
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
appearDurationSec: normalizeAppearDurationSec(
item.appearDurationSec,
),
galleryCards: Array.isArray(item.galleryCards)
? item.galleryCards.map((card: Partial<GalleryCard>) => ({
id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl ?? ''),
title: String(card?.title ?? ''),
description: String(card?.description ?? ''),
}))
: undefined,
galleryHeaderImageUrl:
typeof item.galleryHeaderImageUrl === 'string'
? item.galleryHeaderImageUrl
: undefined,
galleryTitle:
typeof item.galleryTitle === 'string'
? item.galleryTitle
: undefined,
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
? item.galleryInfoSpans.map(
(span: Partial<GalleryInfoSpan>) => ({
id: String(span?.id || createLocalId()),
text: String(span?.text ?? ''),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
}),
)
: undefined,
galleryColumns:
typeof item.galleryColumns === 'number'
? item.galleryColumns
: undefined,
carouselSlides: Array.isArray(item.carouselSlides)
? item.carouselSlides.map((slide: Partial<CarouselSlide>) => ({
id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl ?? ''),
caption: String(slide?.caption ?? ''),
}))
: undefined,
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
carouselPrevIconUrl:
typeof item.carouselPrevIconUrl === 'string'
? item.carouselPrevIconUrl
: '',
carouselNextIconUrl:
typeof item.carouselNextIconUrl === 'string'
? item.carouselNextIconUrl
: '',
// Carousel button positions
carouselPrevX:
typeof item.carouselPrevX === 'number'
? item.carouselPrevX
: undefined,
carouselPrevY:
typeof item.carouselPrevY === 'number'
? item.carouselPrevY
: undefined,
carouselNextX:
typeof item.carouselNextX === 'number'
? item.carouselNextX
: undefined,
carouselNextY:
typeof item.carouselNextY === 'number'
? item.carouselNextY
: undefined,
// Carousel button dimensions
carouselPrevWidth:
typeof item.carouselPrevWidth === 'string'
? item.carouselPrevWidth
: undefined,
carouselPrevHeight:
typeof item.carouselPrevHeight === 'string'
? item.carouselPrevHeight
: undefined,
carouselNextWidth:
typeof item.carouselNextWidth === 'string'
? item.carouselNextWidth
: undefined,
carouselNextHeight:
typeof item.carouselNextHeight === 'string'
? item.carouselNextHeight
: undefined,
// Gallery Carousel Settings
galleryCarouselPrevIconUrl:
typeof item.galleryCarouselPrevIconUrl === 'string'
? item.galleryCarouselPrevIconUrl
: '',
galleryCarouselNextIconUrl:
typeof item.galleryCarouselNextIconUrl === 'string'
? item.galleryCarouselNextIconUrl
: '',
galleryCarouselBackIconUrl:
typeof item.galleryCarouselBackIconUrl === 'string'
? item.galleryCarouselBackIconUrl
: '',
galleryCarouselBackLabel:
typeof item.galleryCarouselBackLabel === 'string'
? item.galleryCarouselBackLabel
: '',
galleryCarouselPrevX:
typeof item.galleryCarouselPrevX === 'number'
? item.galleryCarouselPrevX
: undefined,
galleryCarouselPrevY:
typeof item.galleryCarouselPrevY === 'number'
? item.galleryCarouselPrevY
: undefined,
galleryCarouselNextX:
typeof item.galleryCarouselNextX === 'number'
? item.galleryCarouselNextX
: undefined,
galleryCarouselNextY:
typeof item.galleryCarouselNextY === 'number'
? item.galleryCarouselNextY
: undefined,
galleryCarouselBackX:
typeof item.galleryCarouselBackX === 'number'
? item.galleryCarouselBackX
: undefined,
galleryCarouselBackY:
typeof item.galleryCarouselBackY === 'number'
? item.galleryCarouselBackY
: undefined,
tooltipTitle:
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
tooltipText:
typeof item.tooltipText === 'string' ? item.tooltipText : '',
descriptionTitle:
typeof item.descriptionTitle === 'string'
? item.descriptionTitle
: '',
descriptionText:
typeof item.descriptionText === 'string'
? item.descriptionText
: '',
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
navType:
item.navType === 'back' || item.navType === 'forward'
? item.navType
: isNavigationElementType(elementType)
? getNavigationButtonKind(
elementType as NavigationElementType,
)
: undefined,
// Support both targetPageSlug (new) and targetPageId (legacy)
targetPageSlug:
typeof item.targetPageSlug === 'string'
? item.targetPageSlug
: '',
targetPageId:
typeof item.targetPageId === 'string' ? item.targetPageId : '',
transitionVideoUrl:
typeof item.transitionVideoUrl === 'string'
? item.transitionVideoUrl
: '',
transitionReverseMode:
item.transitionReverseMode === 'separate_video'
? 'separate_video'
: ('auto_reverse' as const),
reverseVideoUrl:
typeof item.reverseVideoUrl === 'string'
? item.reverseVideoUrl
: '',
transitionDurationSec: item.transitionDurationSec
? Number(item.transitionDurationSec)
: undefined,
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
mediaAutoplay:
typeof item.mediaAutoplay === 'boolean'
? item.mediaAutoplay
: true,
mediaLoop:
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
mediaMuted:
typeof item.mediaMuted === 'boolean'
? item.mediaMuted
: isVideoPlayerElementType(item.type),
};
return mergeElementWithDefaults(
normalizedElement,
uiElementDefaultsByType[elementType],
{ preferElementValues: true },
);
})
: [];
setElements(normalizedElements);
setSelectedMenuItem('none');
const currentSelectedId = selectedElementIdRef.current;
if (!normalizedElements.length) {
clearSelection();
} else if (
elementIdFromRoute &&
normalizedElements.some((element) => element.id === elementIdFromRoute)
) {
selectElement(elementIdFromRoute);
} else if (
currentSelectedId &&
!normalizedElements.some((element) => element.id === currentSelectedId)
) {
// Current selection no longer valid
clearSelection();
}
// If current selection is still valid, do nothing (keep current)
// Update consolidated background state (replaces 8 separate setters)
updateBackgroundFromPage(activePage);
// Resolve blob URLs via hook for display (handles initial load and route changes)
// Only call if this page wasn't already initialized via switchToPage function
if (lastInitializedPageIdRef.current !== activePage.id) {
lastInitializedPageIdRef.current = activePage.id;
pageSwitchToPage({
id: activePage.id,
background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url,
background_audio_url: activePage.background_audio_url,
});
}
}, [
activePage,
elementIdFromRoute,
uiElementDefaultsByType,
pageSwitchToPage,
clearSelection,
selectElement,
setElements,
updateBackgroundFromPage,
]);
useEffect(() => {
if (allowedNavigationTypes.length !== 1) return;
const forcedType = allowedNavigationTypes[0];
setElements((prev) => {
let hasChanges = false;
const nextElements = prev.map((element) => {
if (
!isNavigationElementType(element.type) ||
element.type === forcedType
)
return element;
hasChanges = true;
return normalizeNavigationType(element, forcedType);
});
return hasChanges ? nextElements : prev;
});
}, [allowedNavigationTypes, normalizeNavigationType, setElements]);
// Element drag is now handled by useCanvasElementDrag hook
useEffect(() => {
if (isConstructorEditMode) return;
cancelElementDrag();
clearSelection();
setSelectedMenuItem('none');
}, [isConstructorEditMode, cancelElementDrag, clearSelection]);
// Outside click detection to clear element/menu selection
// Ignore clicks on menu to allow menu item selection
useOutsideClick({
containerRef: elementEditorRef,
ignoreRefs: [menuRef],
ignoreDataAttribute: 'data-constructor-element-id',
selectedValue: selectedElementId,
onOutsideClick: useCallback(() => {
clearSelection();
setSelectedMenuItem('none');
}, [clearSelection]),
enabled:
isConstructorEditMode &&
(!!selectedElementId || selectedMenuItem !== 'none'),
});
// Thin wrappers for hook functions (handle additional state like selectedMenuItem)
const selectElementForEdit = useCallback(
(elementId: string) => {
selectElement(elementId);
// Note: setSelectedMenuItem('none') is handled by onElementSelected callback
},
[selectElement],
);
const selectMenuItemForEdit = useCallback(
(item: EditorMenuItem) => {
clearSelection();
setSelectedMenuItem(item);
},
[clearSelection],
);
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
if (!isConstructorEditMode) return;
const currentElement = elements.find((item) => item.id === elementId);
if (!currentElement) return;
// Select the element for editing
selectElementForEdit(elementId);
// Start drag with current position
onElementDragStart(
event,
elementId,
currentElement.xPercent,
currentElement.yPercent,
);
};
// openTransitionPreviewForElement is now provided by useTransitionPreview hook
const openTransitionPreview = (direction: 'forward' | 'back') => {
if (
!selectedElement ||
(selectedElement.type !== 'navigation_next' &&
selectedElement.type !== 'navigation_prev')
) {
return;
}
openTransitionPreviewForElement(selectedElement, direction);
};
const onCanvasElementClick = (element: CanvasElement) => {
if (!isConstructorEditMode) {
if (isNavigationElementType(element.type)) {
// Disable navigation while transition is playing or buffering
if (transitionPreview || isReverseBuffering) {
return;
}
if (element.navDisabled) {
return;
}
// DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded
// Back navigation is always allowed (previous pages are already visited)
if (
false &&
!isBackNavigation(element) &&
!preloadOrchestrator.areNeighborBackgroundsReady
) {
logger.info('Navigation blocked - neighbors not preloaded');
return;
}
// Use shared navigation helpers
const direction = getNavigationDirection(element);
// Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext();
// Pass history context for history-based back navigation
const navTarget = resolveNavigationTarget(element, pages, navContext);
if (!navTarget) {
// Back buttons always use history, forward buttons use target page
if (isBackNavigation(element)) {
if (!navContext.previousPageId) {
setErrorMessage(
'No previous page in history. Navigate to another page first.',
);
} else {
setErrorMessage(
'Previous page not found. It may have been deleted.',
);
}
} else {
setErrorMessage(
'No target page configured for this navigation button.',
);
}
return;
}
// For back navigation, use transition from navTarget (the forward element that brought us here)
// For forward navigation, use the element's own transition settings
const transitionSource = isBackNavigation(element)
? {
type: element.type,
transitionVideoUrl: navTarget.transitionVideoUrl,
transitionReverseMode: navTarget.transitionReverseMode,
reverseVideoUrl: navTarget.reverseVideoUrl,
}
: element;
// Check if transition can be played using shared helper
if (!hasPlayableTransition(transitionSource, direction)) {
closeTransitionPreview();
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
// Pass isBack flag for proper history management
switchToPage(navTarget.page, navTarget.isBack).then(() => {
clearSelection();
setSelectedMenuItem('none');
setErrorMessage('');
});
return;
}
openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
}
return;
}
selectElementForEdit(element.id);
};
// Handler for gallery card clicks
const handleGalleryCardClick = useCallback(
(element: CanvasElement, cardIndex: number) => {
if (element.galleryCards && element.galleryCards.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
},
[],
);
// Handler for gallery carousel button position changes (constructor only)
const handleGalleryCarouselButtonPositionChange = useCallback(
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
if (!activeGalleryCarousel) return;
const positionPatch =
button === 'prev'
? { galleryCarouselPrevX: x, galleryCarouselPrevY: y }
: button === 'next'
? { galleryCarouselNextX: x, galleryCarouselNextY: y }
: { galleryCarouselBackX: x, galleryCarouselBackY: y };
updateSelectedElement(positionPatch);
// Update the active carousel element to reflect the new positions
setActiveGalleryCarousel((prev) =>
prev
? { ...prev, element: { ...prev.element, ...positionPatch } }
: null,
);
},
[activeGalleryCarousel, updateSelectedElement],
);
// Handler for carousel element button position changes (constructor only)
const handleCarouselButtonPositionChange = useCallback(
(elementId: string, button: 'prev' | 'next', x: number, y: number) => {
const positionPatch =
button === 'prev'
? { carouselPrevX: x, carouselPrevY: y }
: { carouselNextX: x, carouselNextY: y };
setElements((prev) =>
prev.map((el) =>
el.id === elementId ? { ...el, ...positionPatch } : el,
),
);
},
[setElements],
);
const isElementVisibleOnCanvas = (element: CanvasElement) =>
isElementVisibleAtTime(
canvasElapsedSec,
element.appearDelaySec,
element.appearDurationSec,
);
const isElementReadyForCanvasRender = (element: CanvasElement) => {
const isPreloadableIconElement =
(isNavigationElementType(element.type) ||
isTooltipElementType(element.type) ||
isDescriptionElementType(element.type)) &&
Boolean(element.iconUrl);
if (!isPreloadableIconElement) return true;
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
if (!playbackUrl) return true;
return Boolean(preloadedIconUrlMap[playbackUrl]);
};
// URL resolver that uses preloaded blob URLs when available
// Depends on readyUrlsVersion to re-render when blob URLs become ready after preload
const resolveUrlWithBlob = useCallback(
(url: string | undefined): string => {
if (!url) return '';
// Try to get blob URL from preload orchestrator (instant display)
// Check storage key first (most reliable), then resolved URL
const blobUrl =
preloadOrchestrator.getReadyBlobUrl(url) ||
preloadOrchestrator.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
if (blobUrl) return blobUrl;
// Fall back to standard resolution
return resolveAssetPlaybackUrl(url);
},
[preloadOrchestrator, preloadOrchestrator.readyUrlsVersion],
);
const canvasBackgroundStyle: React.CSSProperties = {};
// Unified background URL resolution via shared hook
// Priority: local paths (editing) > pageSwitch (navigation)
const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } =
useBackgroundUrls({
pageSwitch,
resolveUrl: resolveUrlWithBlob,
localPaths: {
imageUrl: backgroundImageUrl,
videoUrl: backgroundVideoUrl,
audioUrl: backgroundAudioUrl,
},
});
const hasEditorSelection =
isConstructorEditMode &&
(Boolean(selectedElement) || selectedMenuItem !== 'none');
const editorTitle =
selectedMenuItem === 'background_image'
? 'Background image'
: selectedMenuItem === 'background_video'
? 'Background video'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedMenuItem === 'create_transition'
? 'Create transition'
: selectedElement?.label || 'Element editor';
// Background image is rendered by CanvasBackground component (same as runtime)
// No CSS background-image needed on canvas div
// Duration notes for UI display
const durationNotes = useMemo(
() => ({
backgroundVideo: backgroundVideoDurationNote,
backgroundAudio: backgroundAudioDurationNote,
selectedMedia: selectedMediaDurationNote,
selectedTransition: selectedTransitionDurationNote,
newTransition: newTransitionDurationNote,
}),
[
backgroundVideoDurationNote,
backgroundAudioDurationNote,
selectedMediaDurationNote,
selectedTransitionDurationNote,
newTransitionDurationNote,
],
);
// Transition creation state for context
const transitionCreationState = useMemo(
() => ({
name: newTransitionName,
videoUrl: newTransitionVideoUrl,
supportsReverse: newTransitionSupportsReverse,
isCreating: isCreatingTransition,
setName: setNewTransitionName,
setVideoUrl: setNewTransitionVideoUrl,
setSupportsReverse: setNewTransitionSupportsReverse,
create: () =>
createTransition({
name: newTransitionName,
videoUrl: newTransitionVideoUrl,
supportsReverse: newTransitionSupportsReverse,
durationSec: getDuration(newTransitionVideoUrl),
}),
}),
[
newTransitionName,
newTransitionVideoUrl,
newTransitionSupportsReverse,
isCreatingTransition,
createTransition,
getDuration,
],
);
// Build context value for ConstructorProvider
// This allows child components to access constructor state without prop drilling
const constructorContextValue: ConstructorContextValue = useMemo(
() => ({
// Project state
projectId,
// Page state
pages,
activePageId,
activePage,
setActivePageId,
// Background state
pageBackground,
setPageBackground,
updateBackgroundFromPage,
// Background convenience setters
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
// Element state
elements,
setElements,
selectedElementId,
selectedElement,
selectElement,
clearSelection,
updateElement: (id: string, patch: Partial<CanvasElement>) => {
setElements((prev) =>
prev.map((el) => (el.id === id ? { ...el, ...patch } : el)),
);
},
removeElement: (id: string) => {
setElements((prev) => prev.filter((el) => el.id !== id));
if (selectedElementId === id) {
clearSelection();
}
},
updateSelectedElement,
removeSelectedElement,
// Menu state
selectedMenuItem,
setSelectedMenuItem,
isMenuOpen,
setIsMenuOpen,
// Editor state
elementEditorTab,
setElementEditorTab,
// Assets
assets,
isLoadingAssets: isDataLoading,
// Asset options (derived from assets)
assetOptions,
// Gallery/Carousel operations
galleryCards,
galleryInfoSpans,
carouselSlides,
// Duration resolver
getDuration,
// Duration notes
durationNotes,
// Transition preview
onPreviewTransition: openTransitionPreview,
// Transition creation
transitionCreation: transitionCreationState,
// Navigation settings
allowedNavigationTypes,
normalizeNavigationType,
// Actions
save: saveConstructor,
isSaving,
}),
[
projectId,
pages,
activePageId,
activePage,
pageBackground,
setPageBackground,
updateBackgroundFromPage,
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
elements,
setElements,
selectedElementId,
selectedElement,
selectElement,
clearSelection,
updateSelectedElement,
removeSelectedElement,
selectedMenuItem,
isMenuOpen,
elementEditorTab,
assets,
isDataLoading,
assetOptions,
galleryCards,
galleryInfoSpans,
carouselSlides,
getDuration,
durationNotes,
openTransitionPreview,
transitionCreationState,
allowedNavigationTypes,
normalizeNavigationType,
saveConstructor,
isSaving,
],
);
return (
<ConstructorProvider value={constructorContextValue}>
<Head>
<title>
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
</title>
</Head>
<div className='relative w-screen h-screen bg-black overflow-hidden'>
<div className='absolute top-4 left-4 z-[1000] flex max-w-[80vw] flex-col gap-2'>
<p className='text-xs font-semibold text-gray-700'>
{projectName || 'Loading project...'}
</p>
{errorMessage ? (
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
{errorMessage}
</p>
) : null}
{successMessage ? (
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
{successMessage}
</p>
) : null}
{pages.length > 0 && isElementEditMode && (
<div className='flex items-center gap-2'>
<BaseButton
color='lightDark'
label='Back to Elements'
icon={mdiExitToApp}
href={pageElementsListHref}
/>
<BaseButton
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={saveConstructor}
disabled={isSaving}
/>
</div>
)}
</div>
{pages.length > 0 && !isElementEditMode && (
<ConstructorControlsPanel
projectId={projectId}
pages={pages}
activePageId={activePageId}
interactionMode={constructorInteractionMode}
position={constructorControlsPosition}
onPageChange={(pageId) => {
const page = pages.find((p) => p.id === pageId);
if (page) switchToPage(page);
}}
onModeChange={setConstructorInteractionMode}
onDragStart={onConstructorControlsDragStart}
/>
)}
{/* Canvas container: z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body */}
<div
ref={canvasRef}
tabIndex={-1}
className={`relative z-[46] overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
style={{
...canvasCssVars,
...letterboxStyles,
...canvasBackgroundStyle,
}}
>
<BackdropPortalProvider>
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10) */}
<div
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
<CanvasBackground
backgroundImageUrl={backgroundImageSrc}
backgroundVideoUrl={backgroundVideoSrc}
backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={pageSwitch.previousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn}
onBackgroundReady={() => {
pageSwitch.markBackgroundReady();
setIsBackgroundReady(true);
}}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted}
videoStartTime={backgroundVideoStartTime}
videoEndTime={backgroundVideoEndTime}
videoStoragePath={backgroundVideoUrl || activePage?.background_video_url}
/>
</div>
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top. */}
<div
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
{isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-sm text-gray-500'>
Loading constructor...
</p>
</div>
) : pages.length === 0 ? (
<div className='absolute inset-0 flex items-center justify-center'>
<BaseButton
color='info'
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
icon={mdiPlus}
onClick={createPage}
disabled={isCreatingPage}
/>
</div>
) : (
elements.map((element) => {
const shouldRender =
selectedElementId === element.id ||
(isElementVisibleOnCanvas(element) &&
isElementReadyForCanvasRender(element));
if (!shouldRender) return null;
// Compute disabled state for navigation elements
// Disabled when:
// - Element explicitly disabled (navDisabled)
// - Transition is playing or buffering
// - DISABLED: Neighbor backgrounds not yet preloaded (forward only)
const isForwardNav =
isNavigationElementType(element.type) &&
!isBackNavigation(element);
const isNavDisabled =
isNavigationElementType(element.type) &&
(element.navDisabled ||
Boolean(transitionPreview) ||
isReverseBuffering ||
(false &&
isForwardNav &&
!preloadOrchestrator.areNeighborBackgroundsReady));
return (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
isEditMode={isConstructorEditMode}
isDisabled={isNavDisabled}
onClick={() => onCanvasElementClick(element)}
onMouseDown={(event) =>
onElementMouseDown(event, element.id)
}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
onCarouselButtonPositionChange={(button, x, y) =>
handleCarouselButtonPositionChange(
element.id,
button,
x,
y,
)
}
letterboxStyles={letterboxStyles}
/>
);
})
)}
</div>
</BackdropPortalProvider>
</div>
{/* ElementEditorPanel now uses ConstructorContext for all state */}
{pages.length > 0 && hasEditorSelection && (
<ElementEditorPanel
elementEditorRef={elementEditorRef}
position={editorPosition}
isCollapsed={isEditorCollapsed}
onToggleCollapse={() => setIsEditorCollapsed((prev) => !prev)}
onDragStart={onElementEditorDragStart}
title={editorTitle}
/>
)}
{pages.length > 0 && !isElementEditMode && (
<ConstructorMenu
ref={menuRef}
position={menuPosition}
isOpen={isMenuOpen}
allowedNavigationTypes={allowedNavigationTypes}
isCreatingPage={isCreatingPage}
isSaving={isSaving}
isSavingToStage={isSavingToStage}
onDragStart={onMenuDragStart}
onToggleOpen={() => setIsMenuOpen((prev) => !prev)}
onSelectMenuItem={selectMenuItemForEdit}
onAddElement={addElement}
onCreatePage={createPage}
onSave={saveConstructor}
onSaveToStage={saveToStage}
onExit={() =>
router.push(
projectId
? `/projects/${projectId}`
: '/projects/projects-list',
)
}
/>
)}
</div>
<TransitionPreviewOverlay
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering}
letterboxStyles={letterboxStyles}
/>
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles}
isEditMode={isConstructorEditMode}
onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
/>
)}
<style jsx>{`
.menu-action-btn {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 6px 8px;
border-radius: 6px;
color: #1f2937;
text-align: left;
}
.menu-action-btn:hover {
background: #f3f4f6;
}
`}</style>
</ConstructorProvider>
);
};
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ConstructorPage;