import { mdiContentSave, mdiExitToApp, mdiImageMultiple, mdiMenu, mdiPlus, mdiSwapHorizontal, mdiText, mdiTooltipText, mdiViewCarousel, } 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 BaseIcon from '../components/BaseIcon'; import { baseURLApi, getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; type TourPage = { id: string; name?: string; slug?: string; sort_order?: number; environment?: string; 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 ProjectAsset = { id: string; name?: string; asset_type?: 'image' | 'video' | 'audio' | 'file'; type?: | 'icon' | 'background_image' | 'audio' | 'video' | 'transition' | 'logo' | 'favicon' | 'document' | 'general'; cdn_url?: string | null; storage_key?: string | null; }; type AssetOption = { value: string; label: string; }; type CanvasElementType = | 'navigation_next' | 'navigation_prev' | 'gallery' | 'carousel' | 'tooltip' | 'description' | 'video_player' | 'audio_player'; type NavigationElementType = Extract< CanvasElementType, 'navigation_next' | 'navigation_prev' >; type NavigationButtonKind = 'forward' | 'back'; type CanvasElement = { id: string; type: CanvasElementType; label: string; xPercent: number; yPercent: number; width?: string; height?: string; minWidth?: string; maxWidth?: string; minHeight?: string; maxHeight?: string; margin?: string; padding?: string; gap?: string; fontSize?: string; lineHeight?: string; fontWeight?: string; border?: string; borderRadius?: string; opacity?: string; boxShadow?: string; display?: string; position?: string; justifyContent?: string; alignItems?: string; textAlign?: string; zIndex?: string; appearDelaySec?: number; appearDurationSec?: number | null; iconUrl?: string; galleryCards?: GalleryCard[]; carouselSlides?: CarouselSlide[]; carouselPrevIconUrl?: string; carouselNextIconUrl?: string; tooltipTitle?: string; tooltipText?: string; descriptionTitle?: string; descriptionText?: string; descriptionTitleFontSize?: string; descriptionTextFontSize?: string; descriptionBackgroundColor?: string; navLabel?: string; navType?: NavigationButtonKind; targetPageId?: string; transitionVideoUrl?: string; transitionReverseMode?: 'auto_reverse' | 'separate_video'; reverseVideoUrl?: string; transitionDurationSec?: number; mediaUrl?: string; mediaAutoplay?: boolean; mediaLoop?: boolean; mediaMuted?: boolean; }; type GalleryCard = { id: string; imageUrl: string; title: string; description: string; }; type CarouselSlide = { id: string; imageUrl: string; caption: string; }; type ConstructorSchema = { elements?: CanvasElement[]; }; type UiElementDefault = { id: string; element_type?: string; is_active?: boolean; default_settings_json?: Partial | string | null; }; type DragElementState = { id: string; pointerOffsetX: number; pointerOffsetY: number; }; type TransitionPreviewState = { videoUrl: string; reverseMode: 'none' | 'reverse' | 'separate'; reverseVideoUrl?: string; durationSec?: number; title: string; }; type EditorMenuItem = | 'none' | 'background_image' | 'background_video' | 'background_audio' | 'create_transition'; type ConstructorPageProps = { mode?: 'constructor' | 'element_edit'; }; type ConstructorInteractionMode = 'edit' | 'interact'; const parseJsonObject = (value?: unknown, fallback?: T): T => { if (!value) return (fallback || ({} as T)) as T; try { if (typeof value === 'string') { const parsed = JSON.parse(value); return (parsed || fallback || {}) as T; } if (typeof value === 'object') { return value as T; } return (fallback || ({} as T)) as T; } catch (error) { console.error('Failed to parse constructor JSON:', error); return (fallback || ({} as T)) as T; } }; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const normalizeAppearDelaySec = (value: unknown) => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) return 0; return Number(parsed); }; const normalizeAppearDurationSec = (value: unknown) => { if (value === null || value === undefined || value === '') return null; const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return null; return Number(parsed); }; const getTrimmedCssValue = (value: unknown) => { if (value === null || value === undefined) return ''; return String(value).trim(); }; const createLocalId = () => { if (typeof window !== 'undefined' && window.crypto?.randomUUID) { return window.crypto.randomUUID(); } return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }; const getAssetLabel = (asset: ProjectAsset) => { const baseName = asset.name?.trim() || 'Untitled asset'; const source = String(asset.storage_key || asset.cdn_url || '').trim(); return `${baseName}${source ? ` · ${source}` : ''}`; }; const getAssetSourceValue = (asset: ProjectAsset) => String(asset.storage_key || asset.cdn_url || '').trim(); const formatDurationNote = (durationSec?: number | string | null) => { const parsed = Number(durationSec); if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown'; const totalSeconds = Math.round(parsed); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes <= 0) return `Duration: ${seconds}s`; return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`; }; const resolveAssetPlaybackUrl = (value?: string) => { const normalized = String(value || '').trim(); if (!normalized) return ''; if (normalized.startsWith('data:') || normalized.startsWith('blob:')) return normalized; if (normalized.startsWith('/api/file/download')) return normalized; if (normalized.startsWith('/file/download')) return `${baseURLApi}${normalized}`; if (normalized.startsWith('http://') || normalized.startsWith('https://')) return normalized; const normalizedPrivateUrl = normalized.replace(/^\/+/, ''); return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; }; const readMediaDuration = ( playbackUrl: string, mediaType: 'video' | 'audio', ): Promise => new Promise((resolve) => { const mediaElement = mediaType === 'video' ? document.createElement('video') : document.createElement('audio'); let timeoutId: ReturnType | null = null; const cleanup = () => { mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata); mediaElement.removeEventListener('error', onError); mediaElement.removeEventListener('abort', onError); if (timeoutId) clearTimeout(timeoutId); mediaElement.pause(); mediaElement.removeAttribute('src'); mediaElement.load(); }; const onLoadedMetadata = () => { const duration = Number(mediaElement.duration); cleanup(); if (Number.isFinite(duration) && duration > 0) { resolve(duration); return; } resolve(null); }; const onError = () => { cleanup(); resolve(null); }; timeoutId = setTimeout(() => { cleanup(); resolve(null); }, 12000); mediaElement.preload = 'metadata'; mediaElement.crossOrigin = 'anonymous'; mediaElement.addEventListener('loadedmetadata', onLoadedMetadata); mediaElement.addEventListener('error', onError); mediaElement.addEventListener('abort', onError); mediaElement.src = playbackUrl; mediaElement.load(); }); const resolveDurationWithFallback = async ( source: string, mediaType: 'video' | 'audio', ) => { const playbackUrl = resolveAssetPlaybackUrl(source); if (!playbackUrl) return null; const directDuration = await readMediaDuration(playbackUrl, mediaType); if (Number.isFinite(directDuration) && Number(directDuration) > 0) { return Number(directDuration); } try { const requestUrl = playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://') ? playbackUrl : playbackUrl.replace(/^\/api(?=\/)/, ''); const token = typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; const response = await axios.get(requestUrl, { responseType: 'blob', headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); const blobUrl = URL.createObjectURL(response.data); try { const blobDuration = await readMediaDuration(blobUrl, mediaType); if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) { return Number(blobDuration); } return null; } finally { URL.revokeObjectURL(blobUrl); } } catch (error) { console.error('Failed to fetch media for duration probing:', error); return null; } }; const isBackgroundImageAsset = (asset: ProjectAsset) => { if (asset.type) return asset.type === 'background_image'; const normalizedName = String(asset.name || '').toLowerCase(); if (!normalizedName) return false; const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test( normalizedName, ); const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName); return hasBackgroundKeyword && !hasExcludedKeyword; }; const addFallbackAssetOption = ( options: AssetOption[], value?: string, fallbackLabel?: string, ): AssetOption[] => { const normalizedValue = String(value || '').trim(); if (!normalizedValue) return options; if (options.some((option) => option.value === normalizedValue)) return options; return [ ...options, { value: normalizedValue, label: fallbackLabel || `Custom URL · ${normalizedValue}`, }, ]; }; const labelByType: Record = { navigation_next: 'Navigation: Forward', navigation_prev: 'Navigation: Back', gallery: 'Gallery', carousel: 'Carousel', tooltip: 'Tooltip', description: 'Description', video_player: 'Video Player', audio_player: 'Audio Player', }; const canvasElementTypes: CanvasElementType[] = [ 'navigation_next', 'navigation_prev', 'gallery', 'carousel', 'tooltip', 'description', 'video_player', 'audio_player', ]; const isCanvasElementType = (value: string): value is CanvasElementType => canvasElementTypes.includes(value as CanvasElementType); const isNavigationElementType = ( type: CanvasElementType, ): type is NavigationElementType => type === 'navigation_next' || type === 'navigation_prev'; const getNavigationButtonLabel = (type: NavigationElementType) => type === 'navigation_next' ? 'Forward' : 'Back'; const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind => type === 'navigation_prev' ? 'back' : 'forward'; const getNavigationTypeFromKind = ( kind: NavigationButtonKind, ): NavigationElementType => kind === 'back' ? 'navigation_prev' : 'navigation_next'; const createDefaultElement = ( type: CanvasElementType, index: number, ): CanvasElement => { const base: CanvasElement = { id: createLocalId(), type, label: labelByType[type], xPercent: clamp(12 + index * 4, 5, 80), yPercent: clamp(16 + index * 6, 8, 85), appearDelaySec: 0, appearDurationSec: null, }; if (type === 'gallery') { return { ...base, galleryCards: [ { id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' }, ], }; } if (type === 'carousel') { return { ...base, carouselSlides: [ { id: createLocalId(), imageUrl: '', caption: 'Slide 1' }, ], carouselPrevIconUrl: '', carouselNextIconUrl: '', }; } if (type === 'tooltip') { return { ...base, iconUrl: '', tooltipTitle: 'Tooltip title', tooltipText: 'Tooltip text', }; } if (type === 'description') { return { ...base, iconUrl: '', descriptionTitle: 'TITLE', descriptionText: '', descriptionTitleFontSize: '48px', descriptionTextFontSize: '36px', descriptionBackgroundColor: 'transparent', }; } if (type === 'navigation_next' || type === 'navigation_prev') { return { ...base, navLabel: getNavigationButtonLabel(type), navType: getNavigationButtonKind(type), iconUrl: '', transitionReverseMode: 'auto_reverse', }; } if (type === 'video_player' || type === 'audio_player') { return { ...base, mediaUrl: '', mediaAutoplay: true, mediaLoop: true, mediaMuted: type === 'video_player', }; } return base; }; const mergeElementWithDefaults = ( element: CanvasElement, defaults?: Partial, options?: { preferElementValues?: boolean }, ): CanvasElement => { if (!defaults) return element; const preferElementValues = Boolean(options?.preferElementValues); const base = preferElementValues ? defaults : element; const override = preferElementValues ? element : defaults; const merged: CanvasElement = { ...base, ...override, id: element.id, type: element.type, label: element.label || defaults.label || element.type, xPercent: element.xPercent ?? defaults.xPercent ?? 50, yPercent: element.yPercent ?? defaults.yPercent ?? 50, }; merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100); merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100); merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec); merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec); if (merged.type === 'gallery') { const cards = preferElementValues ? Array.isArray(element.galleryCards) ? element.galleryCards : defaults.galleryCards || [] : Array.isArray(defaults.galleryCards) ? defaults.galleryCards : element.galleryCards || []; merged.galleryCards = cards.map((card, cardIndex) => ({ id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl || ''), title: String(card?.title || `Card ${cardIndex + 1}`), description: String(card?.description || ''), })); } if (merged.type === 'carousel') { const slides = preferElementValues ? Array.isArray(element.carouselSlides) ? element.carouselSlides : defaults.carouselSlides || [] : Array.isArray(defaults.carouselSlides) ? defaults.carouselSlides : element.carouselSlides || []; merged.carouselSlides = slides.map((slide, slideIndex) => ({ id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl || ''), caption: String(slide?.caption || `Slide ${slideIndex + 1}`), })); } return merged; }; const getElementButtonTitle = (element: CanvasElement) => { if (element.type === 'gallery') { return `${element.label} (${element.galleryCards?.length || 0})`; } if (element.type === 'carousel') { return `${element.label} (${element.carouselSlides?.length || 0})`; } if (element.type === 'tooltip') return element.tooltipTitle ?? ''; if (element.type === 'description') return element.descriptionTitle ?? ''; if (element.type === 'navigation_next' || element.type === 'navigation_prev') return ( element.navLabel?.trim() || getNavigationButtonLabel(element.type as NavigationElementType) ); if ( (element.type === 'video_player' || element.type === 'audio_player') && element.mediaUrl ) { return `${element.label} · configured`; } return element.label; }; const buildCanvasElementStyle = ( element: CanvasElement, ): React.CSSProperties => { const style: React.CSSProperties = {}; const width = getTrimmedCssValue(element.width); if (width) style.width = width; const height = getTrimmedCssValue(element.height); if (height) style.height = height; const minWidth = getTrimmedCssValue(element.minWidth); if (minWidth) style.minWidth = minWidth; const maxWidth = getTrimmedCssValue(element.maxWidth); if (maxWidth) style.maxWidth = maxWidth; const minHeight = getTrimmedCssValue(element.minHeight); if (minHeight) style.minHeight = minHeight; const maxHeight = getTrimmedCssValue(element.maxHeight); if (maxHeight) style.maxHeight = maxHeight; const margin = getTrimmedCssValue(element.margin); if (margin) style.margin = margin; const padding = getTrimmedCssValue(element.padding); if (padding) style.padding = padding; const gap = getTrimmedCssValue(element.gap); if (gap) style.gap = gap; const fontSize = getTrimmedCssValue(element.fontSize); if (fontSize) style.fontSize = fontSize; const lineHeight = getTrimmedCssValue(element.lineHeight); if (lineHeight) style.lineHeight = lineHeight; const fontWeight = getTrimmedCssValue(element.fontWeight); if (fontWeight) style.fontWeight = fontWeight as React.CSSProperties['fontWeight']; const border = getTrimmedCssValue(element.border); if (border) style.border = border; const borderRadius = getTrimmedCssValue(element.borderRadius); if (borderRadius) style.borderRadius = borderRadius; const opacity = getTrimmedCssValue(element.opacity); if (opacity) { const parsed = Number(opacity); if (Number.isFinite(parsed)) style.opacity = parsed; } const boxShadow = getTrimmedCssValue(element.boxShadow); if (boxShadow) style.boxShadow = boxShadow; const display = getTrimmedCssValue(element.display); if (display) style.display = display; const position = getTrimmedCssValue(element.position); if (position) style.position = position as React.CSSProperties['position']; const justifyContent = getTrimmedCssValue(element.justifyContent); if (justifyContent) style.justifyContent = justifyContent as React.CSSProperties['justifyContent']; const alignItems = getTrimmedCssValue(element.alignItems); if (alignItems) style.alignItems = alignItems as React.CSSProperties['alignItems']; const textAlign = getTrimmedCssValue(element.textAlign); if (textAlign) style.textAlign = textAlign as React.CSSProperties['textAlign']; const zIndex = getTrimmedCssValue(element.zIndex); if (zIndex) { const parsed = Number(zIndex); if (Number.isFinite(parsed)) style.zIndex = parsed; } return style; }; 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 '/page_elements/page_elements-list'; return `/page_elements/page_elements-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 [assets, setAssets] = useState([]); const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState< Partial>> >({}); const [activePageId, setActivePageId] = useState(''); const [projectName, setProjectName] = useState(''); const [elements, setElements] = useState([]); const [backgroundImageUrl, setBackgroundImageUrl] = useState(''); const [backgroundVideoUrl, setBackgroundVideoUrl] = useState(''); const [backgroundAudioUrl, setBackgroundAudioUrl] = useState(''); const [selectedElementId, setSelectedElementId] = useState(''); const [selectedMenuItem, setSelectedMenuItem] = useState('none'); const [transitionPreview, setTransitionPreview] = useState(null); const [_pendingNavigationPageId, setPendingNavigationPageId] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingTransition, setIsCreatingTransition] = useState(false); const [newTransitionName, setNewTransitionName] = useState(''); const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState(''); const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] = useState(true); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const [resolvedDurationBySource, setResolvedDurationBySource] = useState< Record >({}); const [canvasElapsedSec, setCanvasElapsedSec] = useState(0); const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState< Record >({}); const [constructorInteractionMode, setConstructorInteractionMode] = useState('edit'); const [constructorControlsPosition, setConstructorControlsPosition] = useState({ x: 20, y: 20 }); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 }); const [isMenuOpen, setIsMenuOpen] = useState(false); const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 }); const [isEditorCollapsed, setIsEditorCollapsed] = useState(false); const constructorControlsDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number; } | null>(null); const menuDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number; } | null>(null); const editorDragRef = useRef<{ pointerOffsetX: number; pointerOffsetY: number; } | null>(null); const elementDragRef = useRef(null); const transitionVideoRef = useRef(null); const reverseAnimationFrame = useRef(null); const didSetInitialCanvasFocus = useRef(false); const durationProbeInFlightRef = useRef>(new Set()); const pagePlaybackStartedAtRef = useRef(Date.now()); const preloadedIconUrlsRef = useRef>(new Set()); const activePage = useMemo( () => pages.find((item) => item.id === activePageId) || null, [activePageId, pages], ); const isConstructorEditMode = constructorInteractionMode === 'edit'; const allowedNavigationTypes = useMemo(() => { return ['navigation_next', 'navigation_prev']; }, []); const pageNameById = useMemo(() => { const acc: Record = {}; pages.forEach((page, index) => { acc[String(page.id)] = page.name || `Page ${index + 1}`; }); return acc; }, [pages]); const selectedElement = useMemo( () => elements.find((element) => element.id === selectedElementId) || null, [elements, selectedElementId], ); 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]); const normalizeNavigationElementType = useCallback( (element: CanvasElement, nextType: NavigationElementType): CanvasElement => { if (!isNavigationElementType(element.type)) return element; const nextButtonLabel = getNavigationButtonLabel(nextType); const hasDefaultLabel = element.label === labelByType.navigation_next || element.label === labelByType.navigation_prev; const hasDefaultNavLabel = !element.navLabel || element.navLabel === getNavigationButtonLabel('navigation_next') || element.navLabel === getNavigationButtonLabel('navigation_prev'); return { ...element, type: nextType, navType: getNavigationButtonKind(nextType), label: hasDefaultLabel ? labelByType[nextType] : element.label, navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel, }; }, [], ); 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' && 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), })); if (taggedAssets.length > 0) return taggedAssets; return videoAssetOptions; }, [assets, videoAssetOptions]); const iconAssetOptions = useMemo( () => assets .filter( (asset) => asset.type === 'icon' && asset.asset_type === 'image' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset), })), [assets], ); const getKnownDurationForSource = useCallback( (source?: string) => { const normalizedSource = String(source || '').trim(); if (!normalizedSource) return null; const resolvedDuration = resolvedDurationBySource[normalizedSource]; if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) { return Number(resolvedDuration); } return null; }, [resolvedDurationBySource], ); const durationProbeTargets = useMemo< Array<{ source: string; mediaType: 'video' | 'audio' }> >(() => { const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = []; if (backgroundVideoUrl) { targets.push({ source: backgroundVideoUrl, mediaType: 'video' }); } if (backgroundAudioUrl) { targets.push({ source: backgroundAudioUrl, mediaType: 'audio' }); } if ( selectedElement && (selectedElement.type === 'video_player' || selectedElement.type === 'audio_player') && selectedElement.mediaUrl ) { targets.push({ source: selectedElement.mediaUrl, mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio', }); } if (newTransitionVideoUrl) { targets.push({ source: newTransitionVideoUrl, mediaType: 'video' }); } elements.forEach((element) => { if (!isNavigationElementType(element.type)) return; if (element.transitionVideoUrl) { targets.push({ source: element.transitionVideoUrl, mediaType: 'video' }); } if (element.reverseVideoUrl) { targets.push({ source: element.reverseVideoUrl, mediaType: 'video' }); } }); return targets; }, [ backgroundAudioUrl, backgroundVideoUrl, elements, newTransitionVideoUrl, selectedElement, ]); useEffect(() => { let isCancelled = false; durationProbeTargets.forEach(({ source, mediaType }) => { const normalizedSource = String(source || '').trim(); if (!normalizedSource) return; if (getKnownDurationForSource(normalizedSource)) return; const probeKey = `${mediaType}:${normalizedSource}`; if (durationProbeInFlightRef.current.has(probeKey)) return; durationProbeInFlightRef.current.add(probeKey); resolveDurationWithFallback(normalizedSource, mediaType) .then((duration) => { if (isCancelled) return; setResolvedDurationBySource((prev) => ({ ...prev, [normalizedSource]: Number.isFinite(duration) && Number(duration) > 0 ? Number(duration) : null, })); }) .catch((error) => { console.error('Failed to resolve media duration:', error); if (isCancelled) return; setResolvedDurationBySource((prev) => ({ ...prev, [normalizedSource]: null, })); }) .finally(() => { durationProbeInFlightRef.current.delete(probeKey); }); }); return () => { isCancelled = true; }; }, [durationProbeTargets, getKnownDurationForSource]); const backgroundVideoDurationNote = useMemo( () => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)), [backgroundVideoUrl, getKnownDurationForSource], ); const backgroundAudioDurationNote = useMemo( () => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)), [backgroundAudioUrl, getKnownDurationForSource], ); const selectedMediaDurationNote = useMemo(() => { if ( !selectedElement || (selectedElement.type !== 'video_player' && selectedElement.type !== 'audio_player') ) { return 'Duration: unknown'; } return formatDurationNote( getKnownDurationForSource(selectedElement.mediaUrl || ''), ); }, [getKnownDurationForSource, selectedElement]); const newTransitionDurationNote = useMemo( () => formatDurationNote(getKnownDurationForSource(newTransitionVideoUrl)), [getKnownDurationForSource, newTransitionVideoUrl], ); const selectedTransitionDurationNote = useMemo(() => { if (!selectedElement || !isNavigationElementType(selectedElement.type)) { return 'Duration: unknown'; } return formatDurationNote( getKnownDurationForSource(selectedElement.transitionVideoUrl || ''), ); }, [getKnownDurationForSource, 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 = getKnownDurationForSource( 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; }); }, [getKnownDurationForSource]); const loadData = useCallback(async () => { 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}`, ), axios.get( `/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`, ), axios.get('/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true'), ]); 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); setAssets(assetRows); const uiElementRows: UiElementDefault[] = Array.isArray( uiElementsResponse?.data?.rows, ) ? uiElementsResponse.data.rows : []; const defaultsByType: Partial< Record> > = {}; uiElementRows.forEach((row) => { const elementType = String(row.element_type || '').trim(); if (!isCanvasElementType(elementType)) return; const rawDefaults = parseJsonObject>( row.default_settings_json, {}, ); defaultsByType[elementType] = rawDefaults; }); setUiElementDefaultsByType(defaultsByType); const defaultPageId = 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.'; console.error('Unauthorized constructor request:', error); setErrorMessage(message); setPages([]); setAssets([]); router.replace('/login'); return; } const message = error?.response?.data?.message || error?.message || 'Failed to load constructor data.'; console.error('Failed to load constructor data:', error); setErrorMessage(message); setPages([]); setAssets([]); setUiElementDefaultsByType({}); } finally { setIsLoading(false); } }, [isAuthReady, pageIdFromRoute, projectId, router]); 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 ? '/page_elements/page_elements-list' : '/projects/projects-list', ); }, [isElementEditMode, projectId, router]); useEffect(() => { loadData(); }, [loadData]); useEffect(() => { if (typeof window === 'undefined') return; setConstructorControlsPosition((prev) => { if (prev.x > 0) return prev; return { x: 20, y: 20, }; }); setMenuPosition((prev) => { if (prev.x > 0) return prev; return { x: Math.max(window.innerWidth - 280, 20), y: prev.y, }; }); setEditorPosition((prev) => { if (prev.x > 0) return prev; return { x: Math.max(window.innerWidth - 400, 20), y: prev.y, }; }); }, []); 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 (typeof window === 'undefined') return; if (isLoading || !activePageId) { setCanvasElapsedSec(0); return; } pagePlaybackStartedAtRef.current = Date.now(); setCanvasElapsedSec(0); const intervalId = window.setInterval(() => { const elapsed = (Date.now() - pagePlaybackStartedAtRef.current) / 1000; setCanvasElapsedSec(elapsed > 0 ? elapsed : 0); }, 100); return () => window.clearInterval(intervalId); }, [activePageId, isLoading]); useEffect(() => { if (typeof window === 'undefined') return; const targetSet = new Set(iconPreloadTargets); const nextPreloaded = new Set(); preloadedIconUrlsRef.current.forEach((url) => { if (targetSet.has(url)) nextPreloaded.add(url); }); preloadedIconUrlsRef.current = nextPreloaded; setPreloadedIconUrlMap(() => { const nextMap: Record = {}; nextPreloaded.forEach((url) => { nextMap[url] = true; }); return nextMap; }); if (!iconPreloadTargets.length) return; let isCancelled = false; const preloadImages: HTMLImageElement[] = []; iconPreloadTargets.forEach((url) => { if (preloadedIconUrlsRef.current.has(url)) return; const image = new Image(); const markReady = () => { if (isCancelled) return; preloadedIconUrlsRef.current.add(url); setPreloadedIconUrlMap((prev) => { if (prev[url]) return prev; return { ...prev, [url]: true, }; }); }; image.onload = markReady; image.onerror = () => { console.error('Failed to preload icon asset:', url); markReady(); }; image.src = url; preloadImages.push(image); }); return () => { isCancelled = true; preloadImages.forEach((image) => { image.onload = null; image.onerror = null; }); }; }, [iconPreloadTargets]); useEffect(() => { if (!activePage) { setElements([]); setSelectedElementId(''); 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, 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 : '', 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, 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 : item.type === 'video_player', }; return mergeElementWithDefaults( normalizedElement, uiElementDefaultsByType[elementType], { preferElementValues: true }, ); }) : []; setElements(normalizedElements); setSelectedMenuItem('none'); setSelectedElementId((current) => { if (!normalizedElements.length) return ''; if ( elementIdFromRoute && normalizedElements.some((element) => element.id === elementIdFromRoute) ) { return elementIdFromRoute; } if (normalizedElements.some((element) => element.id === current)) return current; return ''; }); setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundAudioUrl(activePage.background_audio_url || ''); }, [activePage, elementIdFromRoute, uiElementDefaultsByType]); 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 normalizeNavigationElementType(element, forcedType); }); return hasChanges ? nextElements : prev; }); }, [allowedNavigationTypes, normalizeNavigationElementType]); useEffect(() => { const onPointerMove = (event: MouseEvent) => { if (constructorControlsDragRef.current) { const maxX = Math.max(window.innerWidth - 460, 0); const maxY = Math.max(window.innerHeight - 64, 0); const nextX = clamp( event.clientX - constructorControlsDragRef.current.pointerOffsetX, 0, maxX, ); const nextY = clamp( event.clientY - constructorControlsDragRef.current.pointerOffsetY, 0, maxY, ); setConstructorControlsPosition({ x: nextX, y: nextY }); return; } if (menuDragRef.current) { const nextX = clamp( event.clientX - menuDragRef.current.pointerOffsetX, 0, window.innerWidth - 240, ); const nextY = clamp( event.clientY - menuDragRef.current.pointerOffsetY, 0, window.innerHeight - 60, ); setMenuPosition({ x: nextX, y: nextY }); return; } if (editorDragRef.current) { const editorWidth = isEditorCollapsed ? 260 : 380; const nextX = clamp( event.clientX - editorDragRef.current.pointerOffsetX, 0, window.innerWidth - editorWidth, ); const nextY = clamp( event.clientY - editorDragRef.current.pointerOffsetY, 0, window.innerHeight - 60, ); setEditorPosition({ x: nextX, y: nextY }); return; } if (!elementDragRef.current || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const rawX = event.clientX - rect.left - elementDragRef.current.pointerOffsetX; const rawY = event.clientY - rect.top - elementDragRef.current.pointerOffsetY; const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100); const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100); setElements((prev) => prev.map((item) => item.id === elementDragRef.current?.id ? { ...item, xPercent: nextXPercent, yPercent: nextYPercent } : item, ), ); }; const onPointerUp = () => { constructorControlsDragRef.current = null; menuDragRef.current = null; editorDragRef.current = null; elementDragRef.current = null; }; window.addEventListener('mousemove', onPointerMove); window.addEventListener('mouseup', onPointerUp); return () => { window.removeEventListener('mousemove', onPointerMove); window.removeEventListener('mouseup', onPointerUp); }; }, [isEditorCollapsed]); useEffect(() => { if (isConstructorEditMode) return; elementDragRef.current = null; setSelectedElementId(''); setSelectedMenuItem('none'); }, [isConstructorEditMode]); useEffect(() => { if (!isConstructorEditMode) return; if (!selectedElementId && selectedMenuItem === 'none') return; const onOutsideMouseDown = (event: MouseEvent) => { const target = event.target as HTMLElement | null; if (!target) return; if (elementEditorRef.current?.contains(target)) return; const clickedElementId = target .closest('[data-constructor-element-id]') ?.getAttribute('data-constructor-element-id'); if ( selectedElementId && clickedElementId && clickedElementId === selectedElementId ) return; setSelectedElementId(''); setSelectedMenuItem('none'); }; window.addEventListener('mousedown', onOutsideMouseDown); return () => window.removeEventListener('mousedown', onOutsideMouseDown); }, [isConstructorEditMode, selectedElementId, selectedMenuItem]); const selectElementForEdit = (elementId: string) => { setSelectedElementId(elementId); setSelectedMenuItem('none'); }; const selectMenuItemForEdit = (item: EditorMenuItem) => { setSelectedElementId(''); setSelectedMenuItem(item); }; const addElement = (type: CanvasElementType) => { const nextElementType: CanvasElementType = isNavigationElementType(type) ? allowedNavigationTypes.includes(type) ? type : allowedNavigationTypes[0] : type; const baseElement = createDefaultElement(nextElementType, elements.length); const nextElement = mergeElementWithDefaults( baseElement, uiElementDefaultsByType[nextElementType], ); setElements((prev) => [...prev, nextElement]); selectElementForEdit(nextElement.id); setSuccessMessage('Element added. Drag it to set position.'); setErrorMessage(''); }; const createPage = useCallback(async () => { if (!projectId) { setErrorMessage('Project is required.'); return; } const maxSortOrder = Math.max( 0, ...pages.map((item) => Number(item.sort_order || 0)), ); const nextPageNumber = pages.length + 1; const payload = { project: projectId, environment: activePage?.environment || 'dev', source_key: '', name: `Page ${nextPageNumber}`, slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`, sort_order: maxSortOrder + 1, background_image_url: '', background_video_url: '', background_audio_url: '', background_loop: false, requires_auth: false, ui_schema_json: JSON.stringify({ elements: [] }), }; try { setIsCreatingPage(true); setErrorMessage(''); setSuccessMessage(''); const response = await axios.post('/tour_pages', { data: payload }); const createdPage = response?.data; await loadData(); if (createdPage?.id) { setActivePageId(createdPage.id); } setIsMenuOpen(true); setSuccessMessage( 'New page created. You can now configure it in constructor.', ); } catch (error: any) { const message = error?.response?.data?.message || error?.message || 'Failed to create page.'; console.error('Failed to create page from constructor:', error); setErrorMessage(message); } finally { setIsCreatingPage(false); } }, [activePage?.environment, loadData, pages, projectId]); const createTransition = useCallback(async () => { if (!projectId) { setErrorMessage('Project is required.'); return; } const sanitizedVideoUrl = String(newTransitionVideoUrl || '').trim(); if (!sanitizedVideoUrl) { setErrorMessage('Select a transition video asset first.'); return; } const sanitizedName = String(newTransitionName || '').trim() || `Transition ${Date.now().toString().slice(-4)}`; const resolvedDurationSec = getKnownDurationForSource(sanitizedVideoUrl); if (!resolvedDurationSec) { setErrorMessage( 'Could not resolve transition video duration yet. Please wait a moment and try again.', ); return; } try { setIsCreatingTransition(true); setErrorMessage(''); setSuccessMessage(''); const payload = { project: projectId, environment: activePage?.environment || 'dev', source_key: '', name: sanitizedName, slug: `transition-${createLocalId()}`, video_url: sanitizedVideoUrl, audio_url: '', supports_reverse: Boolean(newTransitionSupportsReverse), duration_sec: resolvedDurationSec, }; await axios.post('/transitions', { data: payload }); setSuccessMessage('Transition created.'); setNewTransitionName(''); } catch (error: any) { const message = error?.response?.data?.message || error?.message || 'Failed to create transition.'; console.error('Failed to create transition from constructor:', error); setErrorMessage(message); } finally { setIsCreatingTransition(false); } }, [ activePage?.environment, getKnownDurationForSource, newTransitionName, newTransitionSupportsReverse, newTransitionVideoUrl, projectId, ]); const saveConstructor = useCallback(async () => { if (!activePageId) { setErrorMessage('Select a page before saving.'); return; } try { setIsSaving(true); setErrorMessage(''); setSuccessMessage(''); const existingSchema = parseJsonObject>( activePage?.ui_schema_json, {}, ); const schemaToSave = { ...existingSchema, elements, }; await axios.put(`/tour_pages/${activePageId}`, { id: activePageId, data: { environment: activePage?.environment, source_key: activePage?.source_key, name: activePage?.name, slug: activePage?.slug, sort_order: activePage?.sort_order, requires_auth: activePage?.requires_auth, ui_schema_json: schemaToSave, background_image_url: backgroundImageUrl, background_video_url: backgroundVideoUrl, background_audio_url: backgroundAudioUrl, background_loop: Boolean(backgroundAudioUrl), }, }); setSuccessMessage( 'Constructor settings saved. Element positions are stored in percentages.', ); await loadData(); } catch (error: any) { const message = error?.response?.data?.message || error?.message || 'Failed to save constructor changes.'; console.error('Failed to save constructor changes:', error); setErrorMessage(message); } finally { setIsSaving(false); } }, [ activePage?.environment, activePage?.name, activePage?.requires_auth, activePage?.slug, activePage?.sort_order, activePage?.source_key, activePage?.ui_schema_json, activePageId, backgroundAudioUrl, backgroundImageUrl, backgroundVideoUrl, elements, loadData, ]); const onElementMouseDown = (event: React.MouseEvent, elementId: string) => { if (!isConstructorEditMode) return; event.preventDefault(); if (!canvasRef.current) return; const currentElement = elements.find((item) => item.id === elementId); if (!currentElement) return; selectElementForEdit(elementId); const rect = canvasRef.current.getBoundingClientRect(); const elementLeftPx = (currentElement.xPercent / 100) * rect.width; const elementTopPx = (currentElement.yPercent / 100) * rect.height; elementDragRef.current = { id: elementId, pointerOffsetX: event.clientX - rect.left - elementLeftPx, pointerOffsetY: event.clientY - rect.top - elementTopPx, }; }; const preventImageDragStart = (event: React.DragEvent) => { event.preventDefault(); }; const updateSelectedElement = (patch: Partial) => { if (!selectedElementId) return; setElements((prev) => prev.map((item) => item.id === selectedElementId ? { ...item, ...patch } : item, ), ); }; const removeSelectedElement = () => { if (!selectedElementId) return; let nextSelectedId = ''; setElements((prev) => { const filtered = prev.filter((item) => item.id !== selectedElementId); nextSelectedId = filtered[0]?.id || ''; return filtered; }); if (nextSelectedId) { selectElementForEdit(nextSelectedId); } else { setSelectedElementId(''); setSelectedMenuItem('none'); } setSuccessMessage('Element removed.'); }; const updateGalleryCard = (cardId: string, patch: Partial) => { if (!selectedElement || selectedElement.type !== 'gallery') return; const nextCards = (selectedElement.galleryCards || []).map((card) => card.id === cardId ? { ...card, ...patch } : card, ); updateSelectedElement({ galleryCards: nextCards }); }; const addGalleryCard = () => { if (!selectedElement || selectedElement.type !== 'gallery') return; const nextCards = [ ...(selectedElement.galleryCards || []), { id: createLocalId(), imageUrl: '', title: `Card ${(selectedElement.galleryCards || []).length + 1}`, description: '', }, ]; updateSelectedElement({ galleryCards: nextCards }); }; const removeGalleryCard = (cardId: string) => { if (!selectedElement || selectedElement.type !== 'gallery') return; const nextCards = (selectedElement.galleryCards || []).filter( (card) => card.id !== cardId, ); updateSelectedElement({ galleryCards: nextCards }); }; const updateCarouselSlide = ( slideId: string, patch: Partial, ) => { if (!selectedElement || selectedElement.type !== 'carousel') return; const nextSlides = (selectedElement.carouselSlides || []).map((slide) => slide.id === slideId ? { ...slide, ...patch } : slide, ); updateSelectedElement({ carouselSlides: nextSlides }); }; const addCarouselSlide = () => { if (!selectedElement || selectedElement.type !== 'carousel') return; const nextSlides = [ ...(selectedElement.carouselSlides || []), { id: createLocalId(), imageUrl: '', caption: `Slide ${(selectedElement.carouselSlides || []).length + 1}`, }, ]; updateSelectedElement({ carouselSlides: nextSlides }); }; const removeCarouselSlide = (slideId: string) => { if (!selectedElement || selectedElement.type !== 'carousel') return; const nextSlides = (selectedElement.carouselSlides || []).filter( (slide) => slide.id !== slideId, ); updateSelectedElement({ carouselSlides: nextSlides }); }; const openTransitionPreviewForElement = ( element: CanvasElement, direction: 'forward' | 'back', ) => { if (!isNavigationElementType(element.type)) return; if (!element.transitionVideoUrl) { setErrorMessage( 'Select transition video asset to preview transition playback.', ); return; } if ( direction === 'back' && element.transitionReverseMode === 'separate_video' && !element.reverseVideoUrl ) { setErrorMessage( 'Select back-transition asset or switch reverse mode to Auto Reverse.', ); return; } setTransitionPreview({ videoUrl: element.transitionVideoUrl, reverseMode: direction === 'forward' ? 'none' : element.transitionReverseMode === 'separate_video' ? 'separate' : 'reverse', reverseVideoUrl: element.reverseVideoUrl, durationSec: element.transitionDurationSec, title: `${element.navLabel || element.label} · ${direction}`, }); }; 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)) { const direction = element.navType === 'back' || element.type === 'navigation_prev' ? 'back' : 'forward'; const configuredTargetId = String(element.targetPageId || '').trim(); const fallbackTargetId = (() => { const currentPageIndex = pages.findIndex( (page) => page.id === activePageId, ); if (currentPageIndex < 0) return ''; const nextPageIndex = direction === 'back' ? currentPageIndex - 1 : currentPageIndex + 1; const nextPage = pages[nextPageIndex]; return nextPage ? String(nextPage.id || '').trim() : ''; })(); const targetPageId = configuredTargetId || fallbackTargetId; if (!targetPageId) { setErrorMessage('No target page available for this navigation button.'); return; } const hasPlayableTransition = Boolean(element.transitionVideoUrl) && !( direction === 'back' && element.transitionReverseMode === 'separate_video' && !element.reverseVideoUrl ); if (!hasPlayableTransition) { setPendingNavigationPageId(''); setTransitionPreview(null); setActivePageId(targetPageId); setSelectedElementId(''); setSelectedMenuItem('none'); setErrorMessage(''); return; } setPendingNavigationPageId(targetPageId); openTransitionPreviewForElement(element, direction); } return; } selectElementForEdit(element.id); }; const onMenuDragStart = (event: React.MouseEvent) => { const targetRect = ( event.currentTarget as HTMLElement ).getBoundingClientRect(); menuDragRef.current = { pointerOffsetX: event.clientX - targetRect.left, pointerOffsetY: event.clientY - targetRect.top, }; }; const onConstructorControlsDragStart = (event: React.MouseEvent) => { const targetRect = ( event.currentTarget as HTMLElement ).getBoundingClientRect(); constructorControlsDragRef.current = { pointerOffsetX: event.clientX - targetRect.left, pointerOffsetY: event.clientY - targetRect.top, }; }; const onElementEditorDragStart = (event: React.MouseEvent) => { const target = event.target as HTMLElement; if (target.closest('button')) return; const targetRect = ( event.currentTarget as HTMLElement ).getBoundingClientRect(); editorDragRef.current = { pointerOffsetX: event.clientX - targetRect.left, pointerOffsetY: event.clientY - targetRect.top, }; }; const renderCanvasElementContent = (element: CanvasElement) => { if ( element.type === 'navigation_next' || element.type === 'navigation_prev' ) { const fallbackNavLabel = getNavigationButtonLabel(element.type); const navigationLabel = element.navLabel?.trim() || fallbackNavLabel; if (element.iconUrl) { return ( // eslint-disable-next-line @next/next/no-img-element Navigation icon ); } const targetPageName = element.targetPageId ? pageNameById[element.targetPageId] : ''; return (
{navigationLabel}
{targetPageName ? ( To: {targetPageName} ) : null}
); } if (element.type === 'tooltip') { if (element.iconUrl) { return ( // eslint-disable-next-line @next/next/no-img-element Tooltip icon ); } return (

{element.tooltipTitle}

{element.tooltipText || 'Tooltip text'}

); } if (element.type === 'description') { if (element.iconUrl) { return ( // eslint-disable-next-line @next/next/no-img-element Description icon ); } const bgColor = element.descriptionBackgroundColor || 'transparent'; return (

{element.descriptionTitle || 'TITLE'}

{element.descriptionText && (

{element.descriptionText}

)}
); } if (element.type === 'gallery') { const cards = element.galleryCards || []; return (

Gallery ({cards.length})

{cards.slice(0, 6).map((card) => (
{card.imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {card.title ) : (
No image
)}
))}
); } if (element.type === 'carousel') { const firstSlide = (element.carouselSlides || [])[0]; return (

Carousel ({element.carouselSlides?.length || 0})

{firstSlide?.imageUrl ? ( // eslint-disable-next-line @next/next/no-img-element {firstSlide.caption ) : (
No slide image
)}

{firstSlide?.caption || 'No caption'}

{(element.carouselPrevIconUrl || element.carouselNextIconUrl) && (
{element.carouselPrevIconUrl ? ( // eslint-disable-next-line @next/next/no-img-element Previous icon ) : null} Prev Next {element.carouselNextIconUrl ? ( // eslint-disable-next-line @next/next/no-img-element Next icon ) : null}
)}
); } if (element.type === 'video_player') { return (

Video player

); } if (element.type === 'audio_player') { return (

Audio player

); } return getElementButtonTitle(element); }; const isElementVisibleOnCanvas = (element: CanvasElement) => { const delay = Number(element.appearDelaySec || 0); if (canvasElapsedSec < delay) return false; if (element.appearDurationSec === null || element.appearDurationSec === undefined) { return true; } const duration = Number(element.appearDurationSec); if (!Number.isFinite(duration) || duration <= 0) return true; return canvasElapsedSec <= delay + duration; }; const isElementReadyForCanvasRender = (element: CanvasElement) => { const isPreloadableIconElement = (element.type === 'navigation_next' || element.type === 'navigation_prev' || element.type === 'tooltip' || element.type === 'description') && Boolean(element.iconUrl); if (!isPreloadableIconElement) return true; const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl); if (!playbackUrl) return true; return Boolean(preloadedIconUrlMap[playbackUrl]); }; const canvasBackgroundStyle: React.CSSProperties = {}; const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl); const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl); const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl); const backgroundImageSelectOptions = addFallbackAssetOption( backgroundImageAssetOptions, backgroundImageUrl, `Current image · ${backgroundImageUrl}`, ); const backgroundVideoSelectOptions = addFallbackAssetOption( videoAssetOptions, backgroundVideoUrl, `Current video · ${backgroundVideoUrl}`, ); const backgroundAudioSelectOptions = addFallbackAssetOption( audioAssetOptions, backgroundAudioUrl, `Current audio · ${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'; } useEffect(() => { const video = transitionVideoRef.current; if (!transitionPreview || !video) return; let startWatchdogTimer: ReturnType | null = null; let finishTimer: ReturnType | null = null; let hardTimeoutTimer: ReturnType | null = null; let previewBlobUrl: string | null = null; let didFinish = false; let didStartPlayback = false; const sourceCandidateRaw = transitionPreview.reverseMode === 'separate' ? transitionPreview.reverseVideoUrl || '' : transitionPreview.videoUrl; const sourceUrl = resolveAssetPlaybackUrl(sourceCandidateRaw); const cleanupReverseFrame = () => { if (reverseAnimationFrame.current !== null) { cancelAnimationFrame(reverseAnimationFrame.current); reverseAnimationFrame.current = null; } }; const clearTimers = () => { if (startWatchdogTimer) clearTimeout(startWatchdogTimer); if (finishTimer) clearTimeout(finishTimer); if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer); startWatchdogTimer = null; finishTimer = null; hardTimeoutTimer = null; }; const cleanupPreviewBlobUrl = () => { if (!previewBlobUrl) return; URL.revokeObjectURL(previewBlobUrl); previewBlobUrl = null; }; const shouldLoadTransitionViaBlob = (candidateUrl: string) => { try { const parsedUrl = new URL(candidateUrl, window.location.origin); const isSameOrigin = parsedUrl.origin === window.location.origin; if (!isSameOrigin) return false; return ( parsedUrl.pathname === '/api/file/download' || parsedUrl.pathname === '/file/download' ); } catch (error) { console.error('Transition preview URL parsing failed:', { candidateUrl, error, }); return false; } }; const buildBlobRequestUrl = (candidateUrl: string) => { if (candidateUrl.startsWith('/api/')) { return candidateUrl.replace(/^\/api(?=\/)/, ''); } return candidateUrl; }; const resolvePlayableTransitionSource = async () => { cleanupPreviewBlobUrl(); if (!shouldLoadTransitionViaBlob(sourceUrl)) { return sourceUrl; } const token = typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; const requestUrl = buildBlobRequestUrl(sourceUrl); const response = await axios.get(requestUrl, { responseType: 'blob', headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); previewBlobUrl = URL.createObjectURL(response.data); return previewBlobUrl; }; const finishPreview = (reason: string) => { if (didFinish) return; didFinish = true; clearTimers(); cleanupReverseFrame(); video.pause(); video.removeAttribute('src'); video.load(); cleanupPreviewBlobUrl(); setTransitionPreview(null); setPendingNavigationPageId((pendingPageId) => { const nextPageId = String(pendingPageId || '').trim(); if (nextPageId) { setActivePageId(nextPageId); setSelectedElementId(''); setSelectedMenuItem('none'); setErrorMessage(''); } return ''; }); console.info('Transition preview finished:', { reason, src: video.currentSrc || sourceUrl || '', }); }; const configuredDurationSec = Number(transitionPreview.durationSec); const getMediaErrorDetails = () => { if (!video.error) return null; const mediaError = video.error as MediaError & { message?: string }; return { code: mediaError.code, message: mediaError.message || '', }; }; const logTransitionIssue = (reason: string, error?: unknown) => { console.error('Transition preview issue:', { reason, src: video.currentSrc || sourceUrl || '', readyState: video.readyState, networkState: video.networkState, duration: video.duration, configuredDurationSec: transitionPreview.durationSec, reverseMode: transitionPreview.reverseMode, mediaError: getMediaErrorDetails(), error, }); }; const scheduleFinishByDuration = (durationSec: number) => { if (!Number.isFinite(durationSec) || durationSec <= 0 || finishTimer) { return; } finishTimer = setTimeout(() => { finishPreview('duration-timer'); }, durationSec * 1000 + 200); }; const runReversePreview = () => { cleanupReverseFrame(); const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 ? configuredDurationSec : 0.7; const reverseSeconds = Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 ? configuredDurationSec : duration; const reverseMs = Math.max(reverseSeconds * 1000, 400); const reverseRate = reverseSeconds > 0 ? duration / reverseSeconds : 1; const startTime = performance.now(); video.pause(); video.currentTime = duration; const step = (now: number) => { const elapsed = now - startTime; const nextTime = Math.max(duration - (elapsed / 1000) * reverseRate, 0); video.currentTime = nextTime; if (elapsed >= reverseMs || nextTime <= 0.001) { finishPreview('reverse-complete'); return; } reverseAnimationFrame.current = requestAnimationFrame(step); }; reverseAnimationFrame.current = requestAnimationFrame(step); }; const attemptPlay = () => { if (transitionPreview.reverseMode === 'reverse') return; video .play() .catch((playError) => { logTransitionIssue('play-failed', playError); }); }; const loadSourceCandidate = async () => { didStartPlayback = false; if (startWatchdogTimer) { clearTimeout(startWatchdogTimer); } try { const playableSourceUrl = await resolvePlayableTransitionSource(); if (didFinish) return; video.pause(); cleanupReverseFrame(); video.src = playableSourceUrl; video.currentTime = 0; video.load(); if (transitionPreview.reverseMode !== 'reverse') { attemptPlay(); } startWatchdogTimer = setTimeout(() => { if (didStartPlayback || didFinish) return; logTransitionIssue('playback-start-slow'); attemptPlay(); }, 12000); } catch (error) { logTransitionIssue('source-prepare-failed', error); finishPreview('source-prepare-failed'); } }; if (!sourceUrl) { logTransitionIssue('missing-source'); finishPreview('missing-source'); return () => { cleanupReverseFrame(); }; } const onLoadedMetadata = () => { if (didFinish) return; if (transitionPreview.reverseMode === 'reverse' && !didStartPlayback) { didStartPlayback = true; if (startWatchdogTimer) { clearTimeout(startWatchdogTimer); startWatchdogTimer = null; } runReversePreview(); return; } video.currentTime = 0; attemptPlay(); }; const onCanPlay = () => { if (didFinish) return; attemptPlay(); }; const onPlaying = () => { if (didFinish) return; didStartPlayback = true; if (startWatchdogTimer) { clearTimeout(startWatchdogTimer); startWatchdogTimer = null; } if (transitionPreview.reverseMode !== 'reverse') { const mediaDurationSec = Number(video.duration); const durationSec = Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 ? configuredDurationSec : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 ? mediaDurationSec : NaN; if (Number.isFinite(durationSec) && durationSec > 0) { scheduleFinishByDuration(durationSec); } } }; const onEnded = () => finishPreview('ended'); const onPlaybackError = (eventName: string, error?: unknown) => { if (didFinish) return; logTransitionIssue(eventName, error); finishPreview(eventName); }; const onError = () => onPlaybackError('video-error'); const onAbort = () => onPlaybackError('video-abort'); const onStalled = () => { if (didFinish) return; logTransitionIssue('video-stalled'); }; video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('canplay', onCanPlay); video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); video.addEventListener('error', onError); video.addEventListener('abort', onAbort); video.addEventListener('stalled', onStalled); hardTimeoutTimer = setTimeout(() => { if (didFinish) return; logTransitionIssue('hard-timeout'); finishPreview('hard-timeout'); }, 45000); void loadSourceCandidate(); return () => { video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('canplay', onCanPlay); video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); video.removeEventListener('error', onError); video.removeEventListener('abort', onAbort); video.removeEventListener('stalled', onStalled); clearTimers(); cleanupReverseFrame(); cleanupPreviewBlobUrl(); if (!didFinish) { video.pause(); video.removeAttribute('src'); video.load(); } }; }, [transitionPreview]); useEffect(() => { return () => { if (reverseAnimationFrame.current !== null) { cancelAnimationFrame(reverseAnimationFrame.current); } }; }, []); return ( <> {getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}

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

{errorMessage ? (

{errorMessage}

) : null} {successMessage ? (

{successMessage}

) : null} {pages.length > 0 && isElementEditMode && (
)}
{pages.length > 0 && !isElementEditMode && (
Constructor Controls
{isConstructorEditMode ? 'Drag & configure elements.' : 'Click and interact with rendered elements.'}
)}
{backgroundImageSrc ? ( // eslint-disable-next-line @next/next/no-img-element Background ) : null} {backgroundVideoSrc ? (
{pages.length > 0 && hasEditorSelection && (

{editorTitle}

{selectedElement && ( )}
{!isEditorCollapsed && ( <> {selectedMenuItem === 'background_image' && (
)} {selectedMenuItem === 'background_video' && (

{backgroundVideoDurationNote}

)} {selectedMenuItem === 'background_audio' && (

{backgroundAudioDurationNote}

)} {selectedMenuItem === 'create_transition' && (

Create next page transition

setNewTransitionName(event.target.value) } />

Transition duration is automatic from video metadata.{' '} {newTransitionDurationNote}

)} {selectedElement && (
updateSelectedElement({ label: event.target.value }) } />
updateSelectedElement({ appearDelaySec: normalizeAppearDelaySec( event.target.value, ), }) } />
updateSelectedElement({ appearDurationSec: normalizeAppearDurationSec( event.target.value, ), }) } />

Leave empty for unlimited.

)} {selectedElement && (selectedElement.type === 'navigation_next' || selectedElement.type === 'navigation_prev') && (
updateSelectedElement({ navLabel: event.target.value, }) } />

{selectedMediaDurationNote}

{selectedTransitionDurationNote}

{selectedElement.transitionReverseMode === 'separate_video' && (
)}

Transition duration is set automatically from the selected video. {selectedTransitionDurationNote}

openTransitionPreview('forward')} /> openTransitionPreview('back')} />
)} {selectedElement && selectedElement.type === 'tooltip' && (
updateSelectedElement({ tooltipTitle: event.target.value, }) } />