3916 lines
140 KiB
TypeScript
3916 lines
140 KiB
TypeScript
import {
|
|
mdiCloudUpload,
|
|
mdiContentSave,
|
|
mdiExitToApp,
|
|
mdiImageMultiple,
|
|
mdiMenu,
|
|
mdiPlus,
|
|
mdiSwapHorizontal,
|
|
mdiText,
|
|
mdiTooltipText,
|
|
mdiViewCarousel,
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import NextImage from 'next/image';
|
|
import { useRouter } from 'next/router';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import BaseButton from '../components/BaseButton';
|
|
import BaseIcon from '../components/BaseIcon';
|
|
import { getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
|
import { logger } from '../lib/logger';
|
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|
import { parseJsonObject } from '../lib/parseJson';
|
|
import { buildElementStyle, ELEMENT_STYLE_PROPS } from '../lib/elementStyles';
|
|
import type { PreloadPageLink, PreloadElement } from '../types/preload';
|
|
import type {
|
|
CanvasElementType,
|
|
CanvasElement,
|
|
ConstructorSchema,
|
|
ConstructorAsset as ProjectAsset,
|
|
AssetOption,
|
|
GalleryCard,
|
|
CarouselSlide,
|
|
NavigationButtonKind,
|
|
NormalizedElementDefault,
|
|
} from '../types/constructor';
|
|
import {
|
|
normalizeElementDefault,
|
|
buildElementDefaultsMap,
|
|
isCanvasElementType,
|
|
} from '../types/constructor';
|
|
|
|
type TourPage = {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
sort_order?: number;
|
|
environment?: string;
|
|
source_key?: string;
|
|
requires_auth?: boolean;
|
|
ui_schema_json?: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_audio_url?: string;
|
|
background_loop?: boolean;
|
|
};
|
|
|
|
type NavigationElementType = Extract<
|
|
CanvasElementType,
|
|
'navigation_next' | 'navigation_prev'
|
|
>;
|
|
|
|
type DragElementState = {
|
|
id: string;
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
};
|
|
|
|
type TransitionPreviewState = {
|
|
videoUrl: string;
|
|
reverseMode: 'none' | 'reverse' | 'separate';
|
|
reverseVideoUrl?: string;
|
|
durationSec?: number;
|
|
title: string;
|
|
};
|
|
|
|
type EditorMenuItem =
|
|
| 'none'
|
|
| 'background_image'
|
|
| 'background_video'
|
|
| 'background_audio'
|
|
| 'create_transition';
|
|
|
|
type ConstructorPageProps = {
|
|
mode?: 'constructor' | 'element_edit';
|
|
};
|
|
|
|
type ConstructorInteractionMode = 'edit' | 'interact';
|
|
|
|
const clamp = (value: number, min: number, max: number) =>
|
|
Math.min(Math.max(value, min), max);
|
|
|
|
const normalizeAppearDelaySec = (value: unknown) => {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
return Number(parsed);
|
|
};
|
|
|
|
const normalizeAppearDurationSec = (value: unknown) => {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
return Number(parsed);
|
|
};
|
|
|
|
const getTrimmedCssValue = (value: unknown) => {
|
|
if (value === null || value === undefined) return '';
|
|
return String(value).trim();
|
|
};
|
|
|
|
const createLocalId = () => {
|
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
|
return window.crypto.randomUUID();
|
|
}
|
|
|
|
return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
};
|
|
|
|
const getAssetLabel = (asset: ProjectAsset) => {
|
|
const baseName = asset.name?.trim() || 'Untitled asset';
|
|
const source = String(asset.storage_key || asset.cdn_url || '').trim();
|
|
return `${baseName}${source ? ` · ${source}` : ''}`;
|
|
};
|
|
|
|
const getAssetSourceValue = (asset: ProjectAsset) =>
|
|
String(asset.storage_key || asset.cdn_url || '').trim();
|
|
|
|
const formatDurationNote = (durationSec?: number | string | null) => {
|
|
const parsed = Number(durationSec);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
|
|
|
const totalSeconds = Math.round(parsed);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (minutes <= 0) return `Duration: ${seconds}s`;
|
|
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
};
|
|
|
|
const readMediaDuration = (
|
|
playbackUrl: string,
|
|
mediaType: 'video' | 'audio',
|
|
): Promise<number | null> =>
|
|
new Promise((resolve) => {
|
|
const mediaElement =
|
|
mediaType === 'video'
|
|
? document.createElement('video')
|
|
: document.createElement('audio');
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = () => {
|
|
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
mediaElement.removeEventListener('error', onError);
|
|
mediaElement.removeEventListener('abort', onError);
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
mediaElement.pause();
|
|
mediaElement.removeAttribute('src');
|
|
mediaElement.load();
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
const duration = Number(mediaElement.duration);
|
|
cleanup();
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
resolve(duration);
|
|
return;
|
|
}
|
|
resolve(null);
|
|
};
|
|
|
|
const onError = () => {
|
|
cleanup();
|
|
resolve(null);
|
|
};
|
|
|
|
timeoutId = setTimeout(() => {
|
|
cleanup();
|
|
resolve(null);
|
|
}, 12000);
|
|
|
|
mediaElement.preload = 'metadata';
|
|
mediaElement.crossOrigin = 'anonymous';
|
|
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
mediaElement.addEventListener('error', onError);
|
|
mediaElement.addEventListener('abort', onError);
|
|
mediaElement.src = playbackUrl;
|
|
mediaElement.load();
|
|
});
|
|
|
|
const resolveDurationWithFallback = async (
|
|
source: string,
|
|
mediaType: 'video' | 'audio',
|
|
) => {
|
|
const playbackUrl = resolveAssetPlaybackUrl(source);
|
|
if (!playbackUrl) return null;
|
|
|
|
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
|
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
|
return Number(directDuration);
|
|
}
|
|
|
|
try {
|
|
const requestUrl =
|
|
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
|
|
? playbackUrl
|
|
: playbackUrl.replace(/^\/api(?=\/)/, '');
|
|
|
|
const token =
|
|
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
|
|
const response = await axios.get(requestUrl, {
|
|
responseType: 'blob',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
});
|
|
|
|
const blobUrl = URL.createObjectURL(response.data);
|
|
try {
|
|
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
|
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
|
return Number(blobDuration);
|
|
}
|
|
return null;
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
'Failed to fetch media for duration probing:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
|
if (asset.type) return asset.type === 'background_image';
|
|
const normalizedName = String(asset.name || '').toLowerCase();
|
|
if (!normalizedName) return false;
|
|
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(
|
|
normalizedName,
|
|
);
|
|
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
|
|
return hasBackgroundKeyword && !hasExcludedKeyword;
|
|
};
|
|
|
|
const addFallbackAssetOption = (
|
|
options: AssetOption[],
|
|
value?: string,
|
|
fallbackLabel?: string,
|
|
): AssetOption[] => {
|
|
const normalizedValue = String(value || '').trim();
|
|
if (!normalizedValue) return options;
|
|
if (options.some((option) => option.value === normalizedValue))
|
|
return options;
|
|
return [
|
|
...options,
|
|
{
|
|
value: normalizedValue,
|
|
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
|
|
},
|
|
];
|
|
};
|
|
|
|
const labelByType: Record<CanvasElementType, string> = {
|
|
navigation_next: 'Navigation: Forward',
|
|
navigation_prev: 'Navigation: Back',
|
|
spot: 'Hotspot',
|
|
description: 'Description',
|
|
tooltip: 'Tooltip',
|
|
gallery: 'Gallery',
|
|
carousel: 'Carousel',
|
|
logo: 'Logo',
|
|
video_player: 'Video Player',
|
|
audio_player: 'Audio Player',
|
|
popup: 'Popup',
|
|
};
|
|
|
|
const isNavigationElementType = (
|
|
type: CanvasElementType,
|
|
): type is NavigationElementType =>
|
|
type === 'navigation_next' || type === 'navigation_prev';
|
|
|
|
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
|
type === 'navigation_next' ? 'Forward' : 'Back';
|
|
|
|
const getNavigationButtonKind = (
|
|
type: NavigationElementType,
|
|
): NavigationButtonKind => (type === 'navigation_prev' ? 'back' : 'forward');
|
|
|
|
const getNavigationTypeFromKind = (
|
|
kind: NavigationButtonKind,
|
|
): NavigationElementType =>
|
|
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
|
|
|
const createDefaultElement = (
|
|
type: CanvasElementType,
|
|
index: number,
|
|
): CanvasElement => {
|
|
const base: CanvasElement = {
|
|
id: createLocalId(),
|
|
type,
|
|
label: labelByType[type],
|
|
xPercent: clamp(12 + index * 4, 5, 80),
|
|
yPercent: clamp(16 + index * 6, 8, 85),
|
|
appearDelaySec: 0,
|
|
appearDurationSec: null,
|
|
};
|
|
|
|
if (type === 'gallery') {
|
|
return {
|
|
...base,
|
|
galleryCards: [
|
|
{ id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' },
|
|
],
|
|
};
|
|
}
|
|
|
|
if (type === 'carousel') {
|
|
return {
|
|
...base,
|
|
carouselSlides: [
|
|
{ id: createLocalId(), imageUrl: '', caption: 'Slide 1' },
|
|
],
|
|
carouselPrevIconUrl: '',
|
|
carouselNextIconUrl: '',
|
|
};
|
|
}
|
|
|
|
if (type === 'tooltip') {
|
|
return {
|
|
...base,
|
|
iconUrl: '',
|
|
tooltipTitle: 'Tooltip title',
|
|
tooltipText: 'Tooltip text',
|
|
};
|
|
}
|
|
|
|
if (type === 'description') {
|
|
return {
|
|
...base,
|
|
iconUrl: '',
|
|
descriptionTitle: 'TITLE',
|
|
descriptionText: '',
|
|
descriptionTitleFontSize: '48px',
|
|
descriptionTextFontSize: '36px',
|
|
descriptionTitleFontFamily: 'inherit',
|
|
descriptionTextFontFamily: 'inherit',
|
|
descriptionTitleColor: '#000000',
|
|
descriptionTextColor: '#4B5563',
|
|
descriptionBackgroundColor: 'transparent',
|
|
};
|
|
}
|
|
|
|
if (type === 'navigation_next' || type === 'navigation_prev') {
|
|
return {
|
|
...base,
|
|
navLabel: getNavigationButtonLabel(type),
|
|
navType: getNavigationButtonKind(type),
|
|
navDisabled: false,
|
|
iconUrl: '',
|
|
transitionReverseMode: 'auto_reverse',
|
|
};
|
|
}
|
|
|
|
if (type === 'video_player' || type === 'audio_player') {
|
|
return {
|
|
...base,
|
|
mediaUrl: '',
|
|
mediaAutoplay: true,
|
|
mediaLoop: true,
|
|
mediaMuted: type === 'video_player',
|
|
};
|
|
}
|
|
|
|
return base;
|
|
};
|
|
|
|
const mergeElementWithDefaults = (
|
|
element: CanvasElement,
|
|
defaults?: Partial<CanvasElement>,
|
|
options?: { preferElementValues?: boolean },
|
|
): CanvasElement => {
|
|
if (!defaults) return element;
|
|
|
|
const preferElementValues = Boolean(options?.preferElementValues);
|
|
const base = preferElementValues ? defaults : element;
|
|
const override = preferElementValues ? element : defaults;
|
|
const merged: CanvasElement = {
|
|
...base,
|
|
...override,
|
|
id: element.id,
|
|
type: element.type,
|
|
label: element.label || defaults.label || element.type,
|
|
xPercent: element.xPercent ?? defaults.xPercent ?? 50,
|
|
yPercent: element.yPercent ?? defaults.yPercent ?? 50,
|
|
};
|
|
|
|
// For style properties, use defaults if element has empty/null/undefined value
|
|
// This ensures DB defaults are applied when element has no explicit value
|
|
const elementRecord = element as unknown as Record<string, unknown>;
|
|
const defaultsRecord = defaults as unknown as Record<string, unknown>;
|
|
const mergedRecord = merged as unknown as Record<string, unknown>;
|
|
ELEMENT_STYLE_PROPS.forEach((prop) => {
|
|
const elementValue = elementRecord[prop];
|
|
const defaultValue = defaultsRecord[prop];
|
|
const elementIsEmpty =
|
|
elementValue === '' ||
|
|
elementValue === undefined ||
|
|
elementValue === null;
|
|
const defaultHasValue =
|
|
defaultValue !== undefined &&
|
|
defaultValue !== null &&
|
|
defaultValue !== '';
|
|
if (elementIsEmpty && defaultHasValue) {
|
|
mergedRecord[prop] = defaultValue;
|
|
}
|
|
});
|
|
|
|
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
|
|
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
|
|
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
|
|
merged.appearDurationSec = normalizeAppearDurationSec(
|
|
merged.appearDurationSec,
|
|
);
|
|
|
|
if (merged.type === 'gallery') {
|
|
const cards = preferElementValues
|
|
? Array.isArray(element.galleryCards)
|
|
? element.galleryCards
|
|
: defaults.galleryCards || []
|
|
: Array.isArray(defaults.galleryCards)
|
|
? defaults.galleryCards
|
|
: element.galleryCards || [];
|
|
merged.galleryCards = cards.map((card, cardIndex) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl || ''),
|
|
title: String(card?.title || `Card ${cardIndex + 1}`),
|
|
description: String(card?.description || ''),
|
|
}));
|
|
}
|
|
|
|
if (merged.type === 'carousel') {
|
|
const slides = preferElementValues
|
|
? Array.isArray(element.carouselSlides)
|
|
? element.carouselSlides
|
|
: defaults.carouselSlides || []
|
|
: Array.isArray(defaults.carouselSlides)
|
|
? defaults.carouselSlides
|
|
: element.carouselSlides || [];
|
|
merged.carouselSlides = slides.map((slide, slideIndex) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl || ''),
|
|
caption: String(slide?.caption || `Slide ${slideIndex + 1}`),
|
|
}));
|
|
}
|
|
|
|
return merged;
|
|
};
|
|
|
|
const getElementButtonTitle = (element: CanvasElement) => {
|
|
if (element.type === 'gallery') {
|
|
return `${element.label} (${element.galleryCards?.length || 0})`;
|
|
}
|
|
|
|
if (element.type === 'carousel') {
|
|
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
|
}
|
|
|
|
if (element.type === 'tooltip') return element.tooltipTitle ?? '';
|
|
if (element.type === 'description') return element.descriptionTitle ?? '';
|
|
if (element.type === 'navigation_next' || element.type === 'navigation_prev')
|
|
return (
|
|
element.navLabel?.trim() ||
|
|
getNavigationButtonLabel(element.type as NavigationElementType)
|
|
);
|
|
if (
|
|
(element.type === 'video_player' || element.type === 'audio_player') &&
|
|
element.mediaUrl
|
|
) {
|
|
return `${element.label} · configured`;
|
|
}
|
|
|
|
return element.label;
|
|
};
|
|
|
|
const buildCanvasElementStyle = (element: CanvasElement): React.CSSProperties =>
|
|
buildElementStyle(element);
|
|
|
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|
const router = useRouter();
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const isElementEditMode = mode === 'element_edit';
|
|
|
|
const projectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
const pageElementsListHref = useMemo(() => {
|
|
if (!projectId) return '/project-element-defaults/project-element-defaults-list';
|
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
|
}, [projectId]);
|
|
|
|
const pageIdFromRoute = useMemo(() => {
|
|
const value = router.query.pageId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.pageId]);
|
|
const elementIdFromRoute = useMemo(() => {
|
|
const value = router.query.elementId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.elementId]);
|
|
|
|
const [pages, setPages] = useState<TourPage[]>([]);
|
|
const [pageLinks, setPageLinks] = useState<PreloadPageLink[]>([]);
|
|
const [allPagesPreloadElements, setAllPagesPreloadElements] = useState<
|
|
PreloadElement[]
|
|
>([]);
|
|
const [assets, setAssets] = useState<ProjectAsset[]>([]);
|
|
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
|
|
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
|
|
>({});
|
|
const [activePageId, setActivePageId] = useState('');
|
|
const [projectName, setProjectName] = useState('');
|
|
|
|
const [elements, setElements] = useState<CanvasElement[]>([]);
|
|
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
|
|
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
|
|
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
|
|
const [selectedElementId, setSelectedElementId] = useState('');
|
|
const [selectedMenuItem, setSelectedMenuItem] =
|
|
useState<EditorMenuItem>('none');
|
|
const [transitionPreview, setTransitionPreview] =
|
|
useState<TransitionPreviewState | null>(null);
|
|
const [pendingNavigationPageId, setPendingNavigationPageId] = useState('');
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
|
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
|
const [newTransitionName, setNewTransitionName] = useState('');
|
|
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
|
|
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
|
|
useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
|
|
Record<string, number | null>
|
|
>({});
|
|
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
|
|
const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [constructorInteractionMode, setConstructorInteractionMode] =
|
|
useState<ConstructorInteractionMode>('edit');
|
|
const [constructorControlsPosition, setConstructorControlsPosition] =
|
|
useState({ x: 20, y: 20 });
|
|
|
|
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 });
|
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
|
|
|
const constructorControlsDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const menuDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const editorDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const elementDragRef = useRef<DragElementState | null>(null);
|
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
|
const didSetInitialCanvasFocus = useRef(false);
|
|
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
|
|
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
|
|
const preloadedIconUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
const activePage = useMemo(
|
|
() => pages.find((item) => item.id === activePageId) || null,
|
|
[activePageId, pages],
|
|
);
|
|
|
|
// Preload orchestrator for better DX when previewing pages
|
|
// Preloads neighbor page assets and transition videos
|
|
// Uses allPagesPreloadElements (extracted in loadData) for proper neighbor preloading
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages: pages.map((p) => ({
|
|
id: p.id,
|
|
background_image_url: p.background_image_url,
|
|
background_video_url: p.background_video_url,
|
|
})),
|
|
pageLinks,
|
|
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading
|
|
currentPageId: activePageId,
|
|
enabled: !isLoading && !!activePageId,
|
|
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
|
|
});
|
|
|
|
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
|
|
const pageSwitch = usePageSwitch({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
// Helper to switch pages without flash
|
|
// Uses usePageSwitch hook to resolve blob URLs from preload cache
|
|
// Also updates storage path state for editing/saving purposes
|
|
const switchToPage = useCallback(
|
|
async (page: TourPage | null) => {
|
|
// Mark this page as initialized to prevent redundant effect calls
|
|
if (page) {
|
|
lastInitializedPageIdRef.current = page.id;
|
|
}
|
|
|
|
// Update storage path state (for editing and saving)
|
|
setBackgroundImageUrl(page?.background_image_url || '');
|
|
setBackgroundVideoUrl(page?.background_video_url || '');
|
|
setBackgroundAudioUrl(page?.background_audio_url || '');
|
|
|
|
// Use hook to resolve and set blob URLs for display
|
|
await pageSwitch.switchToPage(
|
|
page
|
|
? {
|
|
id: page.id,
|
|
background_image_url: page.background_image_url,
|
|
background_video_url: page.background_video_url,
|
|
background_audio_url: page.background_audio_url,
|
|
}
|
|
: null,
|
|
() => {
|
|
if (page) {
|
|
setActivePageId(page.id);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
[pageSwitch],
|
|
);
|
|
|
|
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
|
videoRef: transitionVideoRef,
|
|
transition: transitionPreview
|
|
? {
|
|
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
|
|
reverseMode: transitionPreview.reverseMode,
|
|
reverseVideoUrl: transitionPreview.reverseVideoUrl
|
|
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
|
|
: undefined,
|
|
durationSec: transitionPreview.durationSec,
|
|
targetPageId: pendingNavigationPageId || undefined,
|
|
displayName: transitionPreview.title,
|
|
}
|
|
: null,
|
|
onComplete: async (targetPageId) => {
|
|
const video = transitionVideoRef.current;
|
|
if (targetPageId) {
|
|
const targetPage = pages.find((p) => p.id === targetPageId) || null;
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch
|
|
await switchToPage(targetPage);
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
setTransitionPreview(null);
|
|
setPendingNavigationPageId('');
|
|
});
|
|
});
|
|
} else {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
setTransitionPreview(null);
|
|
setPendingNavigationPageId('');
|
|
}
|
|
},
|
|
timeouts: {
|
|
playbackStartMs: 3000,
|
|
hardTimeoutMs: 45000,
|
|
},
|
|
features: {
|
|
useBlobUrl: true,
|
|
preDecodeImages: false, // We handle image loading via usePageSwitch
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
},
|
|
});
|
|
|
|
// Clear previous background overlay when new background is ready (direct navigation)
|
|
useEffect(() => {
|
|
if (
|
|
pageSwitch.isSwitching &&
|
|
pageSwitch.isNewBgReady &&
|
|
pageSwitch.previousBgImageUrl
|
|
) {
|
|
pageSwitch.clearPreviousBackground();
|
|
}
|
|
}, [
|
|
pageSwitch.isSwitching,
|
|
pageSwitch.isNewBgReady,
|
|
pageSwitch.previousBgImageUrl,
|
|
pageSwitch.clearPreviousBackground,
|
|
]);
|
|
|
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
|
return ['navigation_next', 'navigation_prev'];
|
|
}, []);
|
|
const pageNameById = useMemo(() => {
|
|
const acc: Record<string, string> = {};
|
|
pages.forEach((page, index) => {
|
|
acc[String(page.id)] = page.name || `Page ${index + 1}`;
|
|
});
|
|
return acc;
|
|
}, [pages]);
|
|
|
|
const pageNameBySlug = useMemo(() => {
|
|
const acc: Record<string, string> = {};
|
|
pages.forEach((page, index) => {
|
|
acc[String(page.slug)] = page.name || `Page ${index + 1}`;
|
|
});
|
|
return acc;
|
|
}, [pages]);
|
|
const selectedElement = useMemo(
|
|
() => elements.find((element) => element.id === selectedElementId) || null,
|
|
[elements, selectedElementId],
|
|
);
|
|
const iconPreloadTargets = useMemo(() => {
|
|
const preloadableTypes: CanvasElementType[] = [
|
|
'navigation_next',
|
|
'navigation_prev',
|
|
'tooltip',
|
|
'description',
|
|
];
|
|
const urls = elements
|
|
.filter(
|
|
(element) =>
|
|
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
|
|
)
|
|
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
|
|
.filter(Boolean);
|
|
|
|
return Array.from(new Set(urls));
|
|
}, [elements]);
|
|
const normalizeNavigationElementType = useCallback(
|
|
(
|
|
element: CanvasElement,
|
|
nextType: NavigationElementType,
|
|
): CanvasElement => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const nextButtonLabel = getNavigationButtonLabel(nextType);
|
|
const hasDefaultLabel =
|
|
element.label === labelByType.navigation_next ||
|
|
element.label === labelByType.navigation_prev;
|
|
const hasDefaultNavLabel =
|
|
!element.navLabel ||
|
|
element.navLabel === getNavigationButtonLabel('navigation_next') ||
|
|
element.navLabel === getNavigationButtonLabel('navigation_prev');
|
|
|
|
return {
|
|
...element,
|
|
type: nextType,
|
|
navType: getNavigationButtonKind(nextType),
|
|
label: hasDefaultLabel ? labelByType[nextType] : element.label,
|
|
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
const imageAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'image' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const backgroundImageAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset) &&
|
|
isBackgroundImageAsset(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const videoAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'video' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const audioAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const transitionVideoAssetOptions = useMemo(() => {
|
|
const typedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'transition' &&
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (typedAssets.length > 0) return typedAssets;
|
|
|
|
const taggedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset) &&
|
|
/\[TRANSITION\]/i.test(String(asset.name || '')),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (taggedAssets.length > 0) return taggedAssets;
|
|
|
|
return videoAssetOptions;
|
|
}, [assets, videoAssetOptions]);
|
|
const iconAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'icon' &&
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const getKnownDurationForSource = useCallback(
|
|
(source?: string) => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return null;
|
|
|
|
const resolvedDuration = resolvedDurationBySource[normalizedSource];
|
|
if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) {
|
|
return Number(resolvedDuration);
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[resolvedDurationBySource],
|
|
);
|
|
|
|
const durationProbeTargets = useMemo<
|
|
Array<{ source: string; mediaType: 'video' | 'audio' }>
|
|
>(() => {
|
|
const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = [];
|
|
|
|
if (backgroundVideoUrl) {
|
|
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
if (backgroundAudioUrl) {
|
|
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
|
|
}
|
|
|
|
if (
|
|
selectedElement &&
|
|
(selectedElement.type === 'video_player' ||
|
|
selectedElement.type === 'audio_player') &&
|
|
selectedElement.mediaUrl
|
|
) {
|
|
targets.push({
|
|
source: selectedElement.mediaUrl,
|
|
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
|
|
});
|
|
}
|
|
|
|
if (newTransitionVideoUrl) {
|
|
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
elements.forEach((element) => {
|
|
if (!isNavigationElementType(element.type)) return;
|
|
if (element.transitionVideoUrl) {
|
|
targets.push({
|
|
source: element.transitionVideoUrl,
|
|
mediaType: 'video',
|
|
});
|
|
}
|
|
if (element.reverseVideoUrl) {
|
|
targets.push({ source: element.reverseVideoUrl, mediaType: 'video' });
|
|
}
|
|
});
|
|
|
|
return targets;
|
|
}, [
|
|
backgroundAudioUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
newTransitionVideoUrl,
|
|
selectedElement,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
let isCancelled = false;
|
|
|
|
durationProbeTargets.forEach(({ source, mediaType }) => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return;
|
|
|
|
if (getKnownDurationForSource(normalizedSource)) return;
|
|
|
|
const probeKey = `${mediaType}:${normalizedSource}`;
|
|
if (durationProbeInFlightRef.current.has(probeKey)) return;
|
|
durationProbeInFlightRef.current.add(probeKey);
|
|
|
|
resolveDurationWithFallback(normalizedSource, mediaType)
|
|
.then((duration) => {
|
|
if (isCancelled) return;
|
|
setResolvedDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]:
|
|
Number.isFinite(duration) && Number(duration) > 0
|
|
? Number(duration)
|
|
: null,
|
|
}));
|
|
})
|
|
.catch((error) => {
|
|
logger.error(
|
|
'Failed to resolve media duration:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
if (isCancelled) return;
|
|
setResolvedDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]: null,
|
|
}));
|
|
})
|
|
.finally(() => {
|
|
durationProbeInFlightRef.current.delete(probeKey);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, [durationProbeTargets, getKnownDurationForSource]);
|
|
|
|
const backgroundVideoDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)),
|
|
[backgroundVideoUrl, getKnownDurationForSource],
|
|
);
|
|
const backgroundAudioDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)),
|
|
[backgroundAudioUrl, getKnownDurationForSource],
|
|
);
|
|
const selectedMediaDurationNote = useMemo(() => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'video_player' &&
|
|
selectedElement.type !== 'audio_player')
|
|
) {
|
|
return 'Duration: unknown';
|
|
}
|
|
|
|
return formatDurationNote(
|
|
getKnownDurationForSource(selectedElement.mediaUrl || ''),
|
|
);
|
|
}, [getKnownDurationForSource, selectedElement]);
|
|
const newTransitionDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(newTransitionVideoUrl)),
|
|
[getKnownDurationForSource, newTransitionVideoUrl],
|
|
);
|
|
const selectedTransitionDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
|
|
return formatDurationNote(
|
|
getKnownDurationForSource(selectedElement.transitionVideoUrl || ''),
|
|
);
|
|
}, [getKnownDurationForSource, selectedElement]);
|
|
|
|
useEffect(() => {
|
|
if (newTransitionVideoUrl) return;
|
|
if (!transitionVideoAssetOptions.length) return;
|
|
setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value);
|
|
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
|
|
|
|
useEffect(() => {
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const next = prev.map((element) => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const resolvedDuration = getKnownDurationForSource(
|
|
element.transitionVideoUrl || '',
|
|
);
|
|
const nextDuration =
|
|
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
|
|
? Number(resolvedDuration)
|
|
: undefined;
|
|
if (element.transitionDurationSec === nextDuration) return element;
|
|
|
|
hasChanges = true;
|
|
return {
|
|
...element,
|
|
transitionDurationSec: nextDuration,
|
|
};
|
|
});
|
|
|
|
return hasChanges ? next : prev;
|
|
});
|
|
}, [getKnownDurationForSource]);
|
|
|
|
const loadData = useCallback(
|
|
async (preservePageId?: string) => {
|
|
if (!projectId || !router.isReady || !isAuthReady) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const [
|
|
projectResponse,
|
|
pagesResponse,
|
|
assetsResponse,
|
|
uiElementsResponse,
|
|
] = await Promise.all([
|
|
axios.get(`/projects/${projectId}`),
|
|
axios.get(
|
|
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`,
|
|
),
|
|
axios.get(
|
|
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
|
|
),
|
|
axios.get(
|
|
`/project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`,
|
|
),
|
|
]);
|
|
|
|
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
|
|
? pagesResponse.data.rows
|
|
: [];
|
|
const assetRows: ProjectAsset[] = Array.isArray(
|
|
assetsResponse?.data?.rows,
|
|
)
|
|
? assetsResponse.data.rows
|
|
: [];
|
|
setProjectName(projectResponse?.data?.name || '');
|
|
setPages(pageRows);
|
|
|
|
// Extract page links and preload elements using shared utility
|
|
const { pageLinks: syntheticPageLinks, preloadElements: allPreloadElements } =
|
|
extractPageLinksAndElements(pageRows);
|
|
|
|
setPageLinks(syntheticPageLinks);
|
|
setAllPagesPreloadElements(allPreloadElements);
|
|
setAssets(assetRows);
|
|
|
|
// Process project element defaults using shared utilities
|
|
const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows)
|
|
? uiElementsResponse.data.rows
|
|
: [];
|
|
const normalizedDefaults = uiElementRows
|
|
.map((row: Record<string, unknown>) => normalizeElementDefault(row))
|
|
.filter((d): d is NormalizedElementDefault => d !== null);
|
|
const defaultsByType = buildElementDefaultsMap(normalizedDefaults);
|
|
setUiElementDefaultsByType(defaultsByType);
|
|
|
|
// Preserve current page if specified and it still exists, otherwise use route or first page
|
|
const preservedPageExists =
|
|
preservePageId && pageRows.some((p: any) => p.id === preservePageId);
|
|
const defaultPageId = preservedPageExists
|
|
? preservePageId
|
|
: pageIdFromRoute || pageRows[0]?.id || '';
|
|
setActivePageId(defaultPageId);
|
|
setIsMenuOpen(false);
|
|
} catch (error: any) {
|
|
if (error?.response?.status === 401) {
|
|
const message = 'Your session has expired. Please sign in again.';
|
|
logger.error(
|
|
'Unauthorized constructor request:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to load constructor data.';
|
|
logger.error(
|
|
'Failed to load constructor data:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
setUiElementDefaultsByType({});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[isAuthReady, pageIdFromRoute, projectId, router],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof window === 'undefined') return;
|
|
|
|
const token =
|
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (!token) {
|
|
setIsAuthReady(false);
|
|
setErrorMessage('Please sign in to continue.');
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
setIsAuthReady(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) return;
|
|
if (projectId) return;
|
|
router.replace(
|
|
isElementEditMode
|
|
? '/project-element-defaults/project-element-defaults-list'
|
|
: '/projects/projects-list',
|
|
);
|
|
}, [isElementEditMode, projectId, router]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
setConstructorControlsPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: 20,
|
|
y: 20,
|
|
};
|
|
});
|
|
|
|
setMenuPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: Math.max(window.innerWidth - 280, 20),
|
|
y: prev.y,
|
|
};
|
|
});
|
|
|
|
setEditorPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: Math.max(window.innerWidth - 400, 20),
|
|
y: prev.y,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || !isAuthReady || isLoading) return;
|
|
if (didSetInitialCanvasFocus.current) return;
|
|
if (!canvasRef.current) return;
|
|
|
|
didSetInitialCanvasFocus.current = true;
|
|
requestAnimationFrame(() => {
|
|
canvasRef.current?.focus({ preventScroll: true });
|
|
});
|
|
}, [isAuthReady, isLoading, router.isReady]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (isLoading || !activePageId) {
|
|
setCanvasElapsedSec(0);
|
|
return;
|
|
}
|
|
|
|
pagePlaybackStartedAtRef.current = Date.now();
|
|
setCanvasElapsedSec(0);
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
const elapsed = (Date.now() - pagePlaybackStartedAtRef.current) / 1000;
|
|
setCanvasElapsedSec(elapsed > 0 ? elapsed : 0);
|
|
}, 100);
|
|
|
|
return () => window.clearInterval(intervalId);
|
|
}, [activePageId, isLoading]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const targetSet = new Set(iconPreloadTargets);
|
|
const nextPreloaded = new Set<string>();
|
|
preloadedIconUrlsRef.current.forEach((url) => {
|
|
if (targetSet.has(url)) nextPreloaded.add(url);
|
|
});
|
|
preloadedIconUrlsRef.current = nextPreloaded;
|
|
setPreloadedIconUrlMap(() => {
|
|
const nextMap: Record<string, boolean> = {};
|
|
nextPreloaded.forEach((url) => {
|
|
nextMap[url] = true;
|
|
});
|
|
return nextMap;
|
|
});
|
|
|
|
if (!iconPreloadTargets.length) return;
|
|
|
|
let isCancelled = false;
|
|
const preloadImages: HTMLImageElement[] = [];
|
|
|
|
iconPreloadTargets.forEach((url) => {
|
|
if (preloadedIconUrlsRef.current.has(url)) return;
|
|
|
|
const image = new Image();
|
|
const markReady = () => {
|
|
if (isCancelled) return;
|
|
preloadedIconUrlsRef.current.add(url);
|
|
setPreloadedIconUrlMap((prev) => {
|
|
if (prev[url]) return prev;
|
|
return {
|
|
...prev,
|
|
[url]: true,
|
|
};
|
|
});
|
|
};
|
|
|
|
image.onload = markReady;
|
|
image.onerror = () => {
|
|
logger.error('Failed to preload icon asset:', { url });
|
|
markReady();
|
|
};
|
|
image.src = url;
|
|
preloadImages.push(image);
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
preloadImages.forEach((image) => {
|
|
image.onload = null;
|
|
image.onerror = null;
|
|
});
|
|
};
|
|
}, [iconPreloadTargets]);
|
|
|
|
useEffect(() => {
|
|
if (!activePage) {
|
|
setElements([]);
|
|
setSelectedElementId('');
|
|
setBackgroundImageUrl('');
|
|
setBackgroundVideoUrl('');
|
|
setBackgroundAudioUrl('');
|
|
return;
|
|
}
|
|
|
|
const schema = parseJsonObject<ConstructorSchema>(
|
|
activePage.ui_schema_json,
|
|
{},
|
|
);
|
|
const normalizedElements = Array.isArray(schema.elements)
|
|
? schema.elements
|
|
.filter(
|
|
(item) =>
|
|
item && item.type && labelByType[item.type as CanvasElementType],
|
|
)
|
|
.map((item) => {
|
|
const elementType = item.type as CanvasElementType;
|
|
const normalizedElement: CanvasElement = {
|
|
...item,
|
|
id: String(item.id || createLocalId()),
|
|
label:
|
|
typeof item.label === 'string' && item.label.trim()
|
|
? item.label
|
|
: labelByType[elementType],
|
|
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
|
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
|
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
|
appearDurationSec: normalizeAppearDurationSec(
|
|
item.appearDurationSec,
|
|
),
|
|
galleryCards: Array.isArray(item.galleryCards)
|
|
? item.galleryCards.map((card: any, index: number) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl || ''),
|
|
title: String(card?.title || `Card ${index + 1}`),
|
|
description: String(card?.description || ''),
|
|
}))
|
|
: undefined,
|
|
carouselSlides: Array.isArray(item.carouselSlides)
|
|
? item.carouselSlides.map((slide: any, index: number) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl || ''),
|
|
caption: String(slide?.caption || `Slide ${index + 1}`),
|
|
}))
|
|
: undefined,
|
|
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
|
|
carouselPrevIconUrl:
|
|
typeof item.carouselPrevIconUrl === 'string'
|
|
? item.carouselPrevIconUrl
|
|
: '',
|
|
carouselNextIconUrl:
|
|
typeof item.carouselNextIconUrl === 'string'
|
|
? item.carouselNextIconUrl
|
|
: '',
|
|
tooltipTitle:
|
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
tooltipText:
|
|
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
descriptionTitle:
|
|
typeof item.descriptionTitle === 'string'
|
|
? item.descriptionTitle
|
|
: '',
|
|
descriptionText:
|
|
typeof item.descriptionText === 'string'
|
|
? item.descriptionText
|
|
: '',
|
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
|
navType:
|
|
item.navType === 'back' || item.navType === 'forward'
|
|
? item.navType
|
|
: isNavigationElementType(elementType)
|
|
? getNavigationButtonKind(
|
|
elementType as NavigationElementType,
|
|
)
|
|
: undefined,
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
targetPageSlug:
|
|
typeof item.targetPageSlug === 'string' ? item.targetPageSlug : '',
|
|
targetPageId:
|
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
|
transitionVideoUrl:
|
|
typeof item.transitionVideoUrl === 'string'
|
|
? item.transitionVideoUrl
|
|
: '',
|
|
transitionReverseMode:
|
|
item.transitionReverseMode === 'separate_video'
|
|
? 'separate_video'
|
|
: ('auto_reverse' as const),
|
|
reverseVideoUrl:
|
|
typeof item.reverseVideoUrl === 'string'
|
|
? item.reverseVideoUrl
|
|
: '',
|
|
transitionDurationSec: item.transitionDurationSec
|
|
? Number(item.transitionDurationSec)
|
|
: undefined,
|
|
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
|
|
mediaAutoplay:
|
|
typeof item.mediaAutoplay === 'boolean'
|
|
? item.mediaAutoplay
|
|
: true,
|
|
mediaLoop:
|
|
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
|
|
mediaMuted:
|
|
typeof item.mediaMuted === 'boolean'
|
|
? item.mediaMuted
|
|
: item.type === 'video_player',
|
|
};
|
|
|
|
return mergeElementWithDefaults(
|
|
normalizedElement,
|
|
uiElementDefaultsByType[elementType],
|
|
{ preferElementValues: true },
|
|
);
|
|
})
|
|
: [];
|
|
|
|
setElements(normalizedElements);
|
|
setSelectedMenuItem('none');
|
|
setSelectedElementId((current) => {
|
|
if (!normalizedElements.length) return '';
|
|
if (
|
|
elementIdFromRoute &&
|
|
normalizedElements.some((element) => element.id === elementIdFromRoute)
|
|
) {
|
|
return elementIdFromRoute;
|
|
}
|
|
if (normalizedElements.some((element) => element.id === current))
|
|
return current;
|
|
return '';
|
|
});
|
|
// Set storage paths for editing/saving
|
|
setBackgroundImageUrl(activePage.background_image_url || '');
|
|
setBackgroundVideoUrl(activePage.background_video_url || '');
|
|
setBackgroundAudioUrl(activePage.background_audio_url || '');
|
|
// Resolve blob URLs via hook for display (handles initial load and route changes)
|
|
// Only call if this page wasn't already initialized via switchToPage function
|
|
if (lastInitializedPageIdRef.current !== activePage.id) {
|
|
lastInitializedPageIdRef.current = activePage.id;
|
|
pageSwitch.switchToPage({
|
|
id: activePage.id,
|
|
background_image_url: activePage.background_image_url,
|
|
background_video_url: activePage.background_video_url,
|
|
background_audio_url: activePage.background_audio_url,
|
|
});
|
|
}
|
|
}, [activePage, elementIdFromRoute, uiElementDefaultsByType, pageSwitch.switchToPage]);
|
|
|
|
useEffect(() => {
|
|
if (allowedNavigationTypes.length !== 1) return;
|
|
const forcedType = allowedNavigationTypes[0];
|
|
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const nextElements = prev.map((element) => {
|
|
if (
|
|
!isNavigationElementType(element.type) ||
|
|
element.type === forcedType
|
|
)
|
|
return element;
|
|
hasChanges = true;
|
|
return normalizeNavigationElementType(element, forcedType);
|
|
});
|
|
return hasChanges ? nextElements : prev;
|
|
});
|
|
}, [allowedNavigationTypes, normalizeNavigationElementType]);
|
|
|
|
useEffect(() => {
|
|
const onPointerMove = (event: MouseEvent) => {
|
|
if (constructorControlsDragRef.current) {
|
|
const maxX = Math.max(window.innerWidth - 460, 0);
|
|
const maxY = Math.max(window.innerHeight - 64, 0);
|
|
const nextX = clamp(
|
|
event.clientX - constructorControlsDragRef.current.pointerOffsetX,
|
|
0,
|
|
maxX,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - constructorControlsDragRef.current.pointerOffsetY,
|
|
0,
|
|
maxY,
|
|
);
|
|
setConstructorControlsPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (menuDragRef.current) {
|
|
const nextX = clamp(
|
|
event.clientX - menuDragRef.current.pointerOffsetX,
|
|
0,
|
|
window.innerWidth - 240,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - menuDragRef.current.pointerOffsetY,
|
|
0,
|
|
window.innerHeight - 60,
|
|
);
|
|
setMenuPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (editorDragRef.current) {
|
|
const editorWidth = isEditorCollapsed ? 260 : 380;
|
|
const nextX = clamp(
|
|
event.clientX - editorDragRef.current.pointerOffsetX,
|
|
0,
|
|
window.innerWidth - editorWidth,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - editorDragRef.current.pointerOffsetY,
|
|
0,
|
|
window.innerHeight - 60,
|
|
);
|
|
setEditorPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (!elementDragRef.current || !canvasRef.current) return;
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const rawX =
|
|
event.clientX - rect.left - elementDragRef.current.pointerOffsetX;
|
|
const rawY =
|
|
event.clientY - rect.top - elementDragRef.current.pointerOffsetY;
|
|
const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100);
|
|
const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100);
|
|
|
|
setElements((prev) =>
|
|
prev.map((item) =>
|
|
item.id === elementDragRef.current?.id
|
|
? { ...item, xPercent: nextXPercent, yPercent: nextYPercent }
|
|
: item,
|
|
),
|
|
);
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
constructorControlsDragRef.current = null;
|
|
menuDragRef.current = null;
|
|
editorDragRef.current = null;
|
|
elementDragRef.current = null;
|
|
};
|
|
|
|
window.addEventListener('mousemove', onPointerMove);
|
|
window.addEventListener('mouseup', onPointerUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', onPointerMove);
|
|
window.removeEventListener('mouseup', onPointerUp);
|
|
};
|
|
}, [isEditorCollapsed]);
|
|
|
|
useEffect(() => {
|
|
if (isConstructorEditMode) return;
|
|
elementDragRef.current = null;
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
}, [isConstructorEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (!isConstructorEditMode) return;
|
|
if (!selectedElementId && selectedMenuItem === 'none') return;
|
|
|
|
const onOutsideMouseDown = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) return;
|
|
if (elementEditorRef.current?.contains(target)) return;
|
|
const clickedElementId = target
|
|
.closest('[data-constructor-element-id]')
|
|
?.getAttribute('data-constructor-element-id');
|
|
if (
|
|
selectedElementId &&
|
|
clickedElementId &&
|
|
clickedElementId === selectedElementId
|
|
)
|
|
return;
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
};
|
|
|
|
window.addEventListener('mousedown', onOutsideMouseDown);
|
|
return () => window.removeEventListener('mousedown', onOutsideMouseDown);
|
|
}, [isConstructorEditMode, selectedElementId, selectedMenuItem]);
|
|
|
|
const selectElementForEdit = (elementId: string) => {
|
|
setSelectedElementId(elementId);
|
|
setSelectedMenuItem('none');
|
|
};
|
|
|
|
const selectMenuItemForEdit = (item: EditorMenuItem) => {
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem(item);
|
|
};
|
|
|
|
const addElement = (type: CanvasElementType) => {
|
|
const nextElementType: CanvasElementType = isNavigationElementType(type)
|
|
? allowedNavigationTypes.includes(type)
|
|
? type
|
|
: allowedNavigationTypes[0]
|
|
: type;
|
|
const baseElement = createDefaultElement(nextElementType, elements.length);
|
|
const nextElement = mergeElementWithDefaults(
|
|
baseElement,
|
|
uiElementDefaultsByType[nextElementType],
|
|
);
|
|
setElements((prev) => [...prev, nextElement]);
|
|
selectElementForEdit(nextElement.id);
|
|
setSuccessMessage('Element added. Drag it to set position.');
|
|
setErrorMessage('');
|
|
};
|
|
|
|
const createPage = useCallback(async () => {
|
|
if (!projectId) {
|
|
setErrorMessage('Project is required.');
|
|
return;
|
|
}
|
|
|
|
const maxSortOrder = Math.max(
|
|
0,
|
|
...pages.map((item) => Number(item.sort_order || 0)),
|
|
);
|
|
const nextPageNumber = pages.length + 1;
|
|
|
|
const payload = {
|
|
project: projectId,
|
|
environment: activePage?.environment || 'dev',
|
|
source_key: '',
|
|
name: `Page ${nextPageNumber}`,
|
|
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
|
|
sort_order: maxSortOrder + 1,
|
|
background_image_url: '',
|
|
background_video_url: '',
|
|
background_audio_url: '',
|
|
background_loop: false,
|
|
requires_auth: false,
|
|
ui_schema_json: JSON.stringify({ elements: [] }),
|
|
};
|
|
|
|
try {
|
|
setIsCreatingPage(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
const response = await axios.post('/tour_pages', { data: payload });
|
|
const createdPage = response?.data;
|
|
|
|
await loadData();
|
|
|
|
if (createdPage?.id) {
|
|
setActivePageId(createdPage.id);
|
|
}
|
|
|
|
setIsMenuOpen(true);
|
|
setSuccessMessage(
|
|
'New page created. You can now configure it in constructor.',
|
|
);
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to create page.';
|
|
logger.error(
|
|
'Failed to create page from constructor:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsCreatingPage(false);
|
|
}
|
|
}, [activePage?.environment, loadData, pages, projectId]);
|
|
|
|
const createTransition = useCallback(async () => {
|
|
if (!projectId) {
|
|
setErrorMessage('Project is required.');
|
|
return;
|
|
}
|
|
|
|
const sanitizedVideoUrl = String(newTransitionVideoUrl || '').trim();
|
|
if (!sanitizedVideoUrl) {
|
|
setErrorMessage('Select a transition video asset first.');
|
|
return;
|
|
}
|
|
|
|
const sanitizedName =
|
|
String(newTransitionName || '').trim() ||
|
|
`Transition ${Date.now().toString().slice(-4)}`;
|
|
const resolvedDurationSec = getKnownDurationForSource(sanitizedVideoUrl);
|
|
if (!resolvedDurationSec) {
|
|
setErrorMessage(
|
|
'Could not resolve transition video duration yet. Please wait a moment and try again.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCreatingTransition(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const payload = {
|
|
project: projectId,
|
|
environment: activePage?.environment || 'dev',
|
|
source_key: '',
|
|
name: sanitizedName,
|
|
slug: `transition-${createLocalId()}`,
|
|
video_url: sanitizedVideoUrl,
|
|
audio_url: '',
|
|
supports_reverse: Boolean(newTransitionSupportsReverse),
|
|
duration_sec: resolvedDurationSec,
|
|
};
|
|
|
|
// Transitions are now stored directly in navigation elements as transitionVideoUrl
|
|
setSuccessMessage('Transition video can be set directly on navigation elements.');
|
|
setNewTransitionName('');
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to create transition.';
|
|
logger.error(
|
|
'Failed to create transition from constructor:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsCreatingTransition(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
getKnownDurationForSource,
|
|
newTransitionName,
|
|
newTransitionSupportsReverse,
|
|
newTransitionVideoUrl,
|
|
projectId,
|
|
]);
|
|
|
|
const saveConstructor = useCallback(async () => {
|
|
if (!activePageId) {
|
|
setErrorMessage('Select a page before saving.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const existingSchema = parseJsonObject<Record<string, any>>(
|
|
activePage?.ui_schema_json,
|
|
{},
|
|
);
|
|
const schemaToSave = {
|
|
...existingSchema,
|
|
elements,
|
|
};
|
|
|
|
await axios.put(`/tour_pages/${activePageId}`, {
|
|
id: activePageId,
|
|
data: {
|
|
environment: activePage?.environment,
|
|
source_key: activePage?.source_key,
|
|
name: activePage?.name,
|
|
slug: activePage?.slug,
|
|
sort_order: activePage?.sort_order,
|
|
requires_auth: activePage?.requires_auth,
|
|
ui_schema_json: schemaToSave,
|
|
background_image_url: backgroundImageUrl,
|
|
background_video_url: backgroundVideoUrl,
|
|
background_audio_url: backgroundAudioUrl,
|
|
background_loop: Boolean(backgroundAudioUrl),
|
|
},
|
|
});
|
|
|
|
setSuccessMessage(
|
|
'Constructor settings saved. Element positions are stored in percentages.',
|
|
);
|
|
await loadData(activePageId);
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to save constructor changes.';
|
|
logger.error(
|
|
'Failed to save constructor changes:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
activePage?.name,
|
|
activePage?.requires_auth,
|
|
activePage?.slug,
|
|
activePage?.sort_order,
|
|
activePage?.source_key,
|
|
activePage?.ui_schema_json,
|
|
activePageId,
|
|
backgroundAudioUrl,
|
|
backgroundImageUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
loadData,
|
|
]);
|
|
|
|
/**
|
|
* Save all dev content to stage environment.
|
|
* This copies all pages, elements, transitions, and audio from dev to stage.
|
|
*/
|
|
const saveToStage = useCallback(async () => {
|
|
if (!projectId) {
|
|
setErrorMessage('Project ID is required to save to stage.');
|
|
return;
|
|
}
|
|
|
|
// First save current changes
|
|
await saveConstructor();
|
|
|
|
try {
|
|
setIsSavingToStage(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
await axios.post('/publish/save-to-stage', { projectId });
|
|
|
|
setSuccessMessage(
|
|
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
|
|
);
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to save to stage.';
|
|
logger.error(
|
|
'Failed to save to stage:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsSavingToStage(false);
|
|
}
|
|
}, [projectId, saveConstructor]);
|
|
|
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
|
if (!isConstructorEditMode) return;
|
|
event.preventDefault();
|
|
if (!canvasRef.current) return;
|
|
|
|
const currentElement = elements.find((item) => item.id === elementId);
|
|
if (!currentElement) return;
|
|
selectElementForEdit(elementId);
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const elementLeftPx = (currentElement.xPercent / 100) * rect.width;
|
|
const elementTopPx = (currentElement.yPercent / 100) * rect.height;
|
|
|
|
elementDragRef.current = {
|
|
id: elementId,
|
|
pointerOffsetX: event.clientX - rect.left - elementLeftPx,
|
|
pointerOffsetY: event.clientY - rect.top - elementTopPx,
|
|
};
|
|
};
|
|
|
|
const preventImageDragStart = (event: React.DragEvent<HTMLImageElement>) => {
|
|
event.preventDefault();
|
|
};
|
|
|
|
const updateSelectedElement = (patch: Partial<CanvasElement>) => {
|
|
if (!selectedElementId) return;
|
|
setElements((prev) =>
|
|
prev.map((item) =>
|
|
item.id === selectedElementId ? { ...item, ...patch } : item,
|
|
),
|
|
);
|
|
};
|
|
|
|
const removeSelectedElement = () => {
|
|
if (!selectedElementId) return;
|
|
|
|
let nextSelectedId = '';
|
|
setElements((prev) => {
|
|
const filtered = prev.filter((item) => item.id !== selectedElementId);
|
|
nextSelectedId = filtered[0]?.id || '';
|
|
return filtered;
|
|
});
|
|
if (nextSelectedId) {
|
|
selectElementForEdit(nextSelectedId);
|
|
} else {
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
}
|
|
setSuccessMessage('Element removed.');
|
|
};
|
|
|
|
const updateGalleryCard = (cardId: string, patch: Partial<GalleryCard>) => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = (selectedElement.galleryCards || []).map((card) =>
|
|
card.id === cardId ? { ...card, ...patch } : card,
|
|
);
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const addGalleryCard = () => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = [
|
|
...(selectedElement.galleryCards || []),
|
|
{
|
|
id: createLocalId(),
|
|
imageUrl: '',
|
|
title: `Card ${(selectedElement.galleryCards || []).length + 1}`,
|
|
description: '',
|
|
},
|
|
];
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const removeGalleryCard = (cardId: string) => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = (selectedElement.galleryCards || []).filter(
|
|
(card) => card.id !== cardId,
|
|
);
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const updateCarouselSlide = (
|
|
slideId: string,
|
|
patch: Partial<CarouselSlide>,
|
|
) => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = (selectedElement.carouselSlides || []).map((slide) =>
|
|
slide.id === slideId ? { ...slide, ...patch } : slide,
|
|
);
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const addCarouselSlide = () => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = [
|
|
...(selectedElement.carouselSlides || []),
|
|
{
|
|
id: createLocalId(),
|
|
imageUrl: '',
|
|
caption: `Slide ${(selectedElement.carouselSlides || []).length + 1}`,
|
|
},
|
|
];
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const removeCarouselSlide = (slideId: string) => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = (selectedElement.carouselSlides || []).filter(
|
|
(slide) => slide.id !== slideId,
|
|
);
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const openTransitionPreviewForElement = (
|
|
element: CanvasElement,
|
|
direction: 'forward' | 'back',
|
|
) => {
|
|
if (!isNavigationElementType(element.type)) return;
|
|
|
|
if (!element.transitionVideoUrl) {
|
|
setErrorMessage(
|
|
'Select transition video asset to preview transition playback.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
direction === 'back' &&
|
|
element.transitionReverseMode === 'separate_video' &&
|
|
!element.reverseVideoUrl
|
|
) {
|
|
setErrorMessage(
|
|
'Select back-transition asset or switch reverse mode to Auto Reverse.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
setTransitionPreview({
|
|
videoUrl: element.transitionVideoUrl,
|
|
reverseMode:
|
|
direction === 'forward'
|
|
? 'none'
|
|
: element.transitionReverseMode === 'separate_video'
|
|
? 'separate'
|
|
: 'reverse',
|
|
reverseVideoUrl: element.reverseVideoUrl,
|
|
durationSec: element.transitionDurationSec,
|
|
title: `${element.navLabel || element.label} · ${direction}`,
|
|
});
|
|
};
|
|
|
|
const openTransitionPreview = (direction: 'forward' | 'back') => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'navigation_next' &&
|
|
selectedElement.type !== 'navigation_prev')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
openTransitionPreviewForElement(selectedElement, direction);
|
|
};
|
|
|
|
const onCanvasElementClick = (element: CanvasElement) => {
|
|
if (!isConstructorEditMode) {
|
|
if (isNavigationElementType(element.type)) {
|
|
// Disable navigation while transition is playing or buffering
|
|
if (transitionPreview || isReverseBuffering) {
|
|
return;
|
|
}
|
|
if (element.navDisabled) {
|
|
return;
|
|
}
|
|
const direction =
|
|
element.navType === 'back' || element.type === 'navigation_prev'
|
|
? 'back'
|
|
: 'forward';
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
const targetPageSlug = String(element.targetPageSlug || '').trim();
|
|
const legacyTargetPageId = String(element.targetPageId || '').trim();
|
|
|
|
// Resolve slug to page ID, or use legacy targetPageId
|
|
const targetPage = targetPageSlug
|
|
? pages.find((p) => p.slug === targetPageSlug)
|
|
: legacyTargetPageId
|
|
? pages.find((p) => p.id === legacyTargetPageId)
|
|
: null;
|
|
const targetPageId = targetPage?.id || '';
|
|
|
|
if (!targetPageId) {
|
|
setErrorMessage(
|
|
'No target page configured for this navigation button.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hasPlayableTransition =
|
|
Boolean(element.transitionVideoUrl) &&
|
|
!(
|
|
direction === 'back' &&
|
|
element.transitionReverseMode === 'separate_video' &&
|
|
!element.reverseVideoUrl
|
|
);
|
|
|
|
if (!hasPlayableTransition) {
|
|
setPendingNavigationPageId('');
|
|
setTransitionPreview(null);
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
|
switchToPage(targetPage).then(() => {
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
});
|
|
return;
|
|
}
|
|
|
|
setPendingNavigationPageId(targetPageId);
|
|
openTransitionPreviewForElement(element, direction);
|
|
}
|
|
return;
|
|
}
|
|
|
|
selectElementForEdit(element.id);
|
|
};
|
|
|
|
const onMenuDragStart = (event: React.MouseEvent) => {
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
menuDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const onConstructorControlsDragStart = (event: React.MouseEvent) => {
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
constructorControlsDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const onElementEditorDragStart = (event: React.MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('button')) return;
|
|
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
editorDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const renderCanvasElementContent = (element: CanvasElement) => {
|
|
if (
|
|
element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev'
|
|
) {
|
|
const fallbackNavLabel = getNavigationButtonLabel(element.type);
|
|
const navigationLabel = element.navLabel?.trim() || fallbackNavLabel;
|
|
if (element.iconUrl) {
|
|
// Use img tag with flexible sizing - auto for dimensions not provided
|
|
const imgStyle: React.CSSProperties = {
|
|
width: element.width || 'auto',
|
|
height: element.height || 'auto',
|
|
objectFit: 'contain',
|
|
};
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
return (
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Navigation icon'
|
|
style={imgStyle}
|
|
draggable={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
const targetPageName = element.targetPageSlug
|
|
? pageNameBySlug[element.targetPageSlug]
|
|
: element.targetPageId
|
|
? pageNameById[element.targetPageId]
|
|
: '';
|
|
return (
|
|
<div className='flex flex-col items-start gap-1'>
|
|
<div className='flex items-center gap-2'>
|
|
<span>{navigationLabel}</span>
|
|
</div>
|
|
{targetPageName ? (
|
|
<span className='text-[10px] text-gray-500'>
|
|
To: {targetPageName}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'tooltip') {
|
|
if (element.iconUrl) {
|
|
return (
|
|
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Tooltip icon'
|
|
fill
|
|
sizes='100vw'
|
|
className='object-contain'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='max-w-[200px] text-left'>
|
|
<p className='text-[11px] font-bold'>{element.tooltipTitle}</p>
|
|
<p className='text-[10px] text-gray-600 line-clamp-3'>
|
|
{element.tooltipText || 'Tooltip text'}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'description') {
|
|
if (element.iconUrl) {
|
|
return (
|
|
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Description icon'
|
|
fill
|
|
sizes='100vw'
|
|
className='object-contain'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
|
return (
|
|
<div
|
|
className='w-full text-left p-2 rounded'
|
|
style={{ backgroundColor: bgColor }}
|
|
>
|
|
<p
|
|
className='font-bold'
|
|
style={{
|
|
fontSize: element.descriptionTitleFontSize || '48px',
|
|
fontFamily: element.descriptionTitleFontFamily || 'inherit',
|
|
color: element.descriptionTitleColor || '#000000',
|
|
}}
|
|
>
|
|
{element.descriptionTitle || 'TITLE'}
|
|
</p>
|
|
{element.descriptionText && (
|
|
<p
|
|
className='line-clamp-4'
|
|
style={{
|
|
fontSize: element.descriptionTextFontSize || '36px',
|
|
fontFamily: element.descriptionTextFontFamily || 'inherit',
|
|
color: element.descriptionTextColor || '#4B5563',
|
|
}}
|
|
>
|
|
{element.descriptionText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'gallery') {
|
|
const cards = element.galleryCards || [];
|
|
return (
|
|
<div className='w-[220px]'>
|
|
<p className='mb-1 text-left text-[10px] font-semibold text-gray-600'>
|
|
Gallery ({cards.length})
|
|
</p>
|
|
<div className='grid grid-cols-3 gap-1'>
|
|
{cards.slice(0, 6).map((card) => (
|
|
<div
|
|
key={card.id}
|
|
className='h-12 overflow-hidden rounded bg-gray-100'
|
|
>
|
|
{card.imageUrl ? (
|
|
<div className='relative h-full w-full'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
|
alt={card.title || 'Gallery card'}
|
|
fill
|
|
sizes='100vw'
|
|
className='object-cover'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className='flex h-full items-center justify-center text-[9px] text-gray-400'>
|
|
No image
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'carousel') {
|
|
const firstSlide = (element.carouselSlides || [])[0];
|
|
return (
|
|
<div className='w-[220px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Carousel ({element.carouselSlides?.length || 0})
|
|
</p>
|
|
<div className='h-20 overflow-hidden rounded bg-gray-100'>
|
|
{firstSlide?.imageUrl ? (
|
|
<div className='relative h-full w-full'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
|
alt={firstSlide.caption || 'Carousel slide'}
|
|
fill
|
|
sizes='100vw'
|
|
className='object-cover'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className='flex h-full items-center justify-center text-[10px] text-gray-400'>
|
|
No slide image
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className='mt-1 text-[10px] text-gray-600 line-clamp-1'>
|
|
{firstSlide?.caption || 'No caption'}
|
|
</p>
|
|
{(element.carouselPrevIconUrl || element.carouselNextIconUrl) && (
|
|
<div className='mt-1 flex items-center justify-between text-[9px] text-gray-500'>
|
|
<span className='flex items-center gap-1'>
|
|
{element.carouselPrevIconUrl ? (
|
|
<div className='relative h-3 w-3'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
|
|
alt='Previous icon'
|
|
fill
|
|
sizes='100vw'
|
|
className='object-contain'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
Prev
|
|
</span>
|
|
<span className='flex items-center gap-1'>
|
|
Next
|
|
{element.carouselNextIconUrl ? (
|
|
<div className='relative h-3 w-3'>
|
|
<NextImage
|
|
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
|
|
alt='Next icon'
|
|
fill
|
|
sizes='100vw'
|
|
className='object-contain'
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'video_player') {
|
|
return (
|
|
<div className='w-[220px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Video player
|
|
</p>
|
|
<video
|
|
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`}
|
|
className='h-24 w-full rounded bg-black object-cover'
|
|
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
|
controls
|
|
autoPlay={Boolean(element.mediaAutoplay)}
|
|
loop={Boolean(element.mediaLoop)}
|
|
muted={Boolean(element.mediaMuted)}
|
|
playsInline
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'audio_player') {
|
|
return (
|
|
<div className='w-[240px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Audio player
|
|
</p>
|
|
<audio
|
|
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
|
|
className='w-full'
|
|
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
|
controls
|
|
autoPlay={Boolean(element.mediaAutoplay)}
|
|
loop={Boolean(element.mediaLoop)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return getElementButtonTitle(element);
|
|
};
|
|
|
|
const isElementVisibleOnCanvas = (element: CanvasElement) => {
|
|
const delay = Number(element.appearDelaySec || 0);
|
|
if (canvasElapsedSec < delay) return false;
|
|
|
|
if (
|
|
element.appearDurationSec === null ||
|
|
element.appearDurationSec === undefined
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const duration = Number(element.appearDurationSec);
|
|
if (!Number.isFinite(duration) || duration <= 0) return true;
|
|
|
|
return canvasElapsedSec <= delay + duration;
|
|
};
|
|
|
|
const isElementReadyForCanvasRender = (element: CanvasElement) => {
|
|
const isPreloadableIconElement =
|
|
(element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev' ||
|
|
element.type === 'tooltip' ||
|
|
element.type === 'description') &&
|
|
Boolean(element.iconUrl);
|
|
|
|
if (!isPreloadableIconElement) return true;
|
|
|
|
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
|
|
if (!playbackUrl) return true;
|
|
|
|
return Boolean(preloadedIconUrlMap[playbackUrl]);
|
|
};
|
|
|
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
|
// Prefer hook's blob URLs (instant display) but fall back to resolved URLs for manual changes
|
|
const backgroundImageSrc =
|
|
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
|
|
const backgroundVideoSrc =
|
|
pageSwitch.currentBgVideoUrl || resolveAssetPlaybackUrl(backgroundVideoUrl);
|
|
const backgroundAudioSrc =
|
|
pageSwitch.currentBgAudioUrl || resolveAssetPlaybackUrl(backgroundAudioUrl);
|
|
const backgroundImageSelectOptions = addFallbackAssetOption(
|
|
backgroundImageAssetOptions,
|
|
backgroundImageUrl,
|
|
`Current image · ${backgroundImageUrl}`,
|
|
);
|
|
const backgroundVideoSelectOptions = addFallbackAssetOption(
|
|
videoAssetOptions,
|
|
backgroundVideoUrl,
|
|
`Current video · ${backgroundVideoUrl}`,
|
|
);
|
|
const backgroundAudioSelectOptions = addFallbackAssetOption(
|
|
audioAssetOptions,
|
|
backgroundAudioUrl,
|
|
`Current audio · ${backgroundAudioUrl}`,
|
|
);
|
|
const hasEditorSelection =
|
|
isConstructorEditMode &&
|
|
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
|
const editorTitle =
|
|
selectedMenuItem === 'background_image'
|
|
? 'Background image'
|
|
: selectedMenuItem === 'background_video'
|
|
? 'Background video'
|
|
: selectedMenuItem === 'background_audio'
|
|
? 'Background audio'
|
|
: selectedMenuItem === 'create_transition'
|
|
? 'Create transition'
|
|
: selectedElement?.label || 'Element editor';
|
|
|
|
if (backgroundImageSrc) {
|
|
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
|
|
canvasBackgroundStyle.backgroundSize = 'cover';
|
|
canvasBackgroundStyle.backgroundPosition = 'center';
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>
|
|
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
|
|
</title>
|
|
</Head>
|
|
<div className='relative w-screen h-screen bg-black overflow-hidden'>
|
|
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
|
|
<p className='text-xs font-semibold text-gray-700'>
|
|
{projectName || 'Loading project...'}
|
|
</p>
|
|
{errorMessage ? (
|
|
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
{successMessage ? (
|
|
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
|
|
{successMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{pages.length > 0 && isElementEditMode && (
|
|
<div className='flex items-center gap-2'>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Back to Elements'
|
|
icon={mdiExitToApp}
|
|
href={pageElementsListHref}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
icon={mdiContentSave}
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<div
|
|
className='fixed z-40 w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
|
|
style={{
|
|
left: constructorControlsPosition.x,
|
|
top: constructorControlsPosition.y,
|
|
}}
|
|
>
|
|
<div
|
|
className='flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 px-3 py-2'
|
|
onMouseDown={onConstructorControlsDragStart}
|
|
>
|
|
<span className='text-xs font-bold uppercase'>
|
|
Constructor Controls
|
|
</span>
|
|
</div>
|
|
<div className='space-y-2 p-3'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<select
|
|
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
|
|
value={activePageId}
|
|
onChange={(event) => {
|
|
const page = pages.find((p) => p.id === event.target.value);
|
|
if (page) switchToPage(page);
|
|
}}
|
|
>
|
|
{pages.map((page, index) => (
|
|
<option key={page.id} value={page.id}>
|
|
{page.name || `Page ${index + 1}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Exit to Assets'
|
|
icon={mdiExitToApp}
|
|
href={
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list'
|
|
}
|
|
/>
|
|
</div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<div className='inline-flex overflow-hidden rounded border border-gray-300 bg-white text-xs font-semibold'>
|
|
<button
|
|
type='button'
|
|
className={`px-3 py-1.5 ${
|
|
isConstructorEditMode
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => setConstructorInteractionMode('edit')}
|
|
>
|
|
Edit mode
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className={`border-l border-gray-300 px-3 py-1.5 ${
|
|
!isConstructorEditMode
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => setConstructorInteractionMode('interact')}
|
|
>
|
|
Interact mode
|
|
</button>
|
|
</div>
|
|
<span className='text-[11px] text-gray-600'>
|
|
{isConstructorEditMode
|
|
? 'Drag & configure elements.'
|
|
: 'Click and interact with rendered elements.'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={canvasRef}
|
|
tabIndex={-1}
|
|
className='absolute inset-0 bg-black overflow-hidden'
|
|
style={canvasBackgroundStyle}
|
|
>
|
|
{/* Background image - CSS backgroundImage on canvas provides instant display,
|
|
NextImage enhances with optimized loading. bg-black prevents white flash. */}
|
|
{backgroundImageSrc ? (
|
|
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
|
|
<NextImage
|
|
key={`bg_image_${backgroundImageSrc}`}
|
|
src={backgroundImageSrc}
|
|
alt='Background'
|
|
fill
|
|
sizes='100vw'
|
|
className='object-cover'
|
|
draggable={false}
|
|
onLoad={() => pageSwitch.markBackgroundReady()}
|
|
onError={() => pageSwitch.markBackgroundReady()}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Previous background overlay - shows during page switch until new bg is ready */}
|
|
{pageSwitch.previousBgImageUrl &&
|
|
pageSwitch.isSwitching &&
|
|
!pageSwitch.isNewBgReady && (
|
|
<div
|
|
className='absolute inset-0 pointer-events-none z-10'
|
|
style={{
|
|
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{backgroundVideoSrc ? (
|
|
<video
|
|
key={`bg_video_${backgroundVideoSrc}`}
|
|
className='absolute inset-0 w-full h-full object-cover'
|
|
src={backgroundVideoSrc}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
/>
|
|
) : null}
|
|
|
|
{backgroundAudioSrc ? (
|
|
<audio
|
|
key={`bg_audio_${backgroundAudioSrc}`}
|
|
src={backgroundAudioSrc}
|
|
autoPlay
|
|
loop
|
|
hidden
|
|
/>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<p className='text-sm text-gray-500'>Loading constructor...</p>
|
|
</div>
|
|
) : pages.length === 0 ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<BaseButton
|
|
color='info'
|
|
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
|
|
icon={mdiPlus}
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
/>
|
|
</div>
|
|
) : (
|
|
elements.map((element) => {
|
|
const shouldRender =
|
|
selectedElementId === element.id ||
|
|
(isElementVisibleOnCanvas(element) &&
|
|
isElementReadyForCanvasRender(element));
|
|
if (!shouldRender) return null;
|
|
|
|
const hasIconDrivenSize =
|
|
Boolean(element.iconUrl) &&
|
|
(element.type === 'tooltip' ||
|
|
element.type === 'description' ||
|
|
element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev');
|
|
const isNavigationIconElement =
|
|
Boolean(element.iconUrl) &&
|
|
(element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev');
|
|
const hasTransparentBackground =
|
|
(element.type === 'description' &&
|
|
!element.iconUrl &&
|
|
(!element.descriptionBackgroundColor ||
|
|
element.descriptionBackgroundColor === 'transparent')) ||
|
|
// Navigation buttons with icons should be transparent (icon is visible)
|
|
((element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev') &&
|
|
Boolean(element.iconUrl)) ||
|
|
element.type === 'tooltip' ||
|
|
element.type === 'gallery' ||
|
|
element.type === 'carousel';
|
|
const isNavDisabled =
|
|
isNavigationElementType(element.type) &&
|
|
(element.navDisabled ||
|
|
transitionPreview ||
|
|
isReverseBuffering);
|
|
|
|
return (
|
|
<button
|
|
key={element.id}
|
|
type='button'
|
|
data-constructor-element-id={element.id}
|
|
className={`absolute rounded text-xs font-semibold text-left ${
|
|
hasTransparentBackground ? '' : 'border shadow'
|
|
} ${
|
|
hasIconDrivenSize
|
|
? 'overflow-hidden p-0 leading-none'
|
|
: 'px-3 py-2'
|
|
} ${
|
|
isNavigationIconElement
|
|
? 'flex items-center justify-center'
|
|
: ''
|
|
} ${
|
|
isConstructorEditMode
|
|
? 'cursor-move'
|
|
: isNavDisabled
|
|
? 'cursor-not-allowed opacity-50'
|
|
: 'cursor-pointer'
|
|
} ${
|
|
selectedElementId === element.id
|
|
? 'border-blue-500 bg-blue-50 border shadow'
|
|
: hasTransparentBackground
|
|
? 'bg-transparent'
|
|
: 'border-blue-200 bg-white/95'
|
|
}`}
|
|
style={{
|
|
...buildCanvasElementStyle(element),
|
|
left: `${element.xPercent}%`,
|
|
top: `${element.yPercent}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
onMouseDown={
|
|
isConstructorEditMode
|
|
? (event) => onElementMouseDown(event, element.id)
|
|
: undefined
|
|
}
|
|
onClick={() => onCanvasElementClick(element)}
|
|
>
|
|
{renderCanvasElementContent(element)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && hasEditorSelection && (
|
|
<div
|
|
ref={elementEditorRef}
|
|
className={`fixed z-40 ${isEditorCollapsed ? 'w-[260px]' : 'w-[380px]'} max-h-[calc(100vh-2rem)] overflow-auto rounded-lg border border-gray-200 bg-white/95 p-3 shadow-xl`}
|
|
style={{ left: editorPosition.x, top: editorPosition.y }}
|
|
>
|
|
<div
|
|
className='mb-3 flex items-center justify-between gap-2 cursor-move'
|
|
onMouseDown={onElementEditorDragStart}
|
|
>
|
|
<p className='text-xs font-bold uppercase tracking-wide text-gray-700'>
|
|
{editorTitle}
|
|
</p>
|
|
<div className='flex items-center gap-2'>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-gray-700 hover:underline'
|
|
onClick={() => setIsEditorCollapsed((prev) => !prev)}
|
|
>
|
|
{isEditorCollapsed ? 'Expand' : 'Collapse'}
|
|
</button>
|
|
{selectedElement && (
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={removeSelectedElement}
|
|
>
|
|
Remove element
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!isEditorCollapsed && (
|
|
<>
|
|
{selectedMenuItem === 'background_image' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background image
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundImageUrl}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
setBackgroundImageUrl(nextValue);
|
|
if (nextValue) setBackgroundVideoUrl('');
|
|
}}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundImageSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'background_video' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background video
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundVideoUrl}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
setBackgroundVideoUrl(nextValue);
|
|
if (nextValue) setBackgroundImageUrl('');
|
|
}}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundVideoSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{backgroundVideoDurationNote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'background_audio' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background audio (loop)
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundAudioUrl}
|
|
onChange={(event) =>
|
|
setBackgroundAudioUrl(event.target.value)
|
|
}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundAudioSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{backgroundAudioDurationNote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'create_transition' && (
|
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Create next page transition
|
|
</p>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Name'
|
|
value={newTransitionName}
|
|
onChange={(event) =>
|
|
setNewTransitionName(event.target.value)
|
|
}
|
|
/>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={newTransitionVideoUrl}
|
|
onChange={(event) =>
|
|
setNewTransitionVideoUrl(event.target.value)
|
|
}
|
|
>
|
|
<option value=''>Transition video asset</option>
|
|
{transitionVideoAssetOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='text-[11px] text-gray-500'>
|
|
Transition duration is automatic from video metadata.{' '}
|
|
{newTransitionDurationNote}
|
|
</p>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={newTransitionSupportsReverse}
|
|
onChange={(event) =>
|
|
setNewTransitionSupportsReverse(event.target.checked)
|
|
}
|
|
/>
|
|
Supports reverse playback
|
|
</label>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={createTransition}
|
|
disabled={isCreatingTransition}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>
|
|
{isCreatingTransition
|
|
? 'Creating Transition...'
|
|
: 'Create Transition'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && (
|
|
<div className='mb-2 space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Label
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.label}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ label: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Appear delay (sec)
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
type='number'
|
|
min='0'
|
|
step='0.1'
|
|
value={selectedElement.appearDelaySec ?? 0}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
appearDelaySec: normalizeAppearDelaySec(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Appear duration (sec)
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
type='number'
|
|
min='0.1'
|
|
step='0.1'
|
|
placeholder='Unlimited'
|
|
value={selectedElement.appearDurationSec ?? ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
appearDurationSec: normalizeAppearDurationSec(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
Leave empty for unlimited.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement &&
|
|
(selectedElement.type === 'navigation_next' ||
|
|
selectedElement.type === 'navigation_prev') && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Type
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.navType === 'back' ||
|
|
selectedElement.navType === 'forward'
|
|
? selectedElement.navType
|
|
: getNavigationButtonKind(selectedElement.type)
|
|
}
|
|
onChange={(event) => {
|
|
const requestedKind: NavigationButtonKind =
|
|
event.target.value === 'back'
|
|
? 'back'
|
|
: 'forward';
|
|
const requestedType =
|
|
getNavigationTypeFromKind(requestedKind);
|
|
const nextType = allowedNavigationTypes.includes(
|
|
requestedType,
|
|
)
|
|
? requestedType
|
|
: allowedNavigationTypes[0];
|
|
updateSelectedElement(
|
|
normalizeNavigationElementType(
|
|
selectedElement,
|
|
nextType,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
<option
|
|
value='forward'
|
|
disabled={
|
|
!allowedNavigationTypes.includes(
|
|
'navigation_next',
|
|
)
|
|
}
|
|
>
|
|
Forward
|
|
</option>
|
|
<option
|
|
value='back'
|
|
disabled={
|
|
!allowedNavigationTypes.includes(
|
|
'navigation_prev',
|
|
)
|
|
}
|
|
>
|
|
Back
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Button text
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.navLabel || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
navLabel: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='flex items-center gap-2 text-[11px] font-semibold text-gray-600'>
|
|
<input
|
|
type='checkbox'
|
|
checked={selectedElement.navDisabled || false}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
navDisabled: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Disabled
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
iconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{selectedMediaDurationNote}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Target page
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.targetPageSlug || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
targetPageSlug: event.target.value,
|
|
// Clear legacy targetPageId when using slug
|
|
targetPageId: '',
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{pages
|
|
.filter((page) => page.id !== activePageId)
|
|
.map((page, index) => (
|
|
<option key={page.id} value={page.slug}>
|
|
{page.name || `Page ${index + 1}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Transition video asset
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.transitionVideoUrl || ''}
|
|
onChange={(event) => {
|
|
const nextVideoUrl = event.target.value;
|
|
const resolvedDuration =
|
|
getKnownDurationForSource(nextVideoUrl);
|
|
updateSelectedElement({
|
|
transitionVideoUrl: nextVideoUrl,
|
|
transitionDurationSec:
|
|
resolvedDuration || undefined,
|
|
});
|
|
}}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
transitionVideoAssetOptions,
|
|
selectedElement.transitionVideoUrl,
|
|
`Current video · ${selectedElement.transitionVideoUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{selectedTransitionDurationNote}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Back transition mode
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.transitionReverseMode ||
|
|
'auto_reverse'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
transitionReverseMode:
|
|
event.target.value === 'separate_video'
|
|
? 'separate_video'
|
|
: 'auto_reverse',
|
|
})
|
|
}
|
|
>
|
|
<option value='auto_reverse'>
|
|
Auto reverse transition video
|
|
</option>
|
|
<option value='separate_video'>
|
|
Use separate back-transition video
|
|
</option>
|
|
</select>
|
|
</div>
|
|
{selectedElement.transitionReverseMode ===
|
|
'separate_video' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Back transition video asset
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.reverseVideoUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
reverseVideoUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
transitionVideoAssetOptions,
|
|
selectedElement.reverseVideoUrl,
|
|
`Current back video · ${selectedElement.reverseVideoUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<p className='text-[11px] text-gray-500'>
|
|
Transition duration is set automatically from the
|
|
selected video. {selectedTransitionDurationNote}
|
|
</p>
|
|
<div className='flex gap-2 pt-1'>
|
|
<BaseButton
|
|
small
|
|
color='lightDark'
|
|
label='Preview Forward'
|
|
onClick={() => openTransitionPreview('forward')}
|
|
/>
|
|
<BaseButton
|
|
small
|
|
color='lightDark'
|
|
label='Preview Back'
|
|
onClick={() => openTransitionPreview('back')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'tooltip' && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ iconUrl: event.target.value })
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Tooltip title
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.tooltipTitle || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
tooltipTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Tooltip text
|
|
</label>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
rows={4}
|
|
value={selectedElement.tooltipText || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
tooltipText: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'description' && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ iconUrl: event.target.value })
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Description title
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.descriptionTitle || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Description text
|
|
</label>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
rows={5}
|
|
value={selectedElement.descriptionText || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionText: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Title font size
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.descriptionTitleFontSize || '48px'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitleFontSize: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. 48px'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Text font size
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.descriptionTextFontSize || '36px'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTextFontSize: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. 36px'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Title font family
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.descriptionTitleFontFamily ||
|
|
'inherit'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitleFontFamily: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. Arial, Helvetica, sans-serif'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Text font family
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.descriptionTextFontFamily || 'inherit'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTextFontFamily: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. Arial, Helvetica, sans-serif'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Title color
|
|
</label>
|
|
<input
|
|
type='color'
|
|
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
|
value={
|
|
selectedElement.descriptionTitleColor || '#000000'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitleColor: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Text color
|
|
</label>
|
|
<input
|
|
type='color'
|
|
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
|
value={
|
|
selectedElement.descriptionTextColor || '#4B5563'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTextColor: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background color
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.descriptionBackgroundColor ||
|
|
'transparent'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionBackgroundColor: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement &&
|
|
(selectedElement.type === 'video_player' ||
|
|
selectedElement.type === 'audio_player') && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
{selectedElement.type === 'video_player'
|
|
? 'Video asset'
|
|
: 'Audio asset'}
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.mediaUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
selectedElement.type === 'video_player'
|
|
? videoAssetOptions
|
|
: audioAssetOptions,
|
|
selectedElement.mediaUrl,
|
|
`Current media · ${selectedElement.mediaUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaAutoplay)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaAutoplay: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Autoplay
|
|
</label>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaLoop)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaLoop: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Loop
|
|
</label>
|
|
{selectedElement.type === 'video_player' && (
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaMuted)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaMuted: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Muted
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'gallery' && (
|
|
<div className='space-y-2'>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Gallery cards
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-blue-700 hover:underline'
|
|
onClick={addGalleryCard}
|
|
>
|
|
+ Add card
|
|
</button>
|
|
</div>
|
|
{(selectedElement.galleryCards || []).map((card, index) => (
|
|
<div
|
|
key={card.id}
|
|
className='rounded border border-gray-200 p-2 space-y-2'
|
|
>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Card {index + 1}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={() => removeGalleryCard(card.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={card.imageUrl}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
imageUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Image asset</option>
|
|
{addFallbackAssetOption(
|
|
imageAssetOptions,
|
|
card.imageUrl,
|
|
`Current image · ${card.imageUrl}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Title'
|
|
value={card.title}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
title: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Description'
|
|
rows={3}
|
|
value={card.description}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
description: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'carousel' && (
|
|
<div className='space-y-2'>
|
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Navigation icons
|
|
</p>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.carouselPrevIconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
carouselPrevIconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Previous icon</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.carouselPrevIconUrl,
|
|
`Current prev icon · ${selectedElement.carouselPrevIconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.carouselNextIconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
carouselNextIconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Next icon</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.carouselNextIconUrl,
|
|
`Current next icon · ${selectedElement.carouselNextIconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Carousel slides
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-blue-700 hover:underline'
|
|
onClick={addCarouselSlide}
|
|
>
|
|
+ Add slide
|
|
</button>
|
|
</div>
|
|
{(selectedElement.carouselSlides || []).map(
|
|
(slide, index) => (
|
|
<div
|
|
key={slide.id}
|
|
className='rounded border border-gray-200 p-2 space-y-2'
|
|
>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Slide {index + 1}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={() => removeCarouselSlide(slide.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={slide.imageUrl}
|
|
onChange={(event) =>
|
|
updateCarouselSlide(slide.id, {
|
|
imageUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Image asset</option>
|
|
{addFallbackAssetOption(
|
|
imageAssetOptions,
|
|
slide.imageUrl,
|
|
`Current image · ${slide.imageUrl}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Caption'
|
|
value={slide.caption}
|
|
onChange={(event) =>
|
|
updateCarouselSlide(slide.id, {
|
|
caption: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<div
|
|
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
|
|
style={{ left: menuPosition.x, top: menuPosition.y }}
|
|
>
|
|
<div
|
|
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
|
|
onMouseDown={onMenuDragStart}
|
|
>
|
|
<span className='text-xs font-bold uppercase'>
|
|
Constructor Menu
|
|
</span>
|
|
<button
|
|
type='button'
|
|
onClick={() => setIsMenuOpen((prev) => !prev)}
|
|
>
|
|
<BaseIcon path={mdiMenu} size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{isMenuOpen && (
|
|
<div className='p-2 space-y-1'>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_image')}
|
|
>
|
|
<BaseIcon path={mdiImageMultiple} size={16} />
|
|
<span>Background Image</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_video')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Background Video</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_audio')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Background Audio</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement(allowedNavigationTypes[0])}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>Add Navigation Button</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('create_transition')}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>Add Transition</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('gallery')}
|
|
>
|
|
<BaseIcon path={mdiImageMultiple} size={16} />
|
|
<span>Add Gallery</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('carousel')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Add Carousel</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('tooltip')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Add Tooltip</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('description')}
|
|
>
|
|
<BaseIcon path={mdiText} size={16} />
|
|
<span>Add Description</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('video_player')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Add Video Player</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('audio_player')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Add Audio Player</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
>
|
|
<BaseIcon path={mdiPlus} size={16} />
|
|
<span>
|
|
{isCreatingPage ? 'Creating Page...' : 'Create New Page'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn !text-blue-700'
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
>
|
|
<BaseIcon path={mdiContentSave} size={16} />
|
|
<span>{isSaving ? 'Saving...' : 'Save'}</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn !text-green-700'
|
|
onClick={saveToStage}
|
|
disabled={isSavingToStage || isSaving}
|
|
title='Copy all dev content to stage environment'
|
|
>
|
|
<BaseIcon path={mdiCloudUpload} size={16} />
|
|
<span>
|
|
{isSavingToStage ? 'Publishing...' : 'Save to Stage'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn !text-red-700'
|
|
onClick={() =>
|
|
router.push(
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list',
|
|
)
|
|
}
|
|
>
|
|
<BaseIcon path={mdiExitToApp} size={16} />
|
|
<span>Exit</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{transitionPreview && (
|
|
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
|
<video
|
|
ref={transitionVideoRef}
|
|
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
|
style={{ opacity: isReverseBuffering ? 0 : 1 }}
|
|
muted
|
|
playsInline
|
|
preload='auto'
|
|
disablePictureInPicture
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<style jsx>{`
|
|
.menu-action-btn {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
padding: 6px 8px;
|
|
border-radius: 6px;
|
|
color: #1f2937;
|
|
text-align: left;
|
|
}
|
|
|
|
.menu-action-btn:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
};
|
|
|
|
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default ConstructorPage;
|