39948-vm/frontend/src/pages/constructor.tsx
2026-03-26 21:19:18 +04:00

3916 lines
140 KiB
TypeScript

import {
mdiCloudUpload,
mdiContentSave,
mdiExitToApp,
mdiImageMultiple,
mdiMenu,
mdiPlus,
mdiSwapHorizontal,
mdiText,
mdiTooltipText,
mdiViewCarousel,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import NextImage from 'next/image';
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 { 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 { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonObject } from '../lib/parseJson';
import { buildElementStyle, ELEMENT_STYLE_PROPS } from '../lib/elementStyles';
import type { PreloadPageLink, PreloadElement } from '../types/preload';
import type {
CanvasElementType,
CanvasElement,
ConstructorSchema,
ConstructorAsset as ProjectAsset,
AssetOption,
GalleryCard,
CarouselSlide,
NavigationButtonKind,
NormalizedElementDefault,
} from '../types/constructor';
import {
normalizeElementDefault,
buildElementDefaultsMap,
isCanvasElementType,
} from '../types/constructor';
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 NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
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 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 readMediaDuration = (
playbackUrl: string,
mediaType: 'video' | 'audio',
): Promise<number | null> =>
new Promise((resolve) => {
const mediaElement =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
let timeoutId: ReturnType<typeof setTimeout> | 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) {
logger.error(
'Failed to fetch media for duration probing:',
error instanceof Error ? error : { 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<CanvasElementType, string> = {
navigation_next: 'Navigation: Forward',
navigation_prev: 'Navigation: Back',
spot: 'Hotspot',
description: 'Description',
tooltip: 'Tooltip',
gallery: 'Gallery',
carousel: 'Carousel',
logo: 'Logo',
video_player: 'Video Player',
audio_player: 'Audio Player',
popup: 'Popup',
};
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',
descriptionTitleFontFamily: 'inherit',
descriptionTextFontFamily: 'inherit',
descriptionTitleColor: '#000000',
descriptionTextColor: '#4B5563',
descriptionBackgroundColor: 'transparent',
};
}
if (type === 'navigation_next' || type === 'navigation_prev') {
return {
...base,
navLabel: getNavigationButtonLabel(type),
navType: getNavigationButtonKind(type),
navDisabled: false,
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<CanvasElement>,
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,
};
// For style properties, use defaults if element has empty/null/undefined value
// This ensures DB defaults are applied when element has no explicit value
const elementRecord = element as unknown as Record<string, unknown>;
const defaultsRecord = defaults as unknown as Record<string, unknown>;
const mergedRecord = merged as unknown as Record<string, unknown>;
ELEMENT_STYLE_PROPS.forEach((prop) => {
const elementValue = elementRecord[prop];
const defaultValue = defaultsRecord[prop];
const elementIsEmpty =
elementValue === '' ||
elementValue === undefined ||
elementValue === null;
const defaultHasValue =
defaultValue !== undefined &&
defaultValue !== null &&
defaultValue !== '';
if (elementIsEmpty && defaultHasValue) {
mergedRecord[prop] = defaultValue;
}
});
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 =>
buildElementStyle(element);
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const isElementEditMode = mode === 'element_edit';
const projectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const pageElementsListHref = useMemo(() => {
if (!projectId) return '/project-element-defaults/project-element-defaults-list';
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
}, [projectId]);
const pageIdFromRoute = useMemo(() => {
const value = router.query.pageId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.pageId]);
const elementIdFromRoute = useMemo(() => {
const value = router.query.elementId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.elementId]);
const [pages, setPages] = useState<TourPage[]>([]);
const [pageLinks, setPageLinks] = useState<PreloadPageLink[]>([]);
const [allPagesPreloadElements, setAllPagesPreloadElements] = useState<
PreloadElement[]
>([]);
const [assets, setAssets] = useState<ProjectAsset[]>([]);
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
>({});
const [activePageId, setActivePageId] = useState('');
const [projectName, setProjectName] = useState('');
const [elements, setElements] = useState<CanvasElement[]>([]);
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
const [selectedElementId, setSelectedElementId] = useState('');
const [selectedMenuItem, setSelectedMenuItem] =
useState<EditorMenuItem>('none');
const [transitionPreview, setTransitionPreview] =
useState<TransitionPreviewState | null>(null);
const [pendingNavigationPageId, setPendingNavigationPageId] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = 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<string, number | null>
>({});
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState<
Record<string, boolean>
>({});
const [constructorInteractionMode, setConstructorInteractionMode] =
useState<ConstructorInteractionMode>('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<DragElementState | null>(null);
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
const didSetInitialCanvasFocus = useRef(false);
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
const preloadedIconUrlsRef = useRef<Set<string>>(new Set());
const activePage = useMemo(
() => pages.find((item) => item.id === activePageId) || null,
[activePageId, pages],
);
// 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,
})),
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),
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);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingNavigationPageId('');
});
});
} else {
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingNavigationPageId('');
}
},
timeouts: {
playbackStartMs: 3000,
hardTimeoutMs: 45000,
},
features: {
useBlobUrl: true,
preDecodeImages: false, // We handle image loading via usePageSwitch
},
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
},
});
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
return ['navigation_next', 'navigation_prev'];
}, []);
const pageNameById = useMemo(() => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.id)] = page.name || `Page ${index + 1}`;
});
return acc;
}, [pages]);
const pageNameBySlug = useMemo(() => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.slug)] = 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) => {
logger.error(
'Failed to resolve media duration:',
error instanceof Error ? error : { 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 (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&project=${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<string, unknown>) => normalizeElementDefault(row))
.filter((d): 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],
);
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]);
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<string>();
preloadedIconUrlsRef.current.forEach((url) => {
if (targetSet.has(url)) nextPreloaded.add(url);
});
preloadedIconUrlsRef.current = nextPreloaded;
setPreloadedIconUrlMap(() => {
const nextMap: Record<string, boolean> = {};
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 = () => {
logger.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<ConstructorSchema>(
activePage.ui_schema_json,
{},
);
const normalizedElements = Array.isArray(schema.elements)
? schema.elements
.filter(
(item) =>
item && item.type && labelByType[item.type as CanvasElementType],
)
.map((item) => {
const elementType = item.type as CanvasElementType;
const normalizedElement: CanvasElement = {
...item,
id: String(item.id || createLocalId()),
label:
typeof item.label === 'string' && item.label.trim()
? item.label
: labelByType[elementType],
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
appearDurationSec: normalizeAppearDurationSec(
item.appearDurationSec,
),
galleryCards: Array.isArray(item.galleryCards)
? item.galleryCards.map((card: 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,
// 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
: 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 '';
});
// 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]);
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.';
logger.error(
'Failed to create page from constructor:',
error instanceof Error ? error : { 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,
};
// Transitions are now stored directly in navigation elements as transitionVideoUrl
setSuccessMessage('Transition video can be set directly on navigation elements.');
setNewTransitionName('');
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to create transition.';
logger.error(
'Failed to create transition from constructor:',
error instanceof Error ? error : { 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<Record<string, any>>(
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(activePageId);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save constructor changes.';
logger.error(
'Failed to save constructor changes:',
error instanceof Error ? error : { 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,
]);
/**
* Save all dev content to stage environment.
* This copies all pages, elements, transitions, and audio from dev to stage.
*/
const saveToStage = useCallback(async () => {
if (!projectId) {
setErrorMessage('Project ID is required to save to stage.');
return;
}
// First save current changes
await saveConstructor();
try {
setIsSavingToStage(true);
setErrorMessage('');
setSuccessMessage('');
await axios.post('/publish/save-to-stage', { projectId });
setSuccessMessage(
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save to stage.';
logger.error(
'Failed to save to stage:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
} finally {
setIsSavingToStage(false);
}
}, [projectId, saveConstructor]);
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<HTMLImageElement>) => {
event.preventDefault();
};
const updateSelectedElement = (patch: Partial<CanvasElement>) => {
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<GalleryCard>) => {
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<CarouselSlide>,
) => {
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)) {
// Disable navigation while transition is playing or buffering
if (transitionPreview || isReverseBuffering) {
return;
}
if (element.navDisabled) {
return;
}
const direction =
element.navType === 'back' || element.type === 'navigation_prev'
? 'back'
: 'forward';
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = String(element.targetPageSlug || '').trim();
const legacyTargetPageId = String(element.targetPageId || '').trim();
// Resolve slug to page ID, or use legacy targetPageId
const targetPage = targetPageSlug
? pages.find((p) => p.slug === targetPageSlug)
: legacyTargetPageId
? pages.find((p) => p.id === legacyTargetPageId)
: null;
const targetPageId = targetPage?.id || '';
if (!targetPageId) {
setErrorMessage(
'No target page configured for this navigation button.',
);
return;
}
const hasPlayableTransition =
Boolean(element.transitionVideoUrl) &&
!(
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
);
if (!hasPlayableTransition) {
setPendingNavigationPageId('');
setTransitionPreview(null);
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
switchToPage(targetPage).then(() => {
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) {
// Use img tag with flexible sizing - auto for dimensions not provided
const imgStyle: React.CSSProperties = {
width: element.width || 'auto',
height: element.height || 'auto',
objectFit: 'contain',
};
// eslint-disable-next-line @next/next/no-img-element
return (
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation icon'
style={imgStyle}
draggable={false}
/>
);
}
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageName = element.targetPageSlug
? pageNameBySlug[element.targetPageSlug]
: element.targetPageId
? pageNameById[element.targetPageId]
: '';
return (
<div className='flex flex-col items-start gap-1'>
<div className='flex items-center gap-2'>
<span>{navigationLabel}</span>
</div>
{targetPageName ? (
<span className='text-[10px] text-gray-500'>
To: {targetPageName}
</span>
) : null}
</div>
);
}
if (element.type === 'tooltip') {
if (element.iconUrl) {
return (
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
<NextImage
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip icon'
fill
sizes='100vw'
className='object-contain'
draggable={false}
/>
</div>
);
}
return (
<div className='max-w-[200px] text-left'>
<p className='text-[11px] font-bold'>{element.tooltipTitle}</p>
<p className='text-[10px] text-gray-600 line-clamp-3'>
{element.tooltipText || 'Tooltip text'}
</p>
</div>
);
}
if (element.type === 'description') {
if (element.iconUrl) {
return (
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
<NextImage
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description icon'
fill
sizes='100vw'
className='object-contain'
draggable={false}
/>
</div>
);
}
const bgColor = element.descriptionBackgroundColor || 'transparent';
return (
<div
className='w-full text-left p-2 rounded'
style={{ backgroundColor: bgColor }}
>
<p
className='font-bold'
style={{
fontSize: element.descriptionTitleFontSize || '48px',
fontFamily: element.descriptionTitleFontFamily || 'inherit',
color: element.descriptionTitleColor || '#000000',
}}
>
{element.descriptionTitle || 'TITLE'}
</p>
{element.descriptionText && (
<p
className='line-clamp-4'
style={{
fontSize: element.descriptionTextFontSize || '36px',
fontFamily: element.descriptionTextFontFamily || 'inherit',
color: element.descriptionTextColor || '#4B5563',
}}
>
{element.descriptionText}
</p>
)}
</div>
);
}
if (element.type === 'gallery') {
const cards = element.galleryCards || [];
return (
<div className='w-[220px]'>
<p className='mb-1 text-left text-[10px] font-semibold text-gray-600'>
Gallery ({cards.length})
</p>
<div className='grid grid-cols-3 gap-1'>
{cards.slice(0, 6).map((card) => (
<div
key={card.id}
className='h-12 overflow-hidden rounded bg-gray-100'
>
{card.imageUrl ? (
<div className='relative h-full w-full'>
<NextImage
src={resolveAssetPlaybackUrl(card.imageUrl)}
alt={card.title || 'Gallery card'}
fill
sizes='100vw'
className='object-cover'
draggable={false}
/>
</div>
) : (
<div className='flex h-full items-center justify-center text-[9px] text-gray-400'>
No image
</div>
)}
</div>
))}
</div>
</div>
);
}
if (element.type === 'carousel') {
const firstSlide = (element.carouselSlides || [])[0];
return (
<div className='w-[220px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
Carousel ({element.carouselSlides?.length || 0})
</p>
<div className='h-20 overflow-hidden rounded bg-gray-100'>
{firstSlide?.imageUrl ? (
<div className='relative h-full w-full'>
<NextImage
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
fill
sizes='100vw'
className='object-cover'
draggable={false}
/>
</div>
) : (
<div className='flex h-full items-center justify-center text-[10px] text-gray-400'>
No slide image
</div>
)}
</div>
<p className='mt-1 text-[10px] text-gray-600 line-clamp-1'>
{firstSlide?.caption || 'No caption'}
</p>
{(element.carouselPrevIconUrl || element.carouselNextIconUrl) && (
<div className='mt-1 flex items-center justify-between text-[9px] text-gray-500'>
<span className='flex items-center gap-1'>
{element.carouselPrevIconUrl ? (
<div className='relative h-3 w-3'>
<NextImage
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
alt='Previous icon'
fill
sizes='100vw'
className='object-contain'
draggable={false}
/>
</div>
) : null}
Prev
</span>
<span className='flex items-center gap-1'>
Next
{element.carouselNextIconUrl ? (
<div className='relative h-3 w-3'>
<NextImage
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
alt='Next icon'
fill
sizes='100vw'
className='object-contain'
draggable={false}
/>
</div>
) : null}
</span>
</div>
)}
</div>
);
}
if (element.type === 'video_player') {
return (
<div className='w-[220px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
Video player
</p>
<video
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`}
className='h-24 w-full rounded bg-black object-cover'
src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
/>
</div>
);
}
if (element.type === 'audio_player') {
return (
<div className='w-[240px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
Audio player
</p>
<audio
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
className='w-full'
src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
/>
</div>
);
}
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 = {};
// Prefer hook's blob URLs (instant display) but fall back to resolved URLs for manual changes
const backgroundImageSrc =
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc =
pageSwitch.currentBgVideoUrl || resolveAssetPlaybackUrl(backgroundVideoUrl);
const backgroundAudioSrc =
pageSwitch.currentBgAudioUrl || 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';
}
return (
<>
<Head>
<title>
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
</title>
</Head>
<div className='relative w-screen h-screen bg-black overflow-hidden'>
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
<p className='text-xs font-semibold text-gray-700'>
{projectName || 'Loading project...'}
</p>
{errorMessage ? (
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
{errorMessage}
</p>
) : null}
{successMessage ? (
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
{successMessage}
</p>
) : null}
{pages.length > 0 && isElementEditMode && (
<div className='flex items-center gap-2'>
<BaseButton
color='lightDark'
label='Back to Elements'
icon={mdiExitToApp}
href={pageElementsListHref}
/>
<BaseButton
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={saveConstructor}
disabled={isSaving}
/>
</div>
)}
</div>
{pages.length > 0 && !isElementEditMode && (
<div
className='fixed z-40 w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
style={{
left: constructorControlsPosition.x,
top: constructorControlsPosition.y,
}}
>
<div
className='flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 px-3 py-2'
onMouseDown={onConstructorControlsDragStart}
>
<span className='text-xs font-bold uppercase'>
Constructor Controls
</span>
</div>
<div className='space-y-2 p-3'>
<div className='flex flex-wrap items-center gap-2'>
<select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
value={activePageId}
onChange={(event) => {
const page = pages.find((p) => p.id === event.target.value);
if (page) switchToPage(page);
}}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
<BaseButton
color='lightDark'
label='Exit to Assets'
icon={mdiExitToApp}
href={
projectId
? `/projects/${projectId}`
: '/projects/projects-list'
}
/>
</div>
<div className='flex flex-wrap items-center gap-2'>
<div className='inline-flex overflow-hidden rounded border border-gray-300 bg-white text-xs font-semibold'>
<button
type='button'
className={`px-3 py-1.5 ${
isConstructorEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setConstructorInteractionMode('edit')}
>
Edit mode
</button>
<button
type='button'
className={`border-l border-gray-300 px-3 py-1.5 ${
!isConstructorEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setConstructorInteractionMode('interact')}
>
Interact mode
</button>
</div>
<span className='text-[11px] text-gray-600'>
{isConstructorEditMode
? 'Drag & configure elements.'
: 'Click and interact with rendered elements.'}
</span>
</div>
</div>
</div>
)}
<div
ref={canvasRef}
tabIndex={-1}
className='absolute inset-0 bg-black overflow-hidden'
style={canvasBackgroundStyle}
>
{/* Background image - CSS backgroundImage on canvas provides instant display,
NextImage enhances with optimized loading. bg-black prevents white flash. */}
{backgroundImageSrc ? (
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
<NextImage
key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageSrc}
alt='Background'
fill
sizes='100vw'
className='object-cover'
draggable={false}
onLoad={() => pageSwitch.markBackgroundReady()}
onError={() => pageSwitch.markBackgroundReady()}
/>
</div>
) : null}
{/* Previous background overlay - shows during page switch until new bg is ready */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{backgroundVideoSrc ? (
<video
key={`bg_video_${backgroundVideoSrc}`}
className='absolute inset-0 w-full h-full object-cover'
src={backgroundVideoSrc}
autoPlay
loop
muted
playsInline
/>
) : null}
{backgroundAudioSrc ? (
<audio
key={`bg_audio_${backgroundAudioSrc}`}
src={backgroundAudioSrc}
autoPlay
loop
hidden
/>
) : null}
{isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-sm text-gray-500'>Loading constructor...</p>
</div>
) : pages.length === 0 ? (
<div className='absolute inset-0 flex items-center justify-center'>
<BaseButton
color='info'
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
icon={mdiPlus}
onClick={createPage}
disabled={isCreatingPage}
/>
</div>
) : (
elements.map((element) => {
const shouldRender =
selectedElementId === element.id ||
(isElementVisibleOnCanvas(element) &&
isElementReadyForCanvasRender(element));
if (!shouldRender) return null;
const hasIconDrivenSize =
Boolean(element.iconUrl) &&
(element.type === 'tooltip' ||
element.type === 'description' ||
element.type === 'navigation_next' ||
element.type === 'navigation_prev');
const isNavigationIconElement =
Boolean(element.iconUrl) &&
(element.type === 'navigation_next' ||
element.type === 'navigation_prev');
const hasTransparentBackground =
(element.type === 'description' &&
!element.iconUrl &&
(!element.descriptionBackgroundColor ||
element.descriptionBackgroundColor === 'transparent')) ||
// Navigation buttons with icons should be transparent (icon is visible)
((element.type === 'navigation_next' ||
element.type === 'navigation_prev') &&
Boolean(element.iconUrl)) ||
element.type === 'tooltip' ||
element.type === 'gallery' ||
element.type === 'carousel';
const isNavDisabled =
isNavigationElementType(element.type) &&
(element.navDisabled ||
transitionPreview ||
isReverseBuffering);
return (
<button
key={element.id}
type='button'
data-constructor-element-id={element.id}
className={`absolute rounded text-xs font-semibold text-left ${
hasTransparentBackground ? '' : 'border shadow'
} ${
hasIconDrivenSize
? 'overflow-hidden p-0 leading-none'
: 'px-3 py-2'
} ${
isNavigationIconElement
? 'flex items-center justify-center'
: ''
} ${
isConstructorEditMode
? 'cursor-move'
: isNavDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
selectedElementId === element.id
? 'border-blue-500 bg-blue-50 border shadow'
: hasTransparentBackground
? 'bg-transparent'
: 'border-blue-200 bg-white/95'
}`}
style={{
...buildCanvasElementStyle(element),
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseDown={
isConstructorEditMode
? (event) => onElementMouseDown(event, element.id)
: undefined
}
onClick={() => onCanvasElementClick(element)}
>
{renderCanvasElementContent(element)}
</button>
);
})
)}
</div>
{pages.length > 0 && hasEditorSelection && (
<div
ref={elementEditorRef}
className={`fixed z-40 ${isEditorCollapsed ? 'w-[260px]' : 'w-[380px]'} max-h-[calc(100vh-2rem)] overflow-auto rounded-lg border border-gray-200 bg-white/95 p-3 shadow-xl`}
style={{ left: editorPosition.x, top: editorPosition.y }}
>
<div
className='mb-3 flex items-center justify-between gap-2 cursor-move'
onMouseDown={onElementEditorDragStart}
>
<p className='text-xs font-bold uppercase tracking-wide text-gray-700'>
{editorTitle}
</p>
<div className='flex items-center gap-2'>
<button
type='button'
className='text-xs text-gray-700 hover:underline'
onClick={() => setIsEditorCollapsed((prev) => !prev)}
>
{isEditorCollapsed ? 'Expand' : 'Collapse'}
</button>
{selectedElement && (
<button
type='button'
className='text-xs text-red-600 hover:underline'
onClick={removeSelectedElement}
>
Remove element
</button>
)}
</div>
</div>
{!isEditorCollapsed && (
<>
{selectedMenuItem === 'background_image' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Background image
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={backgroundImageUrl}
onChange={(event) => {
const nextValue = event.target.value;
setBackgroundImageUrl(nextValue);
if (nextValue) setBackgroundVideoUrl('');
}}
>
<option value=''>None</option>
{backgroundImageSelectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{selectedMenuItem === 'background_video' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Background video
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={backgroundVideoUrl}
onChange={(event) => {
const nextValue = event.target.value;
setBackgroundVideoUrl(nextValue);
if (nextValue) setBackgroundImageUrl('');
}}
>
<option value=''>None</option>
{backgroundVideoSelectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='mt-1 text-[11px] text-gray-500'>
{backgroundVideoDurationNote}
</p>
</div>
)}
{selectedMenuItem === 'background_audio' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Background audio (loop)
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={backgroundAudioUrl}
onChange={(event) =>
setBackgroundAudioUrl(event.target.value)
}
>
<option value=''>None</option>
{backgroundAudioSelectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='mt-1 text-[11px] text-gray-500'>
{backgroundAudioDurationNote}
</p>
</div>
)}
{selectedMenuItem === 'create_transition' && (
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-600'>
Create next page transition
</p>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Name'
value={newTransitionName}
onChange={(event) =>
setNewTransitionName(event.target.value)
}
/>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={newTransitionVideoUrl}
onChange={(event) =>
setNewTransitionVideoUrl(event.target.value)
}
>
<option value=''>Transition video asset</option>
{transitionVideoAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='text-[11px] text-gray-500'>
Transition duration is automatic from video metadata.{' '}
{newTransitionDurationNote}
</p>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={newTransitionSupportsReverse}
onChange={(event) =>
setNewTransitionSupportsReverse(event.target.checked)
}
/>
Supports reverse playback
</label>
<button
type='button'
className='menu-action-btn'
onClick={createTransition}
disabled={isCreatingTransition}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>
{isCreatingTransition
? 'Creating Transition...'
: 'Create Transition'}
</span>
</button>
</div>
)}
{selectedElement && (
<div className='mb-2 space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Label
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.label}
onChange={(event) =>
updateSelectedElement({ label: event.target.value })
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Appear delay (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
type='number'
min='0'
step='0.1'
value={selectedElement.appearDelaySec ?? 0}
onChange={(event) =>
updateSelectedElement({
appearDelaySec: normalizeAppearDelaySec(
event.target.value,
),
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Appear duration (sec)
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
type='number'
min='0.1'
step='0.1'
placeholder='Unlimited'
value={selectedElement.appearDurationSec ?? ''}
onChange={(event) =>
updateSelectedElement({
appearDurationSec: normalizeAppearDurationSec(
event.target.value,
),
})
}
/>
<p className='mt-1 text-[11px] text-gray-500'>
Leave empty for unlimited.
</p>
</div>
</div>
)}
{selectedElement &&
(selectedElement.type === 'navigation_next' ||
selectedElement.type === 'navigation_prev') && (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.navType === 'back' ||
selectedElement.navType === 'forward'
? selectedElement.navType
: getNavigationButtonKind(selectedElement.type)
}
onChange={(event) => {
const requestedKind: NavigationButtonKind =
event.target.value === 'back'
? 'back'
: 'forward';
const requestedType =
getNavigationTypeFromKind(requestedKind);
const nextType = allowedNavigationTypes.includes(
requestedType,
)
? requestedType
: allowedNavigationTypes[0];
updateSelectedElement(
normalizeNavigationElementType(
selectedElement,
nextType,
),
);
}}
>
<option
value='forward'
disabled={
!allowedNavigationTypes.includes(
'navigation_next',
)
}
>
Forward
</option>
<option
value='back'
disabled={
!allowedNavigationTypes.includes(
'navigation_prev',
)
}
>
Back
</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Button text
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.navLabel || ''}
onChange={(event) =>
updateSelectedElement({
navLabel: event.target.value,
})
}
/>
</div>
<div>
<label className='flex items-center gap-2 text-[11px] font-semibold text-gray-600'>
<input
type='checkbox'
checked={selectedElement.navDisabled || false}
onChange={(event) =>
updateSelectedElement({
navDisabled: event.target.checked,
})
}
/>
Disabled
</label>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Icon
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.iconUrl || ''}
onChange={(event) =>
updateSelectedElement({
iconUrl: event.target.value,
})
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
selectedElement.iconUrl,
`Current icon · ${selectedElement.iconUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='mt-1 text-[11px] text-gray-500'>
{selectedMediaDurationNote}
</p>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Target page
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.targetPageSlug || ''}
onChange={(event) =>
updateSelectedElement({
targetPageSlug: event.target.value,
// Clear legacy targetPageId when using slug
targetPageId: '',
})
}
>
<option value=''>Not selected</option>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.transitionVideoUrl || ''}
onChange={(event) => {
const nextVideoUrl = event.target.value;
const resolvedDuration =
getKnownDurationForSource(nextVideoUrl);
updateSelectedElement({
transitionVideoUrl: nextVideoUrl,
transitionDurationSec:
resolvedDuration || undefined,
});
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoAssetOptions,
selectedElement.transitionVideoUrl,
`Current video · ${selectedElement.transitionVideoUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='mt-1 text-[11px] text-gray-500'>
{selectedTransitionDurationNote}
</p>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.transitionReverseMode ||
'auto_reverse'
}
onChange={(event) =>
updateSelectedElement({
transitionReverseMode:
event.target.value === 'separate_video'
? 'separate_video'
: 'auto_reverse',
})
}
>
<option value='auto_reverse'>
Auto reverse transition video
</option>
<option value='separate_video'>
Use separate back-transition video
</option>
</select>
</div>
{selectedElement.transitionReverseMode ===
'separate_video' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.reverseVideoUrl || ''}
onChange={(event) =>
updateSelectedElement({
reverseVideoUrl: event.target.value,
})
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoAssetOptions,
selectedElement.reverseVideoUrl,
`Current back video · ${selectedElement.reverseVideoUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
<p className='text-[11px] text-gray-500'>
Transition duration is set automatically from the
selected video. {selectedTransitionDurationNote}
</p>
<div className='flex gap-2 pt-1'>
<BaseButton
small
color='lightDark'
label='Preview Forward'
onClick={() => openTransitionPreview('forward')}
/>
<BaseButton
small
color='lightDark'
label='Preview Back'
onClick={() => openTransitionPreview('back')}
/>
</div>
</div>
)}
{selectedElement && selectedElement.type === 'tooltip' && (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Icon
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.iconUrl || ''}
onChange={(event) =>
updateSelectedElement({ iconUrl: event.target.value })
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
selectedElement.iconUrl,
`Current icon · ${selectedElement.iconUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Tooltip title
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.tooltipTitle || ''}
onChange={(event) =>
updateSelectedElement({
tooltipTitle: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Tooltip text
</label>
<textarea
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
rows={4}
value={selectedElement.tooltipText || ''}
onChange={(event) =>
updateSelectedElement({
tooltipText: event.target.value,
})
}
/>
</div>
</div>
)}
{selectedElement && selectedElement.type === 'description' && (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Icon
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.iconUrl || ''}
onChange={(event) =>
updateSelectedElement({ iconUrl: event.target.value })
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
selectedElement.iconUrl,
`Current icon · ${selectedElement.iconUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Description title
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.descriptionTitle || ''}
onChange={(event) =>
updateSelectedElement({
descriptionTitle: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Description text
</label>
<textarea
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
rows={5}
value={selectedElement.descriptionText || ''}
onChange={(event) =>
updateSelectedElement({
descriptionText: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title font size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.descriptionTitleFontSize || '48px'
}
onChange={(event) =>
updateSelectedElement({
descriptionTitleFontSize: event.target.value,
})
}
placeholder='e.g. 48px'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text font size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.descriptionTextFontSize || '36px'
}
onChange={(event) =>
updateSelectedElement({
descriptionTextFontSize: event.target.value,
})
}
placeholder='e.g. 36px'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title font family
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.descriptionTitleFontFamily ||
'inherit'
}
onChange={(event) =>
updateSelectedElement({
descriptionTitleFontFamily: event.target.value,
})
}
placeholder='e.g. Arial, Helvetica, sans-serif'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text font family
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.descriptionTextFontFamily || 'inherit'
}
onChange={(event) =>
updateSelectedElement({
descriptionTextFontFamily: event.target.value,
})
}
placeholder='e.g. Arial, Helvetica, sans-serif'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title color
</label>
<input
type='color'
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
value={
selectedElement.descriptionTitleColor || '#000000'
}
onChange={(event) =>
updateSelectedElement({
descriptionTitleColor: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text color
</label>
<input
type='color'
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
value={
selectedElement.descriptionTextColor || '#4B5563'
}
onChange={(event) =>
updateSelectedElement({
descriptionTextColor: event.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Background color
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.descriptionBackgroundColor ||
'transparent'
}
onChange={(event) =>
updateSelectedElement({
descriptionBackgroundColor: event.target.value,
})
}
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
/>
</div>
</div>
)}
{selectedElement &&
(selectedElement.type === 'video_player' ||
selectedElement.type === 'audio_player') && (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{selectedElement.type === 'video_player'
? 'Video asset'
: 'Audio asset'}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.mediaUrl || ''}
onChange={(event) =>
updateSelectedElement({
mediaUrl: event.target.value,
})
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
selectedElement.type === 'video_player'
? videoAssetOptions
: audioAssetOptions,
selectedElement.mediaUrl,
`Current media · ${selectedElement.mediaUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={Boolean(selectedElement.mediaAutoplay)}
onChange={(event) =>
updateSelectedElement({
mediaAutoplay: event.target.checked,
})
}
/>
Autoplay
</label>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={Boolean(selectedElement.mediaLoop)}
onChange={(event) =>
updateSelectedElement({
mediaLoop: event.target.checked,
})
}
/>
Loop
</label>
{selectedElement.type === 'video_player' && (
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={Boolean(selectedElement.mediaMuted)}
onChange={(event) =>
updateSelectedElement({
mediaMuted: event.target.checked,
})
}
/>
Muted
</label>
)}
</div>
)}
{selectedElement && selectedElement.type === 'gallery' && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-600'>
Gallery cards
</p>
<button
type='button'
className='text-xs text-blue-700 hover:underline'
onClick={addGalleryCard}
>
+ Add card
</button>
</div>
{(selectedElement.galleryCards || []).map((card, index) => (
<div
key={card.id}
className='rounded border border-gray-200 p-2 space-y-2'
>
<div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-700'>
Card {index + 1}
</p>
<button
type='button'
className='text-xs text-red-600 hover:underline'
onClick={() => removeGalleryCard(card.id)}
>
Remove
</button>
</div>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={card.imageUrl}
onChange={(event) =>
updateGalleryCard(card.id, {
imageUrl: event.target.value,
})
}
>
<option value=''>Image asset</option>
{addFallbackAssetOption(
imageAssetOptions,
card.imageUrl,
`Current image · ${card.imageUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Title'
value={card.title}
onChange={(event) =>
updateGalleryCard(card.id, {
title: event.target.value,
})
}
/>
<textarea
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Description'
rows={3}
value={card.description}
onChange={(event) =>
updateGalleryCard(card.id, {
description: event.target.value,
})
}
/>
</div>
))}
</div>
)}
{selectedElement && selectedElement.type === 'carousel' && (
<div className='space-y-2'>
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'>
Navigation icons
</p>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.carouselPrevIconUrl || ''}
onChange={(event) =>
updateSelectedElement({
carouselPrevIconUrl: event.target.value,
})
}
>
<option value=''>Previous icon</option>
{addFallbackAssetOption(
iconAssetOptions,
selectedElement.carouselPrevIconUrl,
`Current prev icon · ${selectedElement.carouselPrevIconUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedElement.carouselNextIconUrl || ''}
onChange={(event) =>
updateSelectedElement({
carouselNextIconUrl: event.target.value,
})
}
>
<option value=''>Next icon</option>
{addFallbackAssetOption(
iconAssetOptions,
selectedElement.carouselNextIconUrl,
`Current next icon · ${selectedElement.carouselNextIconUrl || ''}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-600'>
Carousel slides
</p>
<button
type='button'
className='text-xs text-blue-700 hover:underline'
onClick={addCarouselSlide}
>
+ Add slide
</button>
</div>
{(selectedElement.carouselSlides || []).map(
(slide, index) => (
<div
key={slide.id}
className='rounded border border-gray-200 p-2 space-y-2'
>
<div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-700'>
Slide {index + 1}
</p>
<button
type='button'
className='text-xs text-red-600 hover:underline'
onClick={() => removeCarouselSlide(slide.id)}
>
Remove
</button>
</div>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={slide.imageUrl}
onChange={(event) =>
updateCarouselSlide(slide.id, {
imageUrl: event.target.value,
})
}
>
<option value=''>Image asset</option>
{addFallbackAssetOption(
imageAssetOptions,
slide.imageUrl,
`Current image · ${slide.imageUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Caption'
value={slide.caption}
onChange={(event) =>
updateCarouselSlide(slide.id, {
caption: event.target.value,
})
}
/>
</div>
),
)}
</div>
)}
</>
)}
</div>
)}
{pages.length > 0 && !isElementEditMode && (
<div
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: menuPosition.x, top: menuPosition.y }}
>
<div
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
onMouseDown={onMenuDragStart}
>
<span className='text-xs font-bold uppercase'>
Constructor Menu
</span>
<button
type='button'
onClick={() => setIsMenuOpen((prev) => !prev)}
>
<BaseIcon path={mdiMenu} size={18} />
</button>
</div>
{isMenuOpen && (
<div className='p-2 space-y-1'>
<button
type='button'
className='menu-action-btn'
onClick={() => selectMenuItemForEdit('background_image')}
>
<BaseIcon path={mdiImageMultiple} size={16} />
<span>Background Image</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => selectMenuItemForEdit('background_video')}
>
<BaseIcon path={mdiViewCarousel} size={16} />
<span>Background Video</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => selectMenuItemForEdit('background_audio')}
>
<BaseIcon path={mdiTooltipText} size={16} />
<span>Background Audio</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement(allowedNavigationTypes[0])}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>Add Navigation Button</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => selectMenuItemForEdit('create_transition')}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>Add Transition</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('gallery')}
>
<BaseIcon path={mdiImageMultiple} size={16} />
<span>Add Gallery</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('carousel')}
>
<BaseIcon path={mdiViewCarousel} size={16} />
<span>Add Carousel</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('tooltip')}
>
<BaseIcon path={mdiTooltipText} size={16} />
<span>Add Tooltip</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('description')}
>
<BaseIcon path={mdiText} size={16} />
<span>Add Description</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('video_player')}
>
<BaseIcon path={mdiViewCarousel} size={16} />
<span>Add Video Player</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={() => addElement('audio_player')}
>
<BaseIcon path={mdiTooltipText} size={16} />
<span>Add Audio Player</span>
</button>
<button
type='button'
className='menu-action-btn'
onClick={createPage}
disabled={isCreatingPage}
>
<BaseIcon path={mdiPlus} size={16} />
<span>
{isCreatingPage ? 'Creating Page...' : 'Create New Page'}
</span>
</button>
<button
type='button'
className='menu-action-btn !text-blue-700'
onClick={saveConstructor}
disabled={isSaving}
>
<BaseIcon path={mdiContentSave} size={16} />
<span>{isSaving ? 'Saving...' : 'Save'}</span>
</button>
<button
type='button'
className='menu-action-btn !text-green-700'
onClick={saveToStage}
disabled={isSavingToStage || isSaving}
title='Copy all dev content to stage environment'
>
<BaseIcon path={mdiCloudUpload} size={16} />
<span>
{isSavingToStage ? 'Publishing...' : 'Save to Stage'}
</span>
</button>
<button
type='button'
className='menu-action-btn !text-red-700'
onClick={() =>
router.push(
projectId
? `/projects/${projectId}`
: '/projects/projects-list',
)
}
>
<BaseIcon path={mdiExitToApp} size={16} />
<span>Exit</span>
</button>
</div>
)}
</div>
)}
</div>
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{ opacity: isReverseBuffering ? 0 : 1 }}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
)}
<style jsx>{`
.menu-action-btn {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 6px 8px;
border-radius: 6px;
color: #1f2937;
text-align: left;
}
.menu-action-btn:hover {
background: #f3f4f6;
}
`}</style>
</>
);
};
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ConstructorPage;