39948-vm/frontend/src/pages/constructor.tsx
2026-03-20 10:10:59 +04:00

3964 lines
138 KiB
TypeScript

import {
mdiContentSave,
mdiExitToApp,
mdiImageMultiple,
mdiMenu,
mdiPlus,
mdiSwapHorizontal,
mdiText,
mdiTooltipText,
mdiViewCarousel,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import { baseURLApi, getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type TourPage = {
id: string;
name?: string;
slug?: string;
sort_order?: number;
environment?: string;
source_key?: string;
requires_auth?: boolean;
ui_schema_json?: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
background_loop?: boolean;
};
type ProjectAsset = {
id: string;
name?: string;
asset_type?: 'image' | 'video' | 'audio' | 'file';
type?:
| 'icon'
| 'background_image'
| 'audio'
| 'video'
| 'transition'
| 'logo'
| 'favicon'
| 'document'
| 'general';
cdn_url?: string | null;
storage_key?: string | null;
};
type AssetOption = {
value: string;
label: string;
};
type CanvasElementType =
| 'navigation_next'
| 'navigation_prev'
| 'gallery'
| 'carousel'
| 'tooltip'
| 'description'
| 'video_player'
| 'audio_player';
type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
type NavigationButtonKind = 'forward' | 'back';
type CanvasElement = {
id: string;
type: CanvasElementType;
label: string;
xPercent: number;
yPercent: number;
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string;
padding?: string;
gap?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
border?: string;
borderRadius?: string;
opacity?: string;
boxShadow?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: string;
appearDelaySec?: number;
appearDurationSec?: number | null;
iconUrl?: string;
galleryCards?: GalleryCard[];
carouselSlides?: CarouselSlide[];
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
tooltipTitle?: string;
tooltipText?: string;
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
descriptionTextFontSize?: string;
descriptionBackgroundColor?: string;
navLabel?: string;
navType?: NavigationButtonKind;
targetPageId?: string;
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
mediaUrl?: string;
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
};
type GalleryCard = {
id: string;
imageUrl: string;
title: string;
description: string;
};
type CarouselSlide = {
id: string;
imageUrl: string;
caption: string;
};
type ConstructorSchema = {
elements?: CanvasElement[];
};
type UiElementDefault = {
id: string;
element_type?: string;
is_active?: boolean;
default_settings_json?: Partial<CanvasElement> | string | null;
};
type DragElementState = {
id: string;
pointerOffsetX: number;
pointerOffsetY: number;
};
type TransitionPreviewState = {
videoUrl: string;
reverseMode: 'none' | 'reverse' | 'separate';
reverseVideoUrl?: string;
durationSec?: number;
title: string;
};
type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
type ConstructorPageProps = {
mode?: 'constructor' | 'element_edit';
};
type ConstructorInteractionMode = 'edit' | 'interact';
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T;
}
if (typeof value === 'object') {
return value as T;
}
return (fallback || ({} as T)) as T;
} catch (error) {
console.error('Failed to parse constructor JSON:', error);
return (fallback || ({} as T)) as T;
}
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const normalizeAppearDelaySec = (value: unknown) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Number(parsed);
};
const normalizeAppearDurationSec = (value: unknown) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Number(parsed);
};
const getTrimmedCssValue = (value: unknown) => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const getAssetLabel = (asset: ProjectAsset) => {
const baseName = asset.name?.trim() || 'Untitled asset';
const source = String(asset.storage_key || asset.cdn_url || '').trim();
return `${baseName}${source ? ` · ${source}` : ''}`;
};
const getAssetSourceValue = (asset: ProjectAsset) =>
String(asset.storage_key || asset.cdn_url || '').trim();
const formatDurationNote = (durationSec?: number | string | null) => {
const parsed = Number(durationSec);
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
const totalSeconds = Math.round(parsed);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return `Duration: ${seconds}s`;
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
};
const resolveAssetPlaybackUrl = (value?: string) => {
const normalized = String(value || '').trim();
if (!normalized) return '';
if (normalized.startsWith('data:') || normalized.startsWith('blob:'))
return normalized;
if (normalized.startsWith('/api/file/download')) return normalized;
if (normalized.startsWith('/file/download'))
return `${baseURLApi}${normalized}`;
if (normalized.startsWith('http://') || normalized.startsWith('https://'))
return normalized;
const normalizedPrivateUrl = normalized.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
};
const readMediaDuration = (
playbackUrl: string,
mediaType: 'video' | 'audio',
): Promise<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) {
console.error('Failed to fetch media for duration probing:', error);
return null;
}
};
const isBackgroundImageAsset = (asset: ProjectAsset) => {
if (asset.type) return asset.type === 'background_image';
const normalizedName = String(asset.name || '').toLowerCase();
if (!normalizedName) return false;
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(
normalizedName,
);
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
return hasBackgroundKeyword && !hasExcludedKeyword;
};
const addFallbackAssetOption = (
options: AssetOption[],
value?: string,
fallbackLabel?: string,
): AssetOption[] => {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) return options;
if (options.some((option) => option.value === normalizedValue))
return options;
return [
...options,
{
value: normalizedValue,
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
},
];
};
const labelByType: Record<CanvasElementType, string> = {
navigation_next: 'Navigation: Forward',
navigation_prev: 'Navigation: Back',
gallery: 'Gallery',
carousel: 'Carousel',
tooltip: 'Tooltip',
description: 'Description',
video_player: 'Video Player',
audio_player: 'Audio Player',
};
const canvasElementTypes: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'gallery',
'carousel',
'tooltip',
'description',
'video_player',
'audio_player',
];
const isCanvasElementType = (value: string): value is CanvasElementType =>
canvasElementTypes.includes(value as CanvasElementType);
const isNavigationElementType = (
type: CanvasElementType,
): type is NavigationElementType =>
type === 'navigation_next' || type === 'navigation_prev';
const getNavigationButtonLabel = (type: NavigationElementType) =>
type === 'navigation_next' ? 'Forward' : 'Back';
const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind =>
type === 'navigation_prev' ? 'back' : 'forward';
const getNavigationTypeFromKind = (
kind: NavigationButtonKind,
): NavigationElementType =>
kind === 'back' ? 'navigation_prev' : 'navigation_next';
const createDefaultElement = (
type: CanvasElementType,
index: number,
): CanvasElement => {
const base: CanvasElement = {
id: createLocalId(),
type,
label: labelByType[type],
xPercent: clamp(12 + index * 4, 5, 80),
yPercent: clamp(16 + index * 6, 8, 85),
appearDelaySec: 0,
appearDurationSec: null,
};
if (type === 'gallery') {
return {
...base,
galleryCards: [
{ id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' },
],
};
}
if (type === 'carousel') {
return {
...base,
carouselSlides: [
{ id: createLocalId(), imageUrl: '', caption: 'Slide 1' },
],
carouselPrevIconUrl: '',
carouselNextIconUrl: '',
};
}
if (type === 'tooltip') {
return {
...base,
iconUrl: '',
tooltipTitle: 'Tooltip title',
tooltipText: 'Tooltip text',
};
}
if (type === 'description') {
return {
...base,
iconUrl: '',
descriptionTitle: 'TITLE',
descriptionText: '',
descriptionTitleFontSize: '48px',
descriptionTextFontSize: '36px',
descriptionBackgroundColor: 'transparent',
};
}
if (type === 'navigation_next' || type === 'navigation_prev') {
return {
...base,
navLabel: getNavigationButtonLabel(type),
navType: getNavigationButtonKind(type),
iconUrl: '',
transitionReverseMode: 'auto_reverse',
};
}
if (type === 'video_player' || type === 'audio_player') {
return {
...base,
mediaUrl: '',
mediaAutoplay: true,
mediaLoop: true,
mediaMuted: type === 'video_player',
};
}
return base;
};
const mergeElementWithDefaults = (
element: CanvasElement,
defaults?: Partial<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,
};
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec);
if (merged.type === 'gallery') {
const cards = preferElementValues
? Array.isArray(element.galleryCards)
? element.galleryCards
: defaults.galleryCards || []
: Array.isArray(defaults.galleryCards)
? defaults.galleryCards
: element.galleryCards || [];
merged.galleryCards = cards.map((card, cardIndex) => ({
id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl || ''),
title: String(card?.title || `Card ${cardIndex + 1}`),
description: String(card?.description || ''),
}));
}
if (merged.type === 'carousel') {
const slides = preferElementValues
? Array.isArray(element.carouselSlides)
? element.carouselSlides
: defaults.carouselSlides || []
: Array.isArray(defaults.carouselSlides)
? defaults.carouselSlides
: element.carouselSlides || [];
merged.carouselSlides = slides.map((slide, slideIndex) => ({
id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl || ''),
caption: String(slide?.caption || `Slide ${slideIndex + 1}`),
}));
}
return merged;
};
const getElementButtonTitle = (element: CanvasElement) => {
if (element.type === 'gallery') {
return `${element.label} (${element.galleryCards?.length || 0})`;
}
if (element.type === 'carousel') {
return `${element.label} (${element.carouselSlides?.length || 0})`;
}
if (element.type === 'tooltip') return element.tooltipTitle ?? '';
if (element.type === 'description') return element.descriptionTitle ?? '';
if (element.type === 'navigation_next' || element.type === 'navigation_prev')
return (
element.navLabel?.trim() ||
getNavigationButtonLabel(element.type as NavigationElementType)
);
if (
(element.type === 'video_player' || element.type === 'audio_player') &&
element.mediaUrl
) {
return `${element.label} · configured`;
}
return element.label;
};
const buildCanvasElementStyle = (
element: CanvasElement,
): React.CSSProperties => {
const style: React.CSSProperties = {};
const width = getTrimmedCssValue(element.width);
if (width) style.width = width;
const height = getTrimmedCssValue(element.height);
if (height) style.height = height;
const minWidth = getTrimmedCssValue(element.minWidth);
if (minWidth) style.minWidth = minWidth;
const maxWidth = getTrimmedCssValue(element.maxWidth);
if (maxWidth) style.maxWidth = maxWidth;
const minHeight = getTrimmedCssValue(element.minHeight);
if (minHeight) style.minHeight = minHeight;
const maxHeight = getTrimmedCssValue(element.maxHeight);
if (maxHeight) style.maxHeight = maxHeight;
const margin = getTrimmedCssValue(element.margin);
if (margin) style.margin = margin;
const padding = getTrimmedCssValue(element.padding);
if (padding) style.padding = padding;
const gap = getTrimmedCssValue(element.gap);
if (gap) style.gap = gap;
const fontSize = getTrimmedCssValue(element.fontSize);
if (fontSize) style.fontSize = fontSize;
const lineHeight = getTrimmedCssValue(element.lineHeight);
if (lineHeight) style.lineHeight = lineHeight;
const fontWeight = getTrimmedCssValue(element.fontWeight);
if (fontWeight) style.fontWeight = fontWeight as React.CSSProperties['fontWeight'];
const border = getTrimmedCssValue(element.border);
if (border) style.border = border;
const borderRadius = getTrimmedCssValue(element.borderRadius);
if (borderRadius) style.borderRadius = borderRadius;
const opacity = getTrimmedCssValue(element.opacity);
if (opacity) {
const parsed = Number(opacity);
if (Number.isFinite(parsed)) style.opacity = parsed;
}
const boxShadow = getTrimmedCssValue(element.boxShadow);
if (boxShadow) style.boxShadow = boxShadow;
const display = getTrimmedCssValue(element.display);
if (display) style.display = display;
const position = getTrimmedCssValue(element.position);
if (position) style.position = position as React.CSSProperties['position'];
const justifyContent = getTrimmedCssValue(element.justifyContent);
if (justifyContent)
style.justifyContent = justifyContent as React.CSSProperties['justifyContent'];
const alignItems = getTrimmedCssValue(element.alignItems);
if (alignItems)
style.alignItems = alignItems as React.CSSProperties['alignItems'];
const textAlign = getTrimmedCssValue(element.textAlign);
if (textAlign) style.textAlign = textAlign as React.CSSProperties['textAlign'];
const zIndex = getTrimmedCssValue(element.zIndex);
if (zIndex) {
const parsed = Number(zIndex);
if (Number.isFinite(parsed)) style.zIndex = parsed;
}
return style;
};
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const router = useRouter();
const canvasRef = useRef<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 '/page_elements/page_elements-list';
return `/page_elements/page_elements-list?projectId=${encodeURIComponent(projectId)}`;
}, [projectId]);
const pageIdFromRoute = useMemo(() => {
const value = router.query.pageId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.pageId]);
const elementIdFromRoute = useMemo(() => {
const value = router.query.elementId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.elementId]);
const [pages, setPages] = useState<TourPage[]>([]);
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 [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 reverseAnimationFrame = useRef<number | 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],
);
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 selectedElement = useMemo(
() => elements.find((element) => element.id === selectedElementId) || null,
[elements, selectedElementId],
);
const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [
'navigation_next',
'navigation_prev',
'tooltip',
'description',
];
const urls = elements
.filter(
(element) =>
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
)
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
.filter(Boolean);
return Array.from(new Set(urls));
}, [elements]);
const normalizeNavigationElementType = useCallback(
(element: CanvasElement, nextType: NavigationElementType): CanvasElement => {
if (!isNavigationElementType(element.type)) return element;
const nextButtonLabel = getNavigationButtonLabel(nextType);
const hasDefaultLabel =
element.label === labelByType.navigation_next ||
element.label === labelByType.navigation_prev;
const hasDefaultNavLabel =
!element.navLabel ||
element.navLabel === getNavigationButtonLabel('navigation_next') ||
element.navLabel === getNavigationButtonLabel('navigation_prev');
return {
...element,
type: nextType,
navType: getNavigationButtonKind(nextType),
label: hasDefaultLabel ? labelByType[nextType] : element.label,
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
};
},
[],
);
const imageAssetOptions = useMemo(
() =>
assets
.filter(
(asset) => asset.asset_type === 'image' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
const backgroundImageAssetOptions = useMemo(
() =>
assets
.filter(
(asset) =>
asset.asset_type === 'image' &&
getAssetSourceValue(asset) &&
isBackgroundImageAsset(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
const videoAssetOptions = useMemo(
() =>
assets
.filter(
(asset) => asset.asset_type === 'video' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
const audioAssetOptions = useMemo(
() =>
assets
.filter(
(asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
const transitionVideoAssetOptions = useMemo(() => {
const typedAssets = assets
.filter(
(asset) =>
asset.type === 'transition' &&
asset.asset_type === 'video' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
if (typedAssets.length > 0) return typedAssets;
const taggedAssets = assets
.filter(
(asset) =>
asset.asset_type === 'video' &&
getAssetSourceValue(asset) &&
/\[TRANSITION\]/i.test(String(asset.name || '')),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
if (taggedAssets.length > 0) return taggedAssets;
return videoAssetOptions;
}, [assets, videoAssetOptions]);
const iconAssetOptions = useMemo(
() =>
assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
const getKnownDurationForSource = useCallback(
(source?: string) => {
const normalizedSource = String(source || '').trim();
if (!normalizedSource) return null;
const resolvedDuration = resolvedDurationBySource[normalizedSource];
if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) {
return Number(resolvedDuration);
}
return null;
},
[resolvedDurationBySource],
);
const durationProbeTargets = useMemo<
Array<{ source: string; mediaType: 'video' | 'audio' }>
>(() => {
const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = [];
if (backgroundVideoUrl) {
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
}
if (backgroundAudioUrl) {
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
}
if (
selectedElement &&
(selectedElement.type === 'video_player' ||
selectedElement.type === 'audio_player') &&
selectedElement.mediaUrl
) {
targets.push({
source: selectedElement.mediaUrl,
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
});
}
if (newTransitionVideoUrl) {
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
}
elements.forEach((element) => {
if (!isNavigationElementType(element.type)) return;
if (element.transitionVideoUrl) {
targets.push({ source: element.transitionVideoUrl, mediaType: 'video' });
}
if (element.reverseVideoUrl) {
targets.push({ source: element.reverseVideoUrl, mediaType: 'video' });
}
});
return targets;
}, [
backgroundAudioUrl,
backgroundVideoUrl,
elements,
newTransitionVideoUrl,
selectedElement,
]);
useEffect(() => {
let isCancelled = false;
durationProbeTargets.forEach(({ source, mediaType }) => {
const normalizedSource = String(source || '').trim();
if (!normalizedSource) return;
if (getKnownDurationForSource(normalizedSource)) return;
const probeKey = `${mediaType}:${normalizedSource}`;
if (durationProbeInFlightRef.current.has(probeKey)) return;
durationProbeInFlightRef.current.add(probeKey);
resolveDurationWithFallback(normalizedSource, mediaType)
.then((duration) => {
if (isCancelled) return;
setResolvedDurationBySource((prev) => ({
...prev,
[normalizedSource]:
Number.isFinite(duration) && Number(duration) > 0
? Number(duration)
: null,
}));
})
.catch((error) => {
console.error('Failed to resolve media duration:', error);
if (isCancelled) return;
setResolvedDurationBySource((prev) => ({
...prev,
[normalizedSource]: null,
}));
})
.finally(() => {
durationProbeInFlightRef.current.delete(probeKey);
});
});
return () => {
isCancelled = true;
};
}, [durationProbeTargets, getKnownDurationForSource]);
const backgroundVideoDurationNote = useMemo(
() => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)),
[backgroundVideoUrl, getKnownDurationForSource],
);
const backgroundAudioDurationNote = useMemo(
() => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)),
[backgroundAudioUrl, getKnownDurationForSource],
);
const selectedMediaDurationNote = useMemo(() => {
if (
!selectedElement ||
(selectedElement.type !== 'video_player' &&
selectedElement.type !== 'audio_player')
) {
return 'Duration: unknown';
}
return formatDurationNote(
getKnownDurationForSource(selectedElement.mediaUrl || ''),
);
}, [getKnownDurationForSource, selectedElement]);
const newTransitionDurationNote = useMemo(
() => formatDurationNote(getKnownDurationForSource(newTransitionVideoUrl)),
[getKnownDurationForSource, newTransitionVideoUrl],
);
const selectedTransitionDurationNote = useMemo(() => {
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
return 'Duration: unknown';
}
return formatDurationNote(
getKnownDurationForSource(selectedElement.transitionVideoUrl || ''),
);
}, [getKnownDurationForSource, selectedElement]);
useEffect(() => {
if (newTransitionVideoUrl) return;
if (!transitionVideoAssetOptions.length) return;
setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value);
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
useEffect(() => {
setElements((prev) => {
let hasChanges = false;
const next = prev.map((element) => {
if (!isNavigationElementType(element.type)) return element;
const resolvedDuration = getKnownDurationForSource(
element.transitionVideoUrl || '',
);
const nextDuration =
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
? Number(resolvedDuration)
: undefined;
if (element.transitionDurationSec === nextDuration) return element;
hasChanges = true;
return {
...element,
transitionDurationSec: nextDuration,
};
});
return hasChanges ? next : prev;
});
}, [getKnownDurationForSource]);
const loadData = useCallback(async () => {
if (!projectId || !router.isReady || !isAuthReady) return;
try {
setIsLoading(true);
setErrorMessage('');
setSuccessMessage('');
const [projectResponse, pagesResponse, assetsResponse, uiElementsResponse] =
await Promise.all([
axios.get(`/projects/${projectId}`),
axios.get(
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}`,
),
axios.get(
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
),
axios.get('/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true'),
]);
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
? pagesResponse.data.rows
: [];
const assetRows: ProjectAsset[] = Array.isArray(
assetsResponse?.data?.rows,
)
? assetsResponse.data.rows
: [];
setProjectName(projectResponse?.data?.name || '');
setPages(pageRows);
setAssets(assetRows);
const uiElementRows: UiElementDefault[] = Array.isArray(
uiElementsResponse?.data?.rows,
)
? uiElementsResponse.data.rows
: [];
const defaultsByType: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
> = {};
uiElementRows.forEach((row) => {
const elementType = String(row.element_type || '').trim();
if (!isCanvasElementType(elementType)) return;
const rawDefaults = parseJsonObject<Partial<CanvasElement>>(
row.default_settings_json,
{},
);
defaultsByType[elementType] = rawDefaults;
});
setUiElementDefaultsByType(defaultsByType);
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
setActivePageId(defaultPageId);
setIsMenuOpen(false);
} catch (error: any) {
if (error?.response?.status === 401) {
const message = 'Your session has expired. Please sign in again.';
console.error('Unauthorized constructor request:', error);
setErrorMessage(message);
setPages([]);
setAssets([]);
router.replace('/login');
return;
}
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load constructor data.';
console.error('Failed to load constructor data:', error);
setErrorMessage(message);
setPages([]);
setAssets([]);
setUiElementDefaultsByType({});
} finally {
setIsLoading(false);
}
}, [isAuthReady, pageIdFromRoute, projectId, router]);
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
const token =
sessionStorage.getItem('token') || localStorage.getItem('token');
if (!token) {
setIsAuthReady(false);
setErrorMessage('Please sign in to continue.');
router.replace('/login');
return;
}
setIsAuthReady(true);
}, [router]);
useEffect(() => {
if (!router.isReady) return;
if (projectId) return;
router.replace(
isElementEditMode
? '/page_elements/page_elements-list'
: '/projects/projects-list',
);
}, [isElementEditMode, projectId, router]);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
if (typeof window === 'undefined') return;
setConstructorControlsPosition((prev) => {
if (prev.x > 0) return prev;
return {
x: 20,
y: 20,
};
});
setMenuPosition((prev) => {
if (prev.x > 0) return prev;
return {
x: Math.max(window.innerWidth - 280, 20),
y: prev.y,
};
});
setEditorPosition((prev) => {
if (prev.x > 0) return prev;
return {
x: Math.max(window.innerWidth - 400, 20),
y: prev.y,
};
});
}, []);
useEffect(() => {
if (!router.isReady || !isAuthReady || isLoading) return;
if (didSetInitialCanvasFocus.current) return;
if (!canvasRef.current) return;
didSetInitialCanvasFocus.current = true;
requestAnimationFrame(() => {
canvasRef.current?.focus({ preventScroll: true });
});
}, [isAuthReady, isLoading, router.isReady]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (isLoading || !activePageId) {
setCanvasElapsedSec(0);
return;
}
pagePlaybackStartedAtRef.current = Date.now();
setCanvasElapsedSec(0);
const intervalId = window.setInterval(() => {
const elapsed =
(Date.now() - pagePlaybackStartedAtRef.current) / 1000;
setCanvasElapsedSec(elapsed > 0 ? elapsed : 0);
}, 100);
return () => window.clearInterval(intervalId);
}, [activePageId, isLoading]);
useEffect(() => {
if (typeof window === 'undefined') return;
const targetSet = new Set(iconPreloadTargets);
const nextPreloaded = new Set<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 = () => {
console.error('Failed to preload icon asset:', url);
markReady();
};
image.src = url;
preloadImages.push(image);
});
return () => {
isCancelled = true;
preloadImages.forEach((image) => {
image.onload = null;
image.onerror = null;
});
};
}, [iconPreloadTargets]);
useEffect(() => {
if (!activePage) {
setElements([]);
setSelectedElementId('');
setBackgroundImageUrl('');
setBackgroundVideoUrl('');
setBackgroundAudioUrl('');
return;
}
const schema = parseJsonObject<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,
targetPageId:
typeof item.targetPageId === 'string' ? item.targetPageId : '',
transitionVideoUrl:
typeof item.transitionVideoUrl === 'string'
? item.transitionVideoUrl
: '',
transitionReverseMode:
item.transitionReverseMode === 'separate_video'
? 'separate_video'
: ('auto_reverse' as const),
reverseVideoUrl:
typeof item.reverseVideoUrl === 'string'
? item.reverseVideoUrl
: '',
transitionDurationSec: item.transitionDurationSec
? Number(item.transitionDurationSec)
: undefined,
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
mediaAutoplay:
typeof item.mediaAutoplay === 'boolean'
? item.mediaAutoplay
: true,
mediaLoop:
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
mediaMuted:
typeof item.mediaMuted === 'boolean'
? item.mediaMuted
: item.type === 'video_player',
};
return mergeElementWithDefaults(
normalizedElement,
uiElementDefaultsByType[elementType],
{ preferElementValues: true },
);
})
: [];
setElements(normalizedElements);
setSelectedMenuItem('none');
setSelectedElementId((current) => {
if (!normalizedElements.length) return '';
if (
elementIdFromRoute &&
normalizedElements.some((element) => element.id === elementIdFromRoute)
) {
return elementIdFromRoute;
}
if (normalizedElements.some((element) => element.id === current))
return current;
return '';
});
setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_url || '');
setBackgroundAudioUrl(activePage.background_audio_url || '');
}, [activePage, elementIdFromRoute, uiElementDefaultsByType]);
useEffect(() => {
if (allowedNavigationTypes.length !== 1) return;
const forcedType = allowedNavigationTypes[0];
setElements((prev) => {
let hasChanges = false;
const nextElements = prev.map((element) => {
if (!isNavigationElementType(element.type) || element.type === forcedType)
return element;
hasChanges = true;
return normalizeNavigationElementType(element, forcedType);
});
return hasChanges ? nextElements : prev;
});
}, [allowedNavigationTypes, normalizeNavigationElementType]);
useEffect(() => {
const onPointerMove = (event: MouseEvent) => {
if (constructorControlsDragRef.current) {
const maxX = Math.max(window.innerWidth - 460, 0);
const maxY = Math.max(window.innerHeight - 64, 0);
const nextX = clamp(
event.clientX - constructorControlsDragRef.current.pointerOffsetX,
0,
maxX,
);
const nextY = clamp(
event.clientY - constructorControlsDragRef.current.pointerOffsetY,
0,
maxY,
);
setConstructorControlsPosition({ x: nextX, y: nextY });
return;
}
if (menuDragRef.current) {
const nextX = clamp(
event.clientX - menuDragRef.current.pointerOffsetX,
0,
window.innerWidth - 240,
);
const nextY = clamp(
event.clientY - menuDragRef.current.pointerOffsetY,
0,
window.innerHeight - 60,
);
setMenuPosition({ x: nextX, y: nextY });
return;
}
if (editorDragRef.current) {
const editorWidth = isEditorCollapsed ? 260 : 380;
const nextX = clamp(
event.clientX - editorDragRef.current.pointerOffsetX,
0,
window.innerWidth - editorWidth,
);
const nextY = clamp(
event.clientY - editorDragRef.current.pointerOffsetY,
0,
window.innerHeight - 60,
);
setEditorPosition({ x: nextX, y: nextY });
return;
}
if (!elementDragRef.current || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const rawX =
event.clientX - rect.left - elementDragRef.current.pointerOffsetX;
const rawY =
event.clientY - rect.top - elementDragRef.current.pointerOffsetY;
const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100);
const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100);
setElements((prev) =>
prev.map((item) =>
item.id === elementDragRef.current?.id
? { ...item, xPercent: nextXPercent, yPercent: nextYPercent }
: item,
),
);
};
const onPointerUp = () => {
constructorControlsDragRef.current = null;
menuDragRef.current = null;
editorDragRef.current = null;
elementDragRef.current = null;
};
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
return () => {
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
};
}, [isEditorCollapsed]);
useEffect(() => {
if (isConstructorEditMode) return;
elementDragRef.current = null;
setSelectedElementId('');
setSelectedMenuItem('none');
}, [isConstructorEditMode]);
useEffect(() => {
if (!isConstructorEditMode) return;
if (!selectedElementId && selectedMenuItem === 'none') return;
const onOutsideMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) return;
if (elementEditorRef.current?.contains(target)) return;
const clickedElementId = target
.closest('[data-constructor-element-id]')
?.getAttribute('data-constructor-element-id');
if (
selectedElementId &&
clickedElementId &&
clickedElementId === selectedElementId
)
return;
setSelectedElementId('');
setSelectedMenuItem('none');
};
window.addEventListener('mousedown', onOutsideMouseDown);
return () => window.removeEventListener('mousedown', onOutsideMouseDown);
}, [isConstructorEditMode, selectedElementId, selectedMenuItem]);
const selectElementForEdit = (elementId: string) => {
setSelectedElementId(elementId);
setSelectedMenuItem('none');
};
const selectMenuItemForEdit = (item: EditorMenuItem) => {
setSelectedElementId('');
setSelectedMenuItem(item);
};
const addElement = (type: CanvasElementType) => {
const nextElementType: CanvasElementType = isNavigationElementType(type)
? allowedNavigationTypes.includes(type)
? type
: allowedNavigationTypes[0]
: type;
const baseElement = createDefaultElement(nextElementType, elements.length);
const nextElement = mergeElementWithDefaults(
baseElement,
uiElementDefaultsByType[nextElementType],
);
setElements((prev) => [...prev, nextElement]);
selectElementForEdit(nextElement.id);
setSuccessMessage('Element added. Drag it to set position.');
setErrorMessage('');
};
const createPage = useCallback(async () => {
if (!projectId) {
setErrorMessage('Project is required.');
return;
}
const maxSortOrder = Math.max(
0,
...pages.map((item) => Number(item.sort_order || 0)),
);
const nextPageNumber = pages.length + 1;
const payload = {
project: projectId,
environment: activePage?.environment || 'dev',
source_key: '',
name: `Page ${nextPageNumber}`,
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
sort_order: maxSortOrder + 1,
background_image_url: '',
background_video_url: '',
background_audio_url: '',
background_loop: false,
requires_auth: false,
ui_schema_json: JSON.stringify({ elements: [] }),
};
try {
setIsCreatingPage(true);
setErrorMessage('');
setSuccessMessage('');
const response = await axios.post('/tour_pages', { data: payload });
const createdPage = response?.data;
await loadData();
if (createdPage?.id) {
setActivePageId(createdPage.id);
}
setIsMenuOpen(true);
setSuccessMessage(
'New page created. You can now configure it in constructor.',
);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to create page.';
console.error('Failed to create page from constructor:', error);
setErrorMessage(message);
} finally {
setIsCreatingPage(false);
}
}, [activePage?.environment, loadData, pages, projectId]);
const createTransition = useCallback(async () => {
if (!projectId) {
setErrorMessage('Project is required.');
return;
}
const sanitizedVideoUrl = String(newTransitionVideoUrl || '').trim();
if (!sanitizedVideoUrl) {
setErrorMessage('Select a transition video asset first.');
return;
}
const sanitizedName =
String(newTransitionName || '').trim() ||
`Transition ${Date.now().toString().slice(-4)}`;
const resolvedDurationSec = getKnownDurationForSource(sanitizedVideoUrl);
if (!resolvedDurationSec) {
setErrorMessage(
'Could not resolve transition video duration yet. Please wait a moment and try again.',
);
return;
}
try {
setIsCreatingTransition(true);
setErrorMessage('');
setSuccessMessage('');
const payload = {
project: projectId,
environment: activePage?.environment || 'dev',
source_key: '',
name: sanitizedName,
slug: `transition-${createLocalId()}`,
video_url: sanitizedVideoUrl,
audio_url: '',
supports_reverse: Boolean(newTransitionSupportsReverse),
duration_sec: resolvedDurationSec,
};
await axios.post('/transitions', { data: payload });
setSuccessMessage('Transition created.');
setNewTransitionName('');
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to create transition.';
console.error('Failed to create transition from constructor:', error);
setErrorMessage(message);
} finally {
setIsCreatingTransition(false);
}
}, [
activePage?.environment,
getKnownDurationForSource,
newTransitionName,
newTransitionSupportsReverse,
newTransitionVideoUrl,
projectId,
]);
const saveConstructor = useCallback(async () => {
if (!activePageId) {
setErrorMessage('Select a page before saving.');
return;
}
try {
setIsSaving(true);
setErrorMessage('');
setSuccessMessage('');
const existingSchema = parseJsonObject<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();
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save constructor changes.';
console.error('Failed to save constructor changes:', error);
setErrorMessage(message);
} finally {
setIsSaving(false);
}
}, [
activePage?.environment,
activePage?.name,
activePage?.requires_auth,
activePage?.slug,
activePage?.sort_order,
activePage?.source_key,
activePage?.ui_schema_json,
activePageId,
backgroundAudioUrl,
backgroundImageUrl,
backgroundVideoUrl,
elements,
loadData,
]);
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
if (!isConstructorEditMode) return;
event.preventDefault();
if (!canvasRef.current) return;
const currentElement = elements.find((item) => item.id === elementId);
if (!currentElement) return;
selectElementForEdit(elementId);
const rect = canvasRef.current.getBoundingClientRect();
const elementLeftPx = (currentElement.xPercent / 100) * rect.width;
const elementTopPx = (currentElement.yPercent / 100) * rect.height;
elementDragRef.current = {
id: elementId,
pointerOffsetX: event.clientX - rect.left - elementLeftPx,
pointerOffsetY: event.clientY - rect.top - elementTopPx,
};
};
const preventImageDragStart = (event: React.DragEvent<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)) {
const direction =
element.navType === 'back' || element.type === 'navigation_prev'
? 'back'
: 'forward';
const configuredTargetId = String(element.targetPageId || '').trim();
const fallbackTargetId = (() => {
const currentPageIndex = pages.findIndex(
(page) => page.id === activePageId,
);
if (currentPageIndex < 0) return '';
const nextPageIndex =
direction === 'back' ? currentPageIndex - 1 : currentPageIndex + 1;
const nextPage = pages[nextPageIndex];
return nextPage ? String(nextPage.id || '').trim() : '';
})();
const targetPageId = configuredTargetId || fallbackTargetId;
if (!targetPageId) {
setErrorMessage('No target page available for this navigation button.');
return;
}
const hasPlayableTransition =
Boolean(element.transitionVideoUrl) &&
!(
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
);
if (!hasPlayableTransition) {
setPendingNavigationPageId('');
setTransitionPreview(null);
setActivePageId(targetPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
return;
}
setPendingNavigationPageId(targetPageId);
openTransitionPreviewForElement(element, direction);
}
return;
}
selectElementForEdit(element.id);
};
const onMenuDragStart = (event: React.MouseEvent) => {
const targetRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
menuDragRef.current = {
pointerOffsetX: event.clientX - targetRect.left,
pointerOffsetY: event.clientY - targetRect.top,
};
};
const onConstructorControlsDragStart = (event: React.MouseEvent) => {
const targetRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
constructorControlsDragRef.current = {
pointerOffsetX: event.clientX - targetRect.left,
pointerOffsetY: event.clientY - targetRect.top,
};
};
const onElementEditorDragStart = (event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('button')) return;
const targetRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
editorDragRef.current = {
pointerOffsetX: event.clientX - targetRect.left,
pointerOffsetY: event.clientY - targetRect.top,
};
};
const renderCanvasElementContent = (element: CanvasElement) => {
if (
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
const fallbackNavLabel = getNavigationButtonLabel(element.type);
const navigationLabel = element.navLabel?.trim() || fallbackNavLabel;
if (element.iconUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation icon'
className='block h-full w-full object-contain'
draggable={false}
onDragStart={preventImageDragStart}
/>
);
}
const targetPageName = 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 (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
draggable={false}
onDragStart={preventImageDragStart}
/>
);
}
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 (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
draggable={false}
onDragStart={preventImageDragStart}
/>
);
}
const bgColor = element.descriptionBackgroundColor || 'transparent';
return (
<div
className='max-w-[220px] text-left p-2 rounded'
style={{ backgroundColor: bgColor }}
>
<p
className='font-bold'
style={{
fontSize: element.descriptionTitleFontSize
? `calc(${element.descriptionTitleFontSize} * 0.25)`
: '12px',
}}
>
{element.descriptionTitle || 'TITLE'}
</p>
{element.descriptionText && (
<p
className='text-gray-600 line-clamp-4'
style={{
fontSize: element.descriptionTextFontSize
? `calc(${element.descriptionTextFontSize} * 0.25)`
: '9px',
}}
>
{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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(card.imageUrl)}
alt={card.title || 'Gallery card'}
className='h-full w-full object-cover'
/>
) : (
<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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
className='h-full w-full object-cover'
/>
) : (
<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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
alt='Previous icon'
className='h-3 w-3 object-contain'
/>
) : null}
Prev
</span>
<span className='flex items-center gap-1'>
Next
{element.carouselNextIconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
alt='Next icon'
className='h-3 w-3 object-contain'
/>
) : 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 = {};
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl);
const backgroundImageSelectOptions = addFallbackAssetOption(
backgroundImageAssetOptions,
backgroundImageUrl,
`Current image · ${backgroundImageUrl}`,
);
const backgroundVideoSelectOptions = addFallbackAssetOption(
videoAssetOptions,
backgroundVideoUrl,
`Current video · ${backgroundVideoUrl}`,
);
const backgroundAudioSelectOptions = addFallbackAssetOption(
audioAssetOptions,
backgroundAudioUrl,
`Current audio · ${backgroundAudioUrl}`,
);
const hasEditorSelection =
isConstructorEditMode &&
(Boolean(selectedElement) || selectedMenuItem !== 'none');
const editorTitle =
selectedMenuItem === 'background_image'
? 'Background image'
: selectedMenuItem === 'background_video'
? 'Background video'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedMenuItem === 'create_transition'
? 'Create transition'
: selectedElement?.label || 'Element editor';
if (backgroundImageSrc) {
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
canvasBackgroundStyle.backgroundSize = 'cover';
canvasBackgroundStyle.backgroundPosition = 'center';
}
useEffect(() => {
const video = transitionVideoRef.current;
if (!transitionPreview || !video) return;
let startWatchdogTimer: ReturnType<typeof setTimeout> | null = null;
let finishTimer: ReturnType<typeof setTimeout> | null = null;
let hardTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let previewBlobUrl: string | null = null;
let didFinish = false;
let didStartPlayback = false;
const sourceCandidateRaw =
transitionPreview.reverseMode === 'separate'
? transitionPreview.reverseVideoUrl || ''
: transitionPreview.videoUrl;
const sourceUrl = resolveAssetPlaybackUrl(sourceCandidateRaw);
const cleanupReverseFrame = () => {
if (reverseAnimationFrame.current !== null) {
cancelAnimationFrame(reverseAnimationFrame.current);
reverseAnimationFrame.current = null;
}
};
const clearTimers = () => {
if (startWatchdogTimer) clearTimeout(startWatchdogTimer);
if (finishTimer) clearTimeout(finishTimer);
if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer);
startWatchdogTimer = null;
finishTimer = null;
hardTimeoutTimer = null;
};
const cleanupPreviewBlobUrl = () => {
if (!previewBlobUrl) return;
URL.revokeObjectURL(previewBlobUrl);
previewBlobUrl = null;
};
const shouldLoadTransitionViaBlob = (candidateUrl: string) => {
try {
const parsedUrl = new URL(candidateUrl, window.location.origin);
const isSameOrigin = parsedUrl.origin === window.location.origin;
if (!isSameOrigin) return false;
return (
parsedUrl.pathname === '/api/file/download' ||
parsedUrl.pathname === '/file/download'
);
} catch (error) {
console.error('Transition preview URL parsing failed:', {
candidateUrl,
error,
});
return false;
}
};
const buildBlobRequestUrl = (candidateUrl: string) => {
if (candidateUrl.startsWith('/api/')) {
return candidateUrl.replace(/^\/api(?=\/)/, '');
}
return candidateUrl;
};
const resolvePlayableTransitionSource = async () => {
cleanupPreviewBlobUrl();
if (!shouldLoadTransitionViaBlob(sourceUrl)) {
return sourceUrl;
}
const token =
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
const requestUrl = buildBlobRequestUrl(sourceUrl);
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
previewBlobUrl = URL.createObjectURL(response.data);
return previewBlobUrl;
};
const finishPreview = (reason: string) => {
if (didFinish) return;
didFinish = true;
clearTimers();
cleanupReverseFrame();
video.pause();
video.removeAttribute('src');
video.load();
cleanupPreviewBlobUrl();
setTransitionPreview(null);
setPendingNavigationPageId((pendingPageId) => {
const nextPageId = String(pendingPageId || '').trim();
if (nextPageId) {
setActivePageId(nextPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
}
return '';
});
console.info('Transition preview finished:', {
reason,
src: video.currentSrc || sourceUrl || '',
});
};
const configuredDurationSec = Number(transitionPreview.durationSec);
const getMediaErrorDetails = () => {
if (!video.error) return null;
const mediaError = video.error as MediaError & { message?: string };
return {
code: mediaError.code,
message: mediaError.message || '',
};
};
const logTransitionIssue = (reason: string, error?: unknown) => {
console.error('Transition preview issue:', {
reason,
src: video.currentSrc || sourceUrl || '',
readyState: video.readyState,
networkState: video.networkState,
duration: video.duration,
configuredDurationSec: transitionPreview.durationSec,
reverseMode: transitionPreview.reverseMode,
mediaError: getMediaErrorDetails(),
error,
});
};
const scheduleFinishByDuration = (durationSec: number) => {
if (!Number.isFinite(durationSec) || durationSec <= 0 || finishTimer) {
return;
}
finishTimer = setTimeout(() => {
finishPreview('duration-timer');
}, durationSec * 1000 + 200);
};
const runReversePreview = () => {
cleanupReverseFrame();
const duration =
Number.isFinite(video.duration) && video.duration > 0
? video.duration
: Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: 0.7;
const reverseSeconds =
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: duration;
const reverseMs = Math.max(reverseSeconds * 1000, 400);
const reverseRate = reverseSeconds > 0 ? duration / reverseSeconds : 1;
const startTime = performance.now();
video.pause();
video.currentTime = duration;
const step = (now: number) => {
const elapsed = now - startTime;
const nextTime = Math.max(duration - (elapsed / 1000) * reverseRate, 0);
video.currentTime = nextTime;
if (elapsed >= reverseMs || nextTime <= 0.001) {
finishPreview('reverse-complete');
return;
}
reverseAnimationFrame.current = requestAnimationFrame(step);
};
reverseAnimationFrame.current = requestAnimationFrame(step);
};
const attemptPlay = () => {
if (transitionPreview.reverseMode === 'reverse') return;
video
.play()
.catch((playError) => {
logTransitionIssue('play-failed', playError);
});
};
const loadSourceCandidate = async () => {
didStartPlayback = false;
if (startWatchdogTimer) {
clearTimeout(startWatchdogTimer);
}
try {
const playableSourceUrl = await resolvePlayableTransitionSource();
if (didFinish) return;
video.pause();
cleanupReverseFrame();
video.src = playableSourceUrl;
video.currentTime = 0;
video.load();
if (transitionPreview.reverseMode !== 'reverse') {
attemptPlay();
}
startWatchdogTimer = setTimeout(() => {
if (didStartPlayback || didFinish) return;
logTransitionIssue('playback-start-slow');
attemptPlay();
}, 12000);
} catch (error) {
logTransitionIssue('source-prepare-failed', error);
finishPreview('source-prepare-failed');
}
};
if (!sourceUrl) {
logTransitionIssue('missing-source');
finishPreview('missing-source');
return () => {
cleanupReverseFrame();
};
}
const onLoadedMetadata = () => {
if (didFinish) return;
if (transitionPreview.reverseMode === 'reverse' && !didStartPlayback) {
didStartPlayback = true;
if (startWatchdogTimer) {
clearTimeout(startWatchdogTimer);
startWatchdogTimer = null;
}
runReversePreview();
return;
}
video.currentTime = 0;
attemptPlay();
};
const onCanPlay = () => {
if (didFinish) return;
attemptPlay();
};
const onPlaying = () => {
if (didFinish) return;
didStartPlayback = true;
if (startWatchdogTimer) {
clearTimeout(startWatchdogTimer);
startWatchdogTimer = null;
}
if (transitionPreview.reverseMode !== 'reverse') {
const mediaDurationSec = Number(video.duration);
const durationSec =
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
? mediaDurationSec
: NaN;
if (Number.isFinite(durationSec) && durationSec > 0) {
scheduleFinishByDuration(durationSec);
}
}
};
const onEnded = () => finishPreview('ended');
const onPlaybackError = (eventName: string, error?: unknown) => {
if (didFinish) return;
logTransitionIssue(eventName, error);
finishPreview(eventName);
};
const onError = () => onPlaybackError('video-error');
const onAbort = () => onPlaybackError('video-abort');
const onStalled = () => {
if (didFinish) return;
logTransitionIssue('video-stalled');
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded);
video.addEventListener('error', onError);
video.addEventListener('abort', onAbort);
video.addEventListener('stalled', onStalled);
hardTimeoutTimer = setTimeout(() => {
if (didFinish) return;
logTransitionIssue('hard-timeout');
finishPreview('hard-timeout');
}, 45000);
void loadSourceCandidate();
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
clearTimers();
cleanupReverseFrame();
cleanupPreviewBlobUrl();
if (!didFinish) {
video.pause();
video.removeAttribute('src');
video.load();
}
};
}, [transitionPreview]);
useEffect(() => {
return () => {
if (reverseAnimationFrame.current !== null) {
cancelAnimationFrame(reverseAnimationFrame.current);
}
};
}, []);
return (
<>
<Head>
<title>
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
</title>
</Head>
<div className='relative w-screen h-screen bg-white 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) => setActivePageId(event.target.value)}
>
{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-white overflow-hidden'
style={canvasBackgroundStyle}
>
{backgroundImageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageSrc}
alt='Background'
className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none'
/>
) : null}
{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');
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');
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' : '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='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.targetPageId || ''}
onChange={(event) =>
updateSelectedElement({
targetPageId: event.target.value,
})
}
>
<option value=''>Not selected</option>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.id}>
{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'>
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-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'
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;