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 { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { logger } from '../lib/logger'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { parseJsonObject } from '../lib/parseJson'; import { resolveNavigationTarget, hasPlayableTransition, getNavigationDirection, } from '../lib/navigationHelpers'; import { mergeElementWithDefaults, createLocalId, normalizeAppearDelaySec, normalizeAppearDurationSec, ELEMENT_TYPE_LABELS, getNavigationButtonKind, isNavigationElementType, isTooltipElementType, isDescriptionElementType, isMediaElementType, isVideoPlayerElementType, } from '../lib/elementDefaults'; import type { PreloadPageLink, PreloadElement } from '../types/preload'; import type { CanvasElementType, CanvasElement, ConstructorSchema, ConstructorAsset as ProjectAsset, NormalizedElementDefault, } from '../types/constructor'; import { normalizeElementDefault, buildElementDefaultsMap, } from '../types/constructor'; // 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'; // Constructor helpers (extracted utilities) import { clamp, getAssetLabel, getAssetSourceValue, isBackgroundImageAsset, } from '../lib/constructorHelpers'; type TourPage = { id: string; name?: string; slug?: string; sort_order?: number; environment?: 'dev' | 'stage' | 'production'; source_key?: string; requires_auth?: boolean; ui_schema_json?: string; background_image_url?: string; background_video_url?: string; background_audio_url?: string; background_loop?: boolean; }; type NavigationElementType = Extract< CanvasElementType, 'navigation_next' | 'navigation_prev' >; type EditorMenuItem = | 'none' | 'background_image' | 'background_video' | 'background_audio' | 'create_transition'; 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(null); const elementEditorRef = useRef(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]); const [pages, setPages] = useState([]); const [pageLinks, setPageLinks] = useState([]); const [allPagesPreloadElements, setAllPagesPreloadElements] = useState< PreloadElement[] >([]); const [assets, setAssets] = useState([]); const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState< Partial>> >({}); const [activePageId, setActivePageId] = useState(''); const [projectName, setProjectName] = useState(''); const [backgroundImageUrl, setBackgroundImageUrl] = useState(''); const [backgroundVideoUrl, setBackgroundVideoUrl] = useState(''); const [backgroundAudioUrl, setBackgroundAudioUrl] = useState(''); const [selectedMenuItem, setSelectedMenuItem] = useState('none'); // Transition preview state managed by useTransitionPreview hook (below) const [isLoading, setIsLoading] = useState(true); // 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('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); const isConstructorEditMode = constructorInteractionMode === 'edit'; const allowedNavigationTypes = useMemo(() => { 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.'); }, []), }); // 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(null); const lastInitializedPageIdRef = useRef(null); const didSetInitialCanvasFocus = useRef(false); const selectedElementIdRef = useRef(''); 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, }); // 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 const switchToPage = useCallback( async (page: TourPage | null) => { // Mark this page as initialized to prevent redundant effect calls if (page) { lastInitializedPageIdRef.current = page.id; } // Update storage path state (for editing and saving) setBackgroundImageUrl(page?.background_image_url || ''); setBackgroundVideoUrl(page?.background_video_url || ''); setBackgroundAudioUrl(page?.background_audio_url || ''); // Use hook to resolve and set blob URLs for display await pageSwitch.switchToPage( 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) { setActivePageId(page.id); } }, ); }, [pageSwitch], ); 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, } : null, onComplete: async (targetPageId) => { const video = transitionVideoRef.current; if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId) || null; // Use switchToPage which resolves blob URLs via usePageSwitch await switchToPage(targetPage); clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); requestAnimationFrame(() => { requestAnimationFrame(() => { video?.removeAttribute('src'); video?.load(); closeTransitionPreview(); }); }); } else { video?.removeAttribute('src'); video?.load(); closeTransitionPreview(); } }, 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, }, }); // Use shared background transition hook for direct navigation clearing // (No fade-out needed in constructor - transitions complete immediately) useBackgroundTransition({ pageSwitch }); 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, }); const imageAssetOptions = useMemo( () => assets .filter( (asset) => asset.asset_type === 'image' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [assets], ); const backgroundImageAssetOptions = useMemo( () => assets .filter( (asset) => asset.asset_type === 'image' && getAssetSourceValue(asset) && isBackgroundImageAsset(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [assets], ); const videoAssetOptions = useMemo( () => assets .filter( (asset) => asset.asset_type === 'video' && asset.type !== 'transition' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [assets], ); const audioAssetOptions = useMemo( () => assets .filter( (asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [assets], ); const transitionVideoAssetOptions = useMemo(() => { const typedAssets = assets .filter( (asset) => asset.type === 'transition' && asset.asset_type === 'video' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })); if (typedAssets.length > 0) return typedAssets; const taggedAssets = assets .filter( (asset) => asset.asset_type === 'video' && getAssetSourceValue(asset) && /\[TRANSITION\]/i.test(String(asset.name || '')), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })); return taggedAssets; }, [assets]); const iconAssetOptions = useMemo( () => assets .filter( (asset) => asset.type === 'icon' && asset.asset_type === 'image' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [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 } = 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 (!transitionVideoAssetOptions.length) return; setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value); }, [newTransitionVideoUrl, transitionVideoAssetOptions]); 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]); const loadData = useCallback( async (preservePageId?: string) => { if (!projectId || !router.isReady || !isAuthReady) return; try { setIsLoading(true); setErrorMessage(''); setSuccessMessage(''); const [ projectResponse, pagesResponse, assetsResponse, uiElementsResponse, ] = await Promise.all([ axios.get(`/projects/${projectId}`), axios.get( `/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`, ), axios.get( `/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${projectId}`, ), axios.get( `/project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`, ), ]); const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows) ? pagesResponse.data.rows : []; const assetRows: ProjectAsset[] = Array.isArray( assetsResponse?.data?.rows, ) ? assetsResponse.data.rows : []; setProjectName(projectResponse?.data?.name || ''); setPages(pageRows); // Extract page links and preload elements using shared utility const { pageLinks: syntheticPageLinks, preloadElements: allPreloadElements, } = extractPageLinksAndElements(pageRows); setPageLinks(syntheticPageLinks); setAllPagesPreloadElements(allPreloadElements); setAssets(assetRows); // Process project element defaults using shared utilities const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows) ? uiElementsResponse.data.rows : []; const normalizedDefaults = uiElementRows .map((row: Record) => normalizeElementDefault(row)) .filter( ( d: NormalizedElementDefault | null, ): d is NormalizedElementDefault => d !== null, ); const defaultsByType = buildElementDefaultsMap(normalizedDefaults); setUiElementDefaultsByType(defaultsByType); // Preserve current page if specified and it still exists, otherwise use route or first page const preservedPageExists = preservePageId && pageRows.some((p: any) => p.id === preservePageId); const defaultPageId = preservedPageExists ? preservePageId : pageIdFromRoute || pageRows[0]?.id || ''; setActivePageId(defaultPageId); setIsMenuOpen(false); } catch (error: any) { if (error?.response?.status === 401) { const message = 'Your session has expired. Please sign in again.'; logger.error( 'Unauthorized constructor request:', error instanceof Error ? error : { error }, ); setErrorMessage(message); setPages([]); setAssets([]); router.replace('/login'); return; } const message = error?.response?.data?.message || error?.message || 'Failed to load constructor data.'; logger.error( 'Failed to load constructor data:', error instanceof Error ? error : { error }, ); setErrorMessage(message); setPages([]); setAssets([]); setUiElementDefaultsByType({}); } finally { setIsLoading(false); } }, [isAuthReady, pageIdFromRoute, projectId, router], ); // Page actions (save, create page, save to stage) const { isSaving, isSavingToStage, isCreatingPage, isCreatingTransition, saveConstructor, saveToStage, createPage, createTransition, } = useConstructorPageActions({ projectId, pages, activePage, activePageId, elements, backgroundImageUrl, backgroundVideoUrl, backgroundAudioUrl, onReload: loadData, 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]); useEffect(() => { loadData(); }, [loadData]); // 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(); setBackgroundImageUrl(''); setBackgroundVideoUrl(''); setBackgroundAudioUrl(''); return; } const schema = parseJsonObject( 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: any, index: number) => ({ id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl || ''), title: String(card?.title || `Card ${index + 1}`), 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: any) => ({ id: String(span?.id || createLocalId()), text: String(span?.text || ''), })) : undefined, galleryColumns: typeof item.galleryColumns === 'number' ? item.galleryColumns : undefined, carouselSlides: Array.isArray(item.carouselSlides) ? item.carouselSlides.map((slide: any, index: number) => ({ id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl || ''), caption: String(slide?.caption || `Slide ${index + 1}`), })) : undefined, iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '', carouselPrevIconUrl: typeof item.carouselPrevIconUrl === 'string' ? item.carouselPrevIconUrl : '', carouselNextIconUrl: typeof item.carouselNextIconUrl === 'string' ? item.carouselNextIconUrl : '', // 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) // Set storage paths for editing/saving setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundAudioUrl(activePage.background_audio_url || ''); // 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; pageSwitch.switchToPage({ 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, pageSwitch.switchToPage, clearSelection, selectElement, setElements, ]); 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 useOutsideClick({ containerRef: elementEditorRef, 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; } // Use shared navigation helpers const direction = getNavigationDirection(element); const navTarget = resolveNavigationTarget(element, pages); if (!navTarget) { setErrorMessage( 'No target page configured for this navigation button.', ); return; } // Check if transition can be played using shared helper if (!hasPlayableTransition(element, direction)) { closeTransitionPreview(); // Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash) switchToPage(navTarget.page).then(() => { clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); }); return; } openPreviewWithTarget(element, 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 handleCarouselButtonPositionChange = 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], ); 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 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], ); const canvasBackgroundStyle: React.CSSProperties = {}; // Prefer hook's blob URLs, then try cached blob URLs, finally fall back to direct URLs const backgroundImageSrc = pageSwitch.currentBgImageUrl || resolveUrlWithBlob(backgroundImageUrl); const backgroundVideoSrc = pageSwitch.currentBgVideoUrl || resolveUrlWithBlob(backgroundVideoUrl); const backgroundAudioSrc = pageSwitch.currentBgAudioUrl || resolveUrlWithBlob(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'; if (backgroundImageSrc) { canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`; canvasBackgroundStyle.backgroundSize = 'cover'; canvasBackgroundStyle.backgroundPosition = 'center'; } return ( <> {getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}

{projectName || 'Loading project...'}

{errorMessage ? (

{errorMessage}

) : null} {successMessage ? (

{successMessage}

) : null} {pages.length > 0 && isElementEditMode && (
)}
{pages.length > 0 && !isElementEditMode && ( { const page = pages.find((p) => p.id === pageId); if (page) switchToPage(page); }} onModeChange={setConstructorInteractionMode} onDragStart={onConstructorControlsDragStart} /> )}
pageSwitch.markBackgroundReady()} /> {/* Elements container - z-10 ensures they appear above backdrop layer */}
{isLoading ? (

Loading constructor...

) : pages.length === 0 ? (
) : ( elements.map((element) => { const shouldRender = selectedElementId === element.id || (isElementVisibleOnCanvas(element) && isElementReadyForCanvasRender(element)); if (!shouldRender) return null; const isNavDisabled = isNavigationElementType(element.type) && (element.navDisabled || Boolean(transitionPreview) || isReverseBuffering); return ( onCanvasElementClick(element)} onMouseDown={(event) => onElementMouseDown(event, element.id) } resolveUrl={resolveUrlWithBlob} onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex) } /> ); }) )}
{pages.length > 0 && hasEditorSelection && ( setIsEditorCollapsed((prev) => !prev)} onDragStart={onElementEditorDragStart} title={editorTitle} activeTab={elementEditorTab} onTabChange={setElementEditorTab} selectedElement={selectedElement} selectedMenuItem={selectedMenuItem} onRemoveElement={removeSelectedElement} onUpdateElement={updateSelectedElement} backgroundImageUrl={backgroundImageUrl} backgroundVideoUrl={backgroundVideoUrl} backgroundAudioUrl={backgroundAudioUrl} onBackgroundImageChange={setBackgroundImageUrl} onBackgroundVideoChange={setBackgroundVideoUrl} onBackgroundAudioChange={setBackgroundAudioUrl} newTransitionName={newTransitionName} newTransitionVideoUrl={newTransitionVideoUrl} newTransitionSupportsReverse={newTransitionSupportsReverse} isCreatingTransition={isCreatingTransition} onNewTransitionNameChange={setNewTransitionName} onNewTransitionVideoUrlChange={setNewTransitionVideoUrl} onNewTransitionSupportsReverseChange={ setNewTransitionSupportsReverse } onCreateTransition={() => createTransition({ name: newTransitionName, videoUrl: newTransitionVideoUrl, supportsReverse: newTransitionSupportsReverse, durationSec: getDuration(newTransitionVideoUrl), }) } backgroundVideoDurationNote={backgroundVideoDurationNote} backgroundAudioDurationNote={backgroundAudioDurationNote} newTransitionDurationNote={newTransitionDurationNote} selectedMediaDurationNote={selectedMediaDurationNote} selectedTransitionDurationNote={selectedTransitionDurationNote} backgroundImageAssetOptions={backgroundImageAssetOptions} videoAssetOptions={videoAssetOptions} audioAssetOptions={audioAssetOptions} transitionVideoAssetOptions={transitionVideoAssetOptions} iconAssetOptions={iconAssetOptions} imageAssetOptions={imageAssetOptions} allowedNavigationTypes={allowedNavigationTypes} pages={pages} activePageId={activePageId} onPreviewTransition={openTransitionPreview} galleryCards={galleryCards} galleryInfoSpans={galleryInfoSpans} carouselSlides={carouselSlides} normalizeNavigationType={normalizeNavigationType} getDuration={getDuration} /> )} {pages.length > 0 && !isElementEditMode && ( setIsMenuOpen((prev) => !prev)} onSelectMenuItem={selectMenuItemForEdit} onAddElement={addElement} onCreatePage={createPage} onSave={saveConstructor} onSaveToStage={saveToStage} onExit={() => router.push( projectId ? `/projects/${projectId}` : '/projects/projects-list', ) } /> )}
{/* Gallery Carousel Overlay */} {activeGalleryCarousel && ( 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} isEditMode={isConstructorEditMode} onButtonPositionChange={handleCarouselButtonPositionChange} /> )} ); }; ConstructorPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; export default ConstructorPage;