3964 lines
138 KiB
TypeScript
3964 lines
138 KiB
TypeScript
import {
|
|
mdiContentSave,
|
|
mdiExitToApp,
|
|
mdiImageMultiple,
|
|
mdiMenu,
|
|
mdiPlus,
|
|
mdiSwapHorizontal,
|
|
mdiText,
|
|
mdiTooltipText,
|
|
mdiViewCarousel,
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import BaseButton from '../components/BaseButton';
|
|
import BaseIcon from '../components/BaseIcon';
|
|
import { baseURLApi, getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
|
|
type TourPage = {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
sort_order?: number;
|
|
environment?: string;
|
|
source_key?: string;
|
|
requires_auth?: boolean;
|
|
ui_schema_json?: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_audio_url?: string;
|
|
background_loop?: boolean;
|
|
};
|
|
|
|
type ProjectAsset = {
|
|
id: string;
|
|
name?: string;
|
|
asset_type?: 'image' | 'video' | 'audio' | 'file';
|
|
type?:
|
|
| 'icon'
|
|
| 'background_image'
|
|
| 'audio'
|
|
| 'video'
|
|
| 'transition'
|
|
| 'logo'
|
|
| 'favicon'
|
|
| 'document'
|
|
| 'general';
|
|
cdn_url?: string | null;
|
|
storage_key?: string | null;
|
|
};
|
|
|
|
type AssetOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
type CanvasElementType =
|
|
| 'navigation_next'
|
|
| 'navigation_prev'
|
|
| 'gallery'
|
|
| 'carousel'
|
|
| 'tooltip'
|
|
| 'description'
|
|
| 'video_player'
|
|
| 'audio_player';
|
|
|
|
type NavigationElementType = Extract<
|
|
CanvasElementType,
|
|
'navigation_next' | 'navigation_prev'
|
|
>;
|
|
|
|
type NavigationButtonKind = 'forward' | 'back';
|
|
|
|
type CanvasElement = {
|
|
id: string;
|
|
type: CanvasElementType;
|
|
label: string;
|
|
xPercent: number;
|
|
yPercent: number;
|
|
width?: string;
|
|
height?: string;
|
|
minWidth?: string;
|
|
maxWidth?: string;
|
|
minHeight?: string;
|
|
maxHeight?: string;
|
|
margin?: string;
|
|
padding?: string;
|
|
gap?: string;
|
|
fontSize?: string;
|
|
lineHeight?: string;
|
|
fontWeight?: string;
|
|
border?: string;
|
|
borderRadius?: string;
|
|
opacity?: string;
|
|
boxShadow?: string;
|
|
display?: string;
|
|
position?: string;
|
|
justifyContent?: string;
|
|
alignItems?: string;
|
|
textAlign?: string;
|
|
zIndex?: string;
|
|
appearDelaySec?: number;
|
|
appearDurationSec?: number | null;
|
|
iconUrl?: string;
|
|
galleryCards?: GalleryCard[];
|
|
carouselSlides?: CarouselSlide[];
|
|
carouselPrevIconUrl?: string;
|
|
carouselNextIconUrl?: string;
|
|
tooltipTitle?: string;
|
|
tooltipText?: string;
|
|
descriptionTitle?: string;
|
|
descriptionText?: string;
|
|
descriptionTitleFontSize?: string;
|
|
descriptionTextFontSize?: string;
|
|
descriptionBackgroundColor?: string;
|
|
navLabel?: string;
|
|
navType?: NavigationButtonKind;
|
|
targetPageId?: string;
|
|
transitionVideoUrl?: string;
|
|
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
|
reverseVideoUrl?: string;
|
|
transitionDurationSec?: number;
|
|
mediaUrl?: string;
|
|
mediaAutoplay?: boolean;
|
|
mediaLoop?: boolean;
|
|
mediaMuted?: boolean;
|
|
};
|
|
|
|
type GalleryCard = {
|
|
id: string;
|
|
imageUrl: string;
|
|
title: string;
|
|
description: string;
|
|
};
|
|
|
|
type CarouselSlide = {
|
|
id: string;
|
|
imageUrl: string;
|
|
caption: string;
|
|
};
|
|
|
|
type ConstructorSchema = {
|
|
elements?: CanvasElement[];
|
|
};
|
|
|
|
type UiElementDefault = {
|
|
id: string;
|
|
element_type?: string;
|
|
is_active?: boolean;
|
|
default_settings_json?: Partial<CanvasElement> | string | null;
|
|
};
|
|
|
|
type DragElementState = {
|
|
id: string;
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
};
|
|
|
|
type TransitionPreviewState = {
|
|
videoUrl: string;
|
|
reverseMode: 'none' | 'reverse' | 'separate';
|
|
reverseVideoUrl?: string;
|
|
durationSec?: number;
|
|
title: string;
|
|
};
|
|
|
|
type EditorMenuItem =
|
|
| 'none'
|
|
| 'background_image'
|
|
| 'background_video'
|
|
| 'background_audio'
|
|
| 'create_transition';
|
|
|
|
type ConstructorPageProps = {
|
|
mode?: 'constructor' | 'element_edit';
|
|
};
|
|
|
|
type ConstructorInteractionMode = 'edit' | 'interact';
|
|
|
|
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
|
|
if (!value) return (fallback || ({} as T)) as T;
|
|
|
|
try {
|
|
if (typeof value === 'string') {
|
|
const parsed = JSON.parse(value);
|
|
return (parsed || fallback || {}) as T;
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
return value as T;
|
|
}
|
|
|
|
return (fallback || ({} as T)) as T;
|
|
} catch (error) {
|
|
console.error('Failed to parse constructor JSON:', error);
|
|
return (fallback || ({} as T)) as T;
|
|
}
|
|
};
|
|
|
|
const clamp = (value: number, min: number, max: number) =>
|
|
Math.min(Math.max(value, min), max);
|
|
|
|
const normalizeAppearDelaySec = (value: unknown) => {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
return Number(parsed);
|
|
};
|
|
|
|
const normalizeAppearDurationSec = (value: unknown) => {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
return Number(parsed);
|
|
};
|
|
|
|
const getTrimmedCssValue = (value: unknown) => {
|
|
if (value === null || value === undefined) return '';
|
|
return String(value).trim();
|
|
};
|
|
|
|
const createLocalId = () => {
|
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
|
return window.crypto.randomUUID();
|
|
}
|
|
|
|
return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
};
|
|
|
|
const getAssetLabel = (asset: ProjectAsset) => {
|
|
const baseName = asset.name?.trim() || 'Untitled asset';
|
|
const source = String(asset.storage_key || asset.cdn_url || '').trim();
|
|
return `${baseName}${source ? ` · ${source}` : ''}`;
|
|
};
|
|
|
|
const getAssetSourceValue = (asset: ProjectAsset) =>
|
|
String(asset.storage_key || asset.cdn_url || '').trim();
|
|
|
|
const formatDurationNote = (durationSec?: number | string | null) => {
|
|
const parsed = Number(durationSec);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
|
|
|
const totalSeconds = Math.round(parsed);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (minutes <= 0) return `Duration: ${seconds}s`;
|
|
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
};
|
|
|
|
const resolveAssetPlaybackUrl = (value?: string) => {
|
|
const normalized = String(value || '').trim();
|
|
if (!normalized) return '';
|
|
|
|
if (normalized.startsWith('data:') || normalized.startsWith('blob:'))
|
|
return normalized;
|
|
|
|
if (normalized.startsWith('/api/file/download')) return normalized;
|
|
|
|
if (normalized.startsWith('/file/download'))
|
|
return `${baseURLApi}${normalized}`;
|
|
|
|
if (normalized.startsWith('http://') || normalized.startsWith('https://'))
|
|
return normalized;
|
|
|
|
const normalizedPrivateUrl = normalized.replace(/^\/+/, '');
|
|
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
|
|
};
|
|
|
|
const readMediaDuration = (
|
|
playbackUrl: string,
|
|
mediaType: 'video' | 'audio',
|
|
): Promise<number | null> =>
|
|
new Promise((resolve) => {
|
|
const mediaElement =
|
|
mediaType === 'video'
|
|
? document.createElement('video')
|
|
: document.createElement('audio');
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const cleanup = () => {
|
|
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
mediaElement.removeEventListener('error', onError);
|
|
mediaElement.removeEventListener('abort', onError);
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
mediaElement.pause();
|
|
mediaElement.removeAttribute('src');
|
|
mediaElement.load();
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
const duration = Number(mediaElement.duration);
|
|
cleanup();
|
|
if (Number.isFinite(duration) && duration > 0) {
|
|
resolve(duration);
|
|
return;
|
|
}
|
|
resolve(null);
|
|
};
|
|
|
|
const onError = () => {
|
|
cleanup();
|
|
resolve(null);
|
|
};
|
|
|
|
timeoutId = setTimeout(() => {
|
|
cleanup();
|
|
resolve(null);
|
|
}, 12000);
|
|
|
|
mediaElement.preload = 'metadata';
|
|
mediaElement.crossOrigin = 'anonymous';
|
|
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
mediaElement.addEventListener('error', onError);
|
|
mediaElement.addEventListener('abort', onError);
|
|
mediaElement.src = playbackUrl;
|
|
mediaElement.load();
|
|
});
|
|
|
|
const resolveDurationWithFallback = async (
|
|
source: string,
|
|
mediaType: 'video' | 'audio',
|
|
) => {
|
|
const playbackUrl = resolveAssetPlaybackUrl(source);
|
|
if (!playbackUrl) return null;
|
|
|
|
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
|
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
|
return Number(directDuration);
|
|
}
|
|
|
|
try {
|
|
const requestUrl =
|
|
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
|
|
? playbackUrl
|
|
: playbackUrl.replace(/^\/api(?=\/)/, '');
|
|
|
|
const token =
|
|
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
|
|
const response = await axios.get(requestUrl, {
|
|
responseType: 'blob',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
});
|
|
|
|
const blobUrl = URL.createObjectURL(response.data);
|
|
try {
|
|
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
|
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
|
return Number(blobDuration);
|
|
}
|
|
return null;
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch media for duration probing:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
|
if (asset.type) return asset.type === 'background_image';
|
|
const normalizedName = String(asset.name || '').toLowerCase();
|
|
if (!normalizedName) return false;
|
|
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(
|
|
normalizedName,
|
|
);
|
|
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
|
|
return hasBackgroundKeyword && !hasExcludedKeyword;
|
|
};
|
|
|
|
const addFallbackAssetOption = (
|
|
options: AssetOption[],
|
|
value?: string,
|
|
fallbackLabel?: string,
|
|
): AssetOption[] => {
|
|
const normalizedValue = String(value || '').trim();
|
|
if (!normalizedValue) return options;
|
|
if (options.some((option) => option.value === normalizedValue))
|
|
return options;
|
|
return [
|
|
...options,
|
|
{
|
|
value: normalizedValue,
|
|
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
|
|
},
|
|
];
|
|
};
|
|
|
|
const labelByType: Record<CanvasElementType, string> = {
|
|
navigation_next: 'Navigation: Forward',
|
|
navigation_prev: 'Navigation: Back',
|
|
gallery: 'Gallery',
|
|
carousel: 'Carousel',
|
|
tooltip: 'Tooltip',
|
|
description: 'Description',
|
|
video_player: 'Video Player',
|
|
audio_player: 'Audio Player',
|
|
};
|
|
|
|
const canvasElementTypes: CanvasElementType[] = [
|
|
'navigation_next',
|
|
'navigation_prev',
|
|
'gallery',
|
|
'carousel',
|
|
'tooltip',
|
|
'description',
|
|
'video_player',
|
|
'audio_player',
|
|
];
|
|
|
|
const isCanvasElementType = (value: string): value is CanvasElementType =>
|
|
canvasElementTypes.includes(value as CanvasElementType);
|
|
|
|
const isNavigationElementType = (
|
|
type: CanvasElementType,
|
|
): type is NavigationElementType =>
|
|
type === 'navigation_next' || type === 'navigation_prev';
|
|
|
|
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
|
type === 'navigation_next' ? 'Forward' : 'Back';
|
|
|
|
const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind =>
|
|
type === 'navigation_prev' ? 'back' : 'forward';
|
|
|
|
const getNavigationTypeFromKind = (
|
|
kind: NavigationButtonKind,
|
|
): NavigationElementType =>
|
|
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
|
|
|
const createDefaultElement = (
|
|
type: CanvasElementType,
|
|
index: number,
|
|
): CanvasElement => {
|
|
const base: CanvasElement = {
|
|
id: createLocalId(),
|
|
type,
|
|
label: labelByType[type],
|
|
xPercent: clamp(12 + index * 4, 5, 80),
|
|
yPercent: clamp(16 + index * 6, 8, 85),
|
|
appearDelaySec: 0,
|
|
appearDurationSec: null,
|
|
};
|
|
|
|
if (type === 'gallery') {
|
|
return {
|
|
...base,
|
|
galleryCards: [
|
|
{ id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' },
|
|
],
|
|
};
|
|
}
|
|
|
|
if (type === 'carousel') {
|
|
return {
|
|
...base,
|
|
carouselSlides: [
|
|
{ id: createLocalId(), imageUrl: '', caption: 'Slide 1' },
|
|
],
|
|
carouselPrevIconUrl: '',
|
|
carouselNextIconUrl: '',
|
|
};
|
|
}
|
|
|
|
if (type === 'tooltip') {
|
|
return {
|
|
...base,
|
|
iconUrl: '',
|
|
tooltipTitle: 'Tooltip title',
|
|
tooltipText: 'Tooltip text',
|
|
};
|
|
}
|
|
|
|
if (type === 'description') {
|
|
return {
|
|
...base,
|
|
iconUrl: '',
|
|
descriptionTitle: 'TITLE',
|
|
descriptionText: '',
|
|
descriptionTitleFontSize: '48px',
|
|
descriptionTextFontSize: '36px',
|
|
descriptionBackgroundColor: 'transparent',
|
|
};
|
|
}
|
|
|
|
if (type === 'navigation_next' || type === 'navigation_prev') {
|
|
return {
|
|
...base,
|
|
navLabel: getNavigationButtonLabel(type),
|
|
navType: getNavigationButtonKind(type),
|
|
iconUrl: '',
|
|
transitionReverseMode: 'auto_reverse',
|
|
};
|
|
}
|
|
|
|
if (type === 'video_player' || type === 'audio_player') {
|
|
return {
|
|
...base,
|
|
mediaUrl: '',
|
|
mediaAutoplay: true,
|
|
mediaLoop: true,
|
|
mediaMuted: type === 'video_player',
|
|
};
|
|
}
|
|
|
|
return base;
|
|
};
|
|
|
|
const mergeElementWithDefaults = (
|
|
element: CanvasElement,
|
|
defaults?: Partial<CanvasElement>,
|
|
options?: { preferElementValues?: boolean },
|
|
): CanvasElement => {
|
|
if (!defaults) return element;
|
|
|
|
const preferElementValues = Boolean(options?.preferElementValues);
|
|
const base = preferElementValues ? defaults : element;
|
|
const override = preferElementValues ? element : defaults;
|
|
const merged: CanvasElement = {
|
|
...base,
|
|
...override,
|
|
id: element.id,
|
|
type: element.type,
|
|
label: element.label || defaults.label || element.type,
|
|
xPercent: element.xPercent ?? defaults.xPercent ?? 50,
|
|
yPercent: element.yPercent ?? defaults.yPercent ?? 50,
|
|
};
|
|
|
|
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
|
|
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
|
|
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
|
|
merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec);
|
|
|
|
if (merged.type === 'gallery') {
|
|
const cards = preferElementValues
|
|
? Array.isArray(element.galleryCards)
|
|
? element.galleryCards
|
|
: defaults.galleryCards || []
|
|
: Array.isArray(defaults.galleryCards)
|
|
? defaults.galleryCards
|
|
: element.galleryCards || [];
|
|
merged.galleryCards = cards.map((card, cardIndex) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl || ''),
|
|
title: String(card?.title || `Card ${cardIndex + 1}`),
|
|
description: String(card?.description || ''),
|
|
}));
|
|
}
|
|
|
|
if (merged.type === 'carousel') {
|
|
const slides = preferElementValues
|
|
? Array.isArray(element.carouselSlides)
|
|
? element.carouselSlides
|
|
: defaults.carouselSlides || []
|
|
: Array.isArray(defaults.carouselSlides)
|
|
? defaults.carouselSlides
|
|
: element.carouselSlides || [];
|
|
merged.carouselSlides = slides.map((slide, slideIndex) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl || ''),
|
|
caption: String(slide?.caption || `Slide ${slideIndex + 1}`),
|
|
}));
|
|
}
|
|
|
|
return merged;
|
|
};
|
|
|
|
const getElementButtonTitle = (element: CanvasElement) => {
|
|
if (element.type === 'gallery') {
|
|
return `${element.label} (${element.galleryCards?.length || 0})`;
|
|
}
|
|
|
|
if (element.type === 'carousel') {
|
|
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
|
}
|
|
|
|
if (element.type === 'tooltip') return element.tooltipTitle ?? '';
|
|
if (element.type === 'description') return element.descriptionTitle ?? '';
|
|
if (element.type === 'navigation_next' || element.type === 'navigation_prev')
|
|
return (
|
|
element.navLabel?.trim() ||
|
|
getNavigationButtonLabel(element.type as NavigationElementType)
|
|
);
|
|
if (
|
|
(element.type === 'video_player' || element.type === 'audio_player') &&
|
|
element.mediaUrl
|
|
) {
|
|
return `${element.label} · configured`;
|
|
}
|
|
|
|
return element.label;
|
|
};
|
|
|
|
const buildCanvasElementStyle = (
|
|
element: CanvasElement,
|
|
): React.CSSProperties => {
|
|
const style: React.CSSProperties = {};
|
|
const width = getTrimmedCssValue(element.width);
|
|
if (width) style.width = width;
|
|
const height = getTrimmedCssValue(element.height);
|
|
if (height) style.height = height;
|
|
const minWidth = getTrimmedCssValue(element.minWidth);
|
|
if (minWidth) style.minWidth = minWidth;
|
|
const maxWidth = getTrimmedCssValue(element.maxWidth);
|
|
if (maxWidth) style.maxWidth = maxWidth;
|
|
const minHeight = getTrimmedCssValue(element.minHeight);
|
|
if (minHeight) style.minHeight = minHeight;
|
|
const maxHeight = getTrimmedCssValue(element.maxHeight);
|
|
if (maxHeight) style.maxHeight = maxHeight;
|
|
const margin = getTrimmedCssValue(element.margin);
|
|
if (margin) style.margin = margin;
|
|
const padding = getTrimmedCssValue(element.padding);
|
|
if (padding) style.padding = padding;
|
|
const gap = getTrimmedCssValue(element.gap);
|
|
if (gap) style.gap = gap;
|
|
const fontSize = getTrimmedCssValue(element.fontSize);
|
|
if (fontSize) style.fontSize = fontSize;
|
|
const lineHeight = getTrimmedCssValue(element.lineHeight);
|
|
if (lineHeight) style.lineHeight = lineHeight;
|
|
const fontWeight = getTrimmedCssValue(element.fontWeight);
|
|
if (fontWeight) style.fontWeight = fontWeight as React.CSSProperties['fontWeight'];
|
|
const border = getTrimmedCssValue(element.border);
|
|
if (border) style.border = border;
|
|
const borderRadius = getTrimmedCssValue(element.borderRadius);
|
|
if (borderRadius) style.borderRadius = borderRadius;
|
|
const opacity = getTrimmedCssValue(element.opacity);
|
|
if (opacity) {
|
|
const parsed = Number(opacity);
|
|
if (Number.isFinite(parsed)) style.opacity = parsed;
|
|
}
|
|
const boxShadow = getTrimmedCssValue(element.boxShadow);
|
|
if (boxShadow) style.boxShadow = boxShadow;
|
|
const display = getTrimmedCssValue(element.display);
|
|
if (display) style.display = display;
|
|
const position = getTrimmedCssValue(element.position);
|
|
if (position) style.position = position as React.CSSProperties['position'];
|
|
const justifyContent = getTrimmedCssValue(element.justifyContent);
|
|
if (justifyContent)
|
|
style.justifyContent = justifyContent as React.CSSProperties['justifyContent'];
|
|
const alignItems = getTrimmedCssValue(element.alignItems);
|
|
if (alignItems)
|
|
style.alignItems = alignItems as React.CSSProperties['alignItems'];
|
|
const textAlign = getTrimmedCssValue(element.textAlign);
|
|
if (textAlign) style.textAlign = textAlign as React.CSSProperties['textAlign'];
|
|
const zIndex = getTrimmedCssValue(element.zIndex);
|
|
if (zIndex) {
|
|
const parsed = Number(zIndex);
|
|
if (Number.isFinite(parsed)) style.zIndex = parsed;
|
|
}
|
|
return style;
|
|
};
|
|
|
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|
const router = useRouter();
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const isElementEditMode = mode === 'element_edit';
|
|
|
|
const projectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
const pageElementsListHref = useMemo(() => {
|
|
if (!projectId) return '/page_elements/page_elements-list';
|
|
return `/page_elements/page_elements-list?projectId=${encodeURIComponent(projectId)}`;
|
|
}, [projectId]);
|
|
|
|
const pageIdFromRoute = useMemo(() => {
|
|
const value = router.query.pageId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.pageId]);
|
|
const elementIdFromRoute = useMemo(() => {
|
|
const value = router.query.elementId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.elementId]);
|
|
|
|
const [pages, setPages] = useState<TourPage[]>([]);
|
|
const [assets, setAssets] = useState<ProjectAsset[]>([]);
|
|
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
|
|
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
|
|
>({});
|
|
const [activePageId, setActivePageId] = useState('');
|
|
const [projectName, setProjectName] = useState('');
|
|
|
|
const [elements, setElements] = useState<CanvasElement[]>([]);
|
|
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
|
|
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
|
|
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
|
|
const [selectedElementId, setSelectedElementId] = useState('');
|
|
const [selectedMenuItem, setSelectedMenuItem] =
|
|
useState<EditorMenuItem>('none');
|
|
const [transitionPreview, setTransitionPreview] =
|
|
useState<TransitionPreviewState | null>(null);
|
|
const [_pendingNavigationPageId, setPendingNavigationPageId] = useState('');
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
|
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
|
const [newTransitionName, setNewTransitionName] = useState('');
|
|
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
|
|
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
|
|
useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
|
|
Record<string, number | null>
|
|
>({});
|
|
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
|
|
const [preloadedIconUrlMap, setPreloadedIconUrlMap] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [constructorInteractionMode, setConstructorInteractionMode] =
|
|
useState<ConstructorInteractionMode>('edit');
|
|
const [constructorControlsPosition, setConstructorControlsPosition] =
|
|
useState({ x: 20, y: 20 });
|
|
|
|
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 });
|
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
|
|
|
const constructorControlsDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const menuDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const editorDragRef = useRef<{
|
|
pointerOffsetX: number;
|
|
pointerOffsetY: number;
|
|
} | null>(null);
|
|
const elementDragRef = useRef<DragElementState | null>(null);
|
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const reverseAnimationFrame = useRef<number | null>(null);
|
|
const didSetInitialCanvasFocus = useRef(false);
|
|
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
|
|
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
|
|
const preloadedIconUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
const activePage = useMemo(
|
|
() => pages.find((item) => item.id === activePageId) || null,
|
|
[activePageId, pages],
|
|
);
|
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
|
return ['navigation_next', 'navigation_prev'];
|
|
}, []);
|
|
const pageNameById = useMemo(() => {
|
|
const acc: Record<string, string> = {};
|
|
pages.forEach((page, index) => {
|
|
acc[String(page.id)] = page.name || `Page ${index + 1}`;
|
|
});
|
|
return acc;
|
|
}, [pages]);
|
|
const selectedElement = useMemo(
|
|
() => elements.find((element) => element.id === selectedElementId) || null,
|
|
[elements, selectedElementId],
|
|
);
|
|
const iconPreloadTargets = useMemo(() => {
|
|
const preloadableTypes: CanvasElementType[] = [
|
|
'navigation_next',
|
|
'navigation_prev',
|
|
'tooltip',
|
|
'description',
|
|
];
|
|
const urls = elements
|
|
.filter(
|
|
(element) =>
|
|
preloadableTypes.includes(element.type) && Boolean(element.iconUrl),
|
|
)
|
|
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
|
|
.filter(Boolean);
|
|
|
|
return Array.from(new Set(urls));
|
|
}, [elements]);
|
|
const normalizeNavigationElementType = useCallback(
|
|
(element: CanvasElement, nextType: NavigationElementType): CanvasElement => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const nextButtonLabel = getNavigationButtonLabel(nextType);
|
|
const hasDefaultLabel =
|
|
element.label === labelByType.navigation_next ||
|
|
element.label === labelByType.navigation_prev;
|
|
const hasDefaultNavLabel =
|
|
!element.navLabel ||
|
|
element.navLabel === getNavigationButtonLabel('navigation_next') ||
|
|
element.navLabel === getNavigationButtonLabel('navigation_prev');
|
|
|
|
return {
|
|
...element,
|
|
type: nextType,
|
|
navType: getNavigationButtonKind(nextType),
|
|
label: hasDefaultLabel ? labelByType[nextType] : element.label,
|
|
navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel,
|
|
};
|
|
},
|
|
[],
|
|
);
|
|
const imageAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'image' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const backgroundImageAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset) &&
|
|
isBackgroundImageAsset(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const videoAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'video' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const audioAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const transitionVideoAssetOptions = useMemo(() => {
|
|
const typedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'transition' &&
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (typedAssets.length > 0) return typedAssets;
|
|
|
|
const taggedAssets = assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.asset_type === 'video' &&
|
|
getAssetSourceValue(asset) &&
|
|
/\[TRANSITION\]/i.test(String(asset.name || '')),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
}));
|
|
|
|
if (taggedAssets.length > 0) return taggedAssets;
|
|
|
|
return videoAssetOptions;
|
|
}, [assets, videoAssetOptions]);
|
|
const iconAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'icon' &&
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
const getKnownDurationForSource = useCallback(
|
|
(source?: string) => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return null;
|
|
|
|
const resolvedDuration = resolvedDurationBySource[normalizedSource];
|
|
if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) {
|
|
return Number(resolvedDuration);
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[resolvedDurationBySource],
|
|
);
|
|
|
|
const durationProbeTargets = useMemo<
|
|
Array<{ source: string; mediaType: 'video' | 'audio' }>
|
|
>(() => {
|
|
const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = [];
|
|
|
|
if (backgroundVideoUrl) {
|
|
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
if (backgroundAudioUrl) {
|
|
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
|
|
}
|
|
|
|
if (
|
|
selectedElement &&
|
|
(selectedElement.type === 'video_player' ||
|
|
selectedElement.type === 'audio_player') &&
|
|
selectedElement.mediaUrl
|
|
) {
|
|
targets.push({
|
|
source: selectedElement.mediaUrl,
|
|
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
|
|
});
|
|
}
|
|
|
|
if (newTransitionVideoUrl) {
|
|
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
elements.forEach((element) => {
|
|
if (!isNavigationElementType(element.type)) return;
|
|
if (element.transitionVideoUrl) {
|
|
targets.push({ source: element.transitionVideoUrl, mediaType: 'video' });
|
|
}
|
|
if (element.reverseVideoUrl) {
|
|
targets.push({ source: element.reverseVideoUrl, mediaType: 'video' });
|
|
}
|
|
});
|
|
|
|
return targets;
|
|
}, [
|
|
backgroundAudioUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
newTransitionVideoUrl,
|
|
selectedElement,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
let isCancelled = false;
|
|
|
|
durationProbeTargets.forEach(({ source, mediaType }) => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return;
|
|
|
|
if (getKnownDurationForSource(normalizedSource)) return;
|
|
|
|
const probeKey = `${mediaType}:${normalizedSource}`;
|
|
if (durationProbeInFlightRef.current.has(probeKey)) return;
|
|
durationProbeInFlightRef.current.add(probeKey);
|
|
|
|
resolveDurationWithFallback(normalizedSource, mediaType)
|
|
.then((duration) => {
|
|
if (isCancelled) return;
|
|
setResolvedDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]:
|
|
Number.isFinite(duration) && Number(duration) > 0
|
|
? Number(duration)
|
|
: null,
|
|
}));
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to resolve media duration:', error);
|
|
if (isCancelled) return;
|
|
setResolvedDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]: null,
|
|
}));
|
|
})
|
|
.finally(() => {
|
|
durationProbeInFlightRef.current.delete(probeKey);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, [durationProbeTargets, getKnownDurationForSource]);
|
|
|
|
const backgroundVideoDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)),
|
|
[backgroundVideoUrl, getKnownDurationForSource],
|
|
);
|
|
const backgroundAudioDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)),
|
|
[backgroundAudioUrl, getKnownDurationForSource],
|
|
);
|
|
const selectedMediaDurationNote = useMemo(() => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'video_player' &&
|
|
selectedElement.type !== 'audio_player')
|
|
) {
|
|
return 'Duration: unknown';
|
|
}
|
|
|
|
return formatDurationNote(
|
|
getKnownDurationForSource(selectedElement.mediaUrl || ''),
|
|
);
|
|
}, [getKnownDurationForSource, selectedElement]);
|
|
const newTransitionDurationNote = useMemo(
|
|
() => formatDurationNote(getKnownDurationForSource(newTransitionVideoUrl)),
|
|
[getKnownDurationForSource, newTransitionVideoUrl],
|
|
);
|
|
const selectedTransitionDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
|
|
return formatDurationNote(
|
|
getKnownDurationForSource(selectedElement.transitionVideoUrl || ''),
|
|
);
|
|
}, [getKnownDurationForSource, selectedElement]);
|
|
|
|
useEffect(() => {
|
|
if (newTransitionVideoUrl) return;
|
|
if (!transitionVideoAssetOptions.length) return;
|
|
setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value);
|
|
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
|
|
|
|
useEffect(() => {
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const next = prev.map((element) => {
|
|
if (!isNavigationElementType(element.type)) return element;
|
|
|
|
const resolvedDuration = getKnownDurationForSource(
|
|
element.transitionVideoUrl || '',
|
|
);
|
|
const nextDuration =
|
|
Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0
|
|
? Number(resolvedDuration)
|
|
: undefined;
|
|
if (element.transitionDurationSec === nextDuration) return element;
|
|
|
|
hasChanges = true;
|
|
return {
|
|
...element,
|
|
transitionDurationSec: nextDuration,
|
|
};
|
|
});
|
|
|
|
return hasChanges ? next : prev;
|
|
});
|
|
}, [getKnownDurationForSource]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!projectId || !router.isReady || !isAuthReady) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const [projectResponse, pagesResponse, assetsResponse, uiElementsResponse] =
|
|
await Promise.all([
|
|
axios.get(`/projects/${projectId}`),
|
|
axios.get(
|
|
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}`,
|
|
),
|
|
axios.get(
|
|
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
|
|
),
|
|
axios.get('/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true'),
|
|
]);
|
|
|
|
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
|
|
? pagesResponse.data.rows
|
|
: [];
|
|
const assetRows: ProjectAsset[] = Array.isArray(
|
|
assetsResponse?.data?.rows,
|
|
)
|
|
? assetsResponse.data.rows
|
|
: [];
|
|
setProjectName(projectResponse?.data?.name || '');
|
|
setPages(pageRows);
|
|
setAssets(assetRows);
|
|
|
|
const uiElementRows: UiElementDefault[] = Array.isArray(
|
|
uiElementsResponse?.data?.rows,
|
|
)
|
|
? uiElementsResponse.data.rows
|
|
: [];
|
|
const defaultsByType: Partial<
|
|
Record<CanvasElementType, Partial<CanvasElement>>
|
|
> = {};
|
|
uiElementRows.forEach((row) => {
|
|
const elementType = String(row.element_type || '').trim();
|
|
if (!isCanvasElementType(elementType)) return;
|
|
const rawDefaults = parseJsonObject<Partial<CanvasElement>>(
|
|
row.default_settings_json,
|
|
{},
|
|
);
|
|
defaultsByType[elementType] = rawDefaults;
|
|
});
|
|
setUiElementDefaultsByType(defaultsByType);
|
|
|
|
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
|
|
setActivePageId(defaultPageId);
|
|
setIsMenuOpen(false);
|
|
} catch (error: any) {
|
|
if (error?.response?.status === 401) {
|
|
const message = 'Your session has expired. Please sign in again.';
|
|
console.error('Unauthorized constructor request:', error);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to load constructor data.';
|
|
console.error('Failed to load constructor data:', error);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
setUiElementDefaultsByType({});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [isAuthReady, pageIdFromRoute, projectId, router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof window === 'undefined') return;
|
|
|
|
const token =
|
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (!token) {
|
|
setIsAuthReady(false);
|
|
setErrorMessage('Please sign in to continue.');
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
setIsAuthReady(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) return;
|
|
if (projectId) return;
|
|
router.replace(
|
|
isElementEditMode
|
|
? '/page_elements/page_elements-list'
|
|
: '/projects/projects-list',
|
|
);
|
|
}, [isElementEditMode, projectId, router]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
setConstructorControlsPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: 20,
|
|
y: 20,
|
|
};
|
|
});
|
|
|
|
setMenuPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: Math.max(window.innerWidth - 280, 20),
|
|
y: prev.y,
|
|
};
|
|
});
|
|
|
|
setEditorPosition((prev) => {
|
|
if (prev.x > 0) return prev;
|
|
return {
|
|
x: Math.max(window.innerWidth - 400, 20),
|
|
y: prev.y,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || !isAuthReady || isLoading) return;
|
|
if (didSetInitialCanvasFocus.current) return;
|
|
if (!canvasRef.current) return;
|
|
|
|
didSetInitialCanvasFocus.current = true;
|
|
requestAnimationFrame(() => {
|
|
canvasRef.current?.focus({ preventScroll: true });
|
|
});
|
|
}, [isAuthReady, isLoading, router.isReady]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (isLoading || !activePageId) {
|
|
setCanvasElapsedSec(0);
|
|
return;
|
|
}
|
|
|
|
pagePlaybackStartedAtRef.current = Date.now();
|
|
setCanvasElapsedSec(0);
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
const elapsed =
|
|
(Date.now() - pagePlaybackStartedAtRef.current) / 1000;
|
|
setCanvasElapsedSec(elapsed > 0 ? elapsed : 0);
|
|
}, 100);
|
|
|
|
return () => window.clearInterval(intervalId);
|
|
}, [activePageId, isLoading]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const targetSet = new Set(iconPreloadTargets);
|
|
const nextPreloaded = new Set<string>();
|
|
preloadedIconUrlsRef.current.forEach((url) => {
|
|
if (targetSet.has(url)) nextPreloaded.add(url);
|
|
});
|
|
preloadedIconUrlsRef.current = nextPreloaded;
|
|
setPreloadedIconUrlMap(() => {
|
|
const nextMap: Record<string, boolean> = {};
|
|
nextPreloaded.forEach((url) => {
|
|
nextMap[url] = true;
|
|
});
|
|
return nextMap;
|
|
});
|
|
|
|
if (!iconPreloadTargets.length) return;
|
|
|
|
let isCancelled = false;
|
|
const preloadImages: HTMLImageElement[] = [];
|
|
|
|
iconPreloadTargets.forEach((url) => {
|
|
if (preloadedIconUrlsRef.current.has(url)) return;
|
|
|
|
const image = new Image();
|
|
const markReady = () => {
|
|
if (isCancelled) return;
|
|
preloadedIconUrlsRef.current.add(url);
|
|
setPreloadedIconUrlMap((prev) => {
|
|
if (prev[url]) return prev;
|
|
return {
|
|
...prev,
|
|
[url]: true,
|
|
};
|
|
});
|
|
};
|
|
|
|
image.onload = markReady;
|
|
image.onerror = () => {
|
|
console.error('Failed to preload icon asset:', url);
|
|
markReady();
|
|
};
|
|
image.src = url;
|
|
preloadImages.push(image);
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
preloadImages.forEach((image) => {
|
|
image.onload = null;
|
|
image.onerror = null;
|
|
});
|
|
};
|
|
}, [iconPreloadTargets]);
|
|
|
|
useEffect(() => {
|
|
if (!activePage) {
|
|
setElements([]);
|
|
setSelectedElementId('');
|
|
setBackgroundImageUrl('');
|
|
setBackgroundVideoUrl('');
|
|
setBackgroundAudioUrl('');
|
|
return;
|
|
}
|
|
|
|
const schema = parseJsonObject<ConstructorSchema>(
|
|
activePage.ui_schema_json,
|
|
{},
|
|
);
|
|
const normalizedElements = Array.isArray(schema.elements)
|
|
? schema.elements
|
|
.filter(
|
|
(item) =>
|
|
item && item.type && labelByType[item.type as CanvasElementType],
|
|
)
|
|
.map((item) => {
|
|
const elementType = item.type as CanvasElementType;
|
|
const normalizedElement: CanvasElement = {
|
|
...item,
|
|
id: String(item.id || createLocalId()),
|
|
label:
|
|
typeof item.label === 'string' && item.label.trim()
|
|
? item.label
|
|
: labelByType[elementType],
|
|
xPercent: clamp(Number(item.xPercent || 0), 0, 100),
|
|
yPercent: clamp(Number(item.yPercent || 0), 0, 100),
|
|
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
|
|
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
|
|
galleryCards: Array.isArray(item.galleryCards)
|
|
? item.galleryCards.map((card: any, index: number) => ({
|
|
id: String(card?.id || createLocalId()),
|
|
imageUrl: String(card?.imageUrl || ''),
|
|
title: String(card?.title || `Card ${index + 1}`),
|
|
description: String(card?.description || ''),
|
|
}))
|
|
: undefined,
|
|
carouselSlides: Array.isArray(item.carouselSlides)
|
|
? item.carouselSlides.map((slide: any, index: number) => ({
|
|
id: String(slide?.id || createLocalId()),
|
|
imageUrl: String(slide?.imageUrl || ''),
|
|
caption: String(slide?.caption || `Slide ${index + 1}`),
|
|
}))
|
|
: undefined,
|
|
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
|
|
carouselPrevIconUrl:
|
|
typeof item.carouselPrevIconUrl === 'string'
|
|
? item.carouselPrevIconUrl
|
|
: '',
|
|
carouselNextIconUrl:
|
|
typeof item.carouselNextIconUrl === 'string'
|
|
? item.carouselNextIconUrl
|
|
: '',
|
|
tooltipTitle:
|
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
tooltipText:
|
|
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
descriptionTitle:
|
|
typeof item.descriptionTitle === 'string'
|
|
? item.descriptionTitle
|
|
: '',
|
|
descriptionText:
|
|
typeof item.descriptionText === 'string'
|
|
? item.descriptionText
|
|
: '',
|
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
|
navType:
|
|
item.navType === 'back' || item.navType === 'forward'
|
|
? item.navType
|
|
: isNavigationElementType(elementType)
|
|
? getNavigationButtonKind(elementType as NavigationElementType)
|
|
: undefined,
|
|
targetPageId:
|
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
|
transitionVideoUrl:
|
|
typeof item.transitionVideoUrl === 'string'
|
|
? item.transitionVideoUrl
|
|
: '',
|
|
transitionReverseMode:
|
|
item.transitionReverseMode === 'separate_video'
|
|
? 'separate_video'
|
|
: ('auto_reverse' as const),
|
|
reverseVideoUrl:
|
|
typeof item.reverseVideoUrl === 'string'
|
|
? item.reverseVideoUrl
|
|
: '',
|
|
transitionDurationSec: item.transitionDurationSec
|
|
? Number(item.transitionDurationSec)
|
|
: undefined,
|
|
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
|
|
mediaAutoplay:
|
|
typeof item.mediaAutoplay === 'boolean'
|
|
? item.mediaAutoplay
|
|
: true,
|
|
mediaLoop:
|
|
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
|
|
mediaMuted:
|
|
typeof item.mediaMuted === 'boolean'
|
|
? item.mediaMuted
|
|
: item.type === 'video_player',
|
|
};
|
|
|
|
return mergeElementWithDefaults(
|
|
normalizedElement,
|
|
uiElementDefaultsByType[elementType],
|
|
{ preferElementValues: true },
|
|
);
|
|
})
|
|
: [];
|
|
|
|
setElements(normalizedElements);
|
|
setSelectedMenuItem('none');
|
|
setSelectedElementId((current) => {
|
|
if (!normalizedElements.length) return '';
|
|
if (
|
|
elementIdFromRoute &&
|
|
normalizedElements.some((element) => element.id === elementIdFromRoute)
|
|
) {
|
|
return elementIdFromRoute;
|
|
}
|
|
if (normalizedElements.some((element) => element.id === current))
|
|
return current;
|
|
return '';
|
|
});
|
|
setBackgroundImageUrl(activePage.background_image_url || '');
|
|
setBackgroundVideoUrl(activePage.background_video_url || '');
|
|
setBackgroundAudioUrl(activePage.background_audio_url || '');
|
|
}, [activePage, elementIdFromRoute, uiElementDefaultsByType]);
|
|
|
|
useEffect(() => {
|
|
if (allowedNavigationTypes.length !== 1) return;
|
|
const forcedType = allowedNavigationTypes[0];
|
|
|
|
setElements((prev) => {
|
|
let hasChanges = false;
|
|
const nextElements = prev.map((element) => {
|
|
if (!isNavigationElementType(element.type) || element.type === forcedType)
|
|
return element;
|
|
hasChanges = true;
|
|
return normalizeNavigationElementType(element, forcedType);
|
|
});
|
|
return hasChanges ? nextElements : prev;
|
|
});
|
|
}, [allowedNavigationTypes, normalizeNavigationElementType]);
|
|
|
|
useEffect(() => {
|
|
const onPointerMove = (event: MouseEvent) => {
|
|
if (constructorControlsDragRef.current) {
|
|
const maxX = Math.max(window.innerWidth - 460, 0);
|
|
const maxY = Math.max(window.innerHeight - 64, 0);
|
|
const nextX = clamp(
|
|
event.clientX - constructorControlsDragRef.current.pointerOffsetX,
|
|
0,
|
|
maxX,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - constructorControlsDragRef.current.pointerOffsetY,
|
|
0,
|
|
maxY,
|
|
);
|
|
setConstructorControlsPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (menuDragRef.current) {
|
|
const nextX = clamp(
|
|
event.clientX - menuDragRef.current.pointerOffsetX,
|
|
0,
|
|
window.innerWidth - 240,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - menuDragRef.current.pointerOffsetY,
|
|
0,
|
|
window.innerHeight - 60,
|
|
);
|
|
setMenuPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (editorDragRef.current) {
|
|
const editorWidth = isEditorCollapsed ? 260 : 380;
|
|
const nextX = clamp(
|
|
event.clientX - editorDragRef.current.pointerOffsetX,
|
|
0,
|
|
window.innerWidth - editorWidth,
|
|
);
|
|
const nextY = clamp(
|
|
event.clientY - editorDragRef.current.pointerOffsetY,
|
|
0,
|
|
window.innerHeight - 60,
|
|
);
|
|
setEditorPosition({ x: nextX, y: nextY });
|
|
return;
|
|
}
|
|
|
|
if (!elementDragRef.current || !canvasRef.current) return;
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const rawX =
|
|
event.clientX - rect.left - elementDragRef.current.pointerOffsetX;
|
|
const rawY =
|
|
event.clientY - rect.top - elementDragRef.current.pointerOffsetY;
|
|
const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100);
|
|
const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100);
|
|
|
|
setElements((prev) =>
|
|
prev.map((item) =>
|
|
item.id === elementDragRef.current?.id
|
|
? { ...item, xPercent: nextXPercent, yPercent: nextYPercent }
|
|
: item,
|
|
),
|
|
);
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
constructorControlsDragRef.current = null;
|
|
menuDragRef.current = null;
|
|
editorDragRef.current = null;
|
|
elementDragRef.current = null;
|
|
};
|
|
|
|
window.addEventListener('mousemove', onPointerMove);
|
|
window.addEventListener('mouseup', onPointerUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', onPointerMove);
|
|
window.removeEventListener('mouseup', onPointerUp);
|
|
};
|
|
}, [isEditorCollapsed]);
|
|
|
|
useEffect(() => {
|
|
if (isConstructorEditMode) return;
|
|
elementDragRef.current = null;
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
}, [isConstructorEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (!isConstructorEditMode) return;
|
|
if (!selectedElementId && selectedMenuItem === 'none') return;
|
|
|
|
const onOutsideMouseDown = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) return;
|
|
if (elementEditorRef.current?.contains(target)) return;
|
|
const clickedElementId = target
|
|
.closest('[data-constructor-element-id]')
|
|
?.getAttribute('data-constructor-element-id');
|
|
if (
|
|
selectedElementId &&
|
|
clickedElementId &&
|
|
clickedElementId === selectedElementId
|
|
)
|
|
return;
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
};
|
|
|
|
window.addEventListener('mousedown', onOutsideMouseDown);
|
|
return () => window.removeEventListener('mousedown', onOutsideMouseDown);
|
|
}, [isConstructorEditMode, selectedElementId, selectedMenuItem]);
|
|
|
|
const selectElementForEdit = (elementId: string) => {
|
|
setSelectedElementId(elementId);
|
|
setSelectedMenuItem('none');
|
|
};
|
|
|
|
const selectMenuItemForEdit = (item: EditorMenuItem) => {
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem(item);
|
|
};
|
|
|
|
const addElement = (type: CanvasElementType) => {
|
|
const nextElementType: CanvasElementType = isNavigationElementType(type)
|
|
? allowedNavigationTypes.includes(type)
|
|
? type
|
|
: allowedNavigationTypes[0]
|
|
: type;
|
|
const baseElement = createDefaultElement(nextElementType, elements.length);
|
|
const nextElement = mergeElementWithDefaults(
|
|
baseElement,
|
|
uiElementDefaultsByType[nextElementType],
|
|
);
|
|
setElements((prev) => [...prev, nextElement]);
|
|
selectElementForEdit(nextElement.id);
|
|
setSuccessMessage('Element added. Drag it to set position.');
|
|
setErrorMessage('');
|
|
};
|
|
|
|
const createPage = useCallback(async () => {
|
|
if (!projectId) {
|
|
setErrorMessage('Project is required.');
|
|
return;
|
|
}
|
|
|
|
const maxSortOrder = Math.max(
|
|
0,
|
|
...pages.map((item) => Number(item.sort_order || 0)),
|
|
);
|
|
const nextPageNumber = pages.length + 1;
|
|
|
|
const payload = {
|
|
project: projectId,
|
|
environment: activePage?.environment || 'dev',
|
|
source_key: '',
|
|
name: `Page ${nextPageNumber}`,
|
|
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
|
|
sort_order: maxSortOrder + 1,
|
|
background_image_url: '',
|
|
background_video_url: '',
|
|
background_audio_url: '',
|
|
background_loop: false,
|
|
requires_auth: false,
|
|
ui_schema_json: JSON.stringify({ elements: [] }),
|
|
};
|
|
|
|
try {
|
|
setIsCreatingPage(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
const response = await axios.post('/tour_pages', { data: payload });
|
|
const createdPage = response?.data;
|
|
|
|
await loadData();
|
|
|
|
if (createdPage?.id) {
|
|
setActivePageId(createdPage.id);
|
|
}
|
|
|
|
setIsMenuOpen(true);
|
|
setSuccessMessage(
|
|
'New page created. You can now configure it in constructor.',
|
|
);
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to create page.';
|
|
console.error('Failed to create page from constructor:', error);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsCreatingPage(false);
|
|
}
|
|
}, [activePage?.environment, loadData, pages, projectId]);
|
|
|
|
const createTransition = useCallback(async () => {
|
|
if (!projectId) {
|
|
setErrorMessage('Project is required.');
|
|
return;
|
|
}
|
|
|
|
const sanitizedVideoUrl = String(newTransitionVideoUrl || '').trim();
|
|
if (!sanitizedVideoUrl) {
|
|
setErrorMessage('Select a transition video asset first.');
|
|
return;
|
|
}
|
|
|
|
const sanitizedName =
|
|
String(newTransitionName || '').trim() ||
|
|
`Transition ${Date.now().toString().slice(-4)}`;
|
|
const resolvedDurationSec = getKnownDurationForSource(sanitizedVideoUrl);
|
|
if (!resolvedDurationSec) {
|
|
setErrorMessage(
|
|
'Could not resolve transition video duration yet. Please wait a moment and try again.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCreatingTransition(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const payload = {
|
|
project: projectId,
|
|
environment: activePage?.environment || 'dev',
|
|
source_key: '',
|
|
name: sanitizedName,
|
|
slug: `transition-${createLocalId()}`,
|
|
video_url: sanitizedVideoUrl,
|
|
audio_url: '',
|
|
supports_reverse: Boolean(newTransitionSupportsReverse),
|
|
duration_sec: resolvedDurationSec,
|
|
};
|
|
|
|
await axios.post('/transitions', { data: payload });
|
|
setSuccessMessage('Transition created.');
|
|
setNewTransitionName('');
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to create transition.';
|
|
console.error('Failed to create transition from constructor:', error);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsCreatingTransition(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
getKnownDurationForSource,
|
|
newTransitionName,
|
|
newTransitionSupportsReverse,
|
|
newTransitionVideoUrl,
|
|
projectId,
|
|
]);
|
|
|
|
const saveConstructor = useCallback(async () => {
|
|
if (!activePageId) {
|
|
setErrorMessage('Select a page before saving.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const existingSchema = parseJsonObject<Record<string, any>>(
|
|
activePage?.ui_schema_json,
|
|
{},
|
|
);
|
|
const schemaToSave = {
|
|
...existingSchema,
|
|
elements,
|
|
};
|
|
|
|
await axios.put(`/tour_pages/${activePageId}`, {
|
|
id: activePageId,
|
|
data: {
|
|
environment: activePage?.environment,
|
|
source_key: activePage?.source_key,
|
|
name: activePage?.name,
|
|
slug: activePage?.slug,
|
|
sort_order: activePage?.sort_order,
|
|
requires_auth: activePage?.requires_auth,
|
|
ui_schema_json: schemaToSave,
|
|
background_image_url: backgroundImageUrl,
|
|
background_video_url: backgroundVideoUrl,
|
|
background_audio_url: backgroundAudioUrl,
|
|
background_loop: Boolean(backgroundAudioUrl),
|
|
},
|
|
});
|
|
|
|
setSuccessMessage(
|
|
'Constructor settings saved. Element positions are stored in percentages.',
|
|
);
|
|
await loadData();
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to save constructor changes.';
|
|
console.error('Failed to save constructor changes:', error);
|
|
setErrorMessage(message);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
activePage?.environment,
|
|
activePage?.name,
|
|
activePage?.requires_auth,
|
|
activePage?.slug,
|
|
activePage?.sort_order,
|
|
activePage?.source_key,
|
|
activePage?.ui_schema_json,
|
|
activePageId,
|
|
backgroundAudioUrl,
|
|
backgroundImageUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
loadData,
|
|
]);
|
|
|
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
|
if (!isConstructorEditMode) return;
|
|
event.preventDefault();
|
|
if (!canvasRef.current) return;
|
|
|
|
const currentElement = elements.find((item) => item.id === elementId);
|
|
if (!currentElement) return;
|
|
selectElementForEdit(elementId);
|
|
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const elementLeftPx = (currentElement.xPercent / 100) * rect.width;
|
|
const elementTopPx = (currentElement.yPercent / 100) * rect.height;
|
|
|
|
elementDragRef.current = {
|
|
id: elementId,
|
|
pointerOffsetX: event.clientX - rect.left - elementLeftPx,
|
|
pointerOffsetY: event.clientY - rect.top - elementTopPx,
|
|
};
|
|
};
|
|
|
|
const preventImageDragStart = (event: React.DragEvent<HTMLImageElement>) => {
|
|
event.preventDefault();
|
|
};
|
|
|
|
const updateSelectedElement = (patch: Partial<CanvasElement>) => {
|
|
if (!selectedElementId) return;
|
|
setElements((prev) =>
|
|
prev.map((item) =>
|
|
item.id === selectedElementId ? { ...item, ...patch } : item,
|
|
),
|
|
);
|
|
};
|
|
|
|
const removeSelectedElement = () => {
|
|
if (!selectedElementId) return;
|
|
|
|
let nextSelectedId = '';
|
|
setElements((prev) => {
|
|
const filtered = prev.filter((item) => item.id !== selectedElementId);
|
|
nextSelectedId = filtered[0]?.id || '';
|
|
return filtered;
|
|
});
|
|
if (nextSelectedId) {
|
|
selectElementForEdit(nextSelectedId);
|
|
} else {
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
}
|
|
setSuccessMessage('Element removed.');
|
|
};
|
|
|
|
const updateGalleryCard = (cardId: string, patch: Partial<GalleryCard>) => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = (selectedElement.galleryCards || []).map((card) =>
|
|
card.id === cardId ? { ...card, ...patch } : card,
|
|
);
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const addGalleryCard = () => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = [
|
|
...(selectedElement.galleryCards || []),
|
|
{
|
|
id: createLocalId(),
|
|
imageUrl: '',
|
|
title: `Card ${(selectedElement.galleryCards || []).length + 1}`,
|
|
description: '',
|
|
},
|
|
];
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const removeGalleryCard = (cardId: string) => {
|
|
if (!selectedElement || selectedElement.type !== 'gallery') return;
|
|
const nextCards = (selectedElement.galleryCards || []).filter(
|
|
(card) => card.id !== cardId,
|
|
);
|
|
updateSelectedElement({ galleryCards: nextCards });
|
|
};
|
|
|
|
const updateCarouselSlide = (
|
|
slideId: string,
|
|
patch: Partial<CarouselSlide>,
|
|
) => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = (selectedElement.carouselSlides || []).map((slide) =>
|
|
slide.id === slideId ? { ...slide, ...patch } : slide,
|
|
);
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const addCarouselSlide = () => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = [
|
|
...(selectedElement.carouselSlides || []),
|
|
{
|
|
id: createLocalId(),
|
|
imageUrl: '',
|
|
caption: `Slide ${(selectedElement.carouselSlides || []).length + 1}`,
|
|
},
|
|
];
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const removeCarouselSlide = (slideId: string) => {
|
|
if (!selectedElement || selectedElement.type !== 'carousel') return;
|
|
const nextSlides = (selectedElement.carouselSlides || []).filter(
|
|
(slide) => slide.id !== slideId,
|
|
);
|
|
updateSelectedElement({ carouselSlides: nextSlides });
|
|
};
|
|
|
|
const openTransitionPreviewForElement = (
|
|
element: CanvasElement,
|
|
direction: 'forward' | 'back',
|
|
) => {
|
|
if (!isNavigationElementType(element.type)) return;
|
|
|
|
if (!element.transitionVideoUrl) {
|
|
setErrorMessage(
|
|
'Select transition video asset to preview transition playback.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
direction === 'back' &&
|
|
element.transitionReverseMode === 'separate_video' &&
|
|
!element.reverseVideoUrl
|
|
) {
|
|
setErrorMessage(
|
|
'Select back-transition asset or switch reverse mode to Auto Reverse.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
setTransitionPreview({
|
|
videoUrl: element.transitionVideoUrl,
|
|
reverseMode:
|
|
direction === 'forward'
|
|
? 'none'
|
|
: element.transitionReverseMode === 'separate_video'
|
|
? 'separate'
|
|
: 'reverse',
|
|
reverseVideoUrl: element.reverseVideoUrl,
|
|
durationSec: element.transitionDurationSec,
|
|
title: `${element.navLabel || element.label} · ${direction}`,
|
|
});
|
|
};
|
|
|
|
const openTransitionPreview = (direction: 'forward' | 'back') => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'navigation_next' &&
|
|
selectedElement.type !== 'navigation_prev')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
openTransitionPreviewForElement(selectedElement, direction);
|
|
};
|
|
|
|
const onCanvasElementClick = (element: CanvasElement) => {
|
|
if (!isConstructorEditMode) {
|
|
if (isNavigationElementType(element.type)) {
|
|
const direction =
|
|
element.navType === 'back' || element.type === 'navigation_prev'
|
|
? 'back'
|
|
: 'forward';
|
|
const configuredTargetId = String(element.targetPageId || '').trim();
|
|
const fallbackTargetId = (() => {
|
|
const currentPageIndex = pages.findIndex(
|
|
(page) => page.id === activePageId,
|
|
);
|
|
if (currentPageIndex < 0) return '';
|
|
|
|
const nextPageIndex =
|
|
direction === 'back' ? currentPageIndex - 1 : currentPageIndex + 1;
|
|
const nextPage = pages[nextPageIndex];
|
|
return nextPage ? String(nextPage.id || '').trim() : '';
|
|
})();
|
|
const targetPageId = configuredTargetId || fallbackTargetId;
|
|
|
|
if (!targetPageId) {
|
|
setErrorMessage('No target page available for this navigation button.');
|
|
return;
|
|
}
|
|
|
|
const hasPlayableTransition =
|
|
Boolean(element.transitionVideoUrl) &&
|
|
!(
|
|
direction === 'back' &&
|
|
element.transitionReverseMode === 'separate_video' &&
|
|
!element.reverseVideoUrl
|
|
);
|
|
|
|
if (!hasPlayableTransition) {
|
|
setPendingNavigationPageId('');
|
|
setTransitionPreview(null);
|
|
setActivePageId(targetPageId);
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
return;
|
|
}
|
|
|
|
setPendingNavigationPageId(targetPageId);
|
|
openTransitionPreviewForElement(element, direction);
|
|
}
|
|
return;
|
|
}
|
|
|
|
selectElementForEdit(element.id);
|
|
};
|
|
|
|
const onMenuDragStart = (event: React.MouseEvent) => {
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
menuDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const onConstructorControlsDragStart = (event: React.MouseEvent) => {
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
constructorControlsDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const onElementEditorDragStart = (event: React.MouseEvent) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('button')) return;
|
|
|
|
const targetRect = (
|
|
event.currentTarget as HTMLElement
|
|
).getBoundingClientRect();
|
|
editorDragRef.current = {
|
|
pointerOffsetX: event.clientX - targetRect.left,
|
|
pointerOffsetY: event.clientY - targetRect.top,
|
|
};
|
|
};
|
|
|
|
const renderCanvasElementContent = (element: CanvasElement) => {
|
|
if (
|
|
element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev'
|
|
) {
|
|
const fallbackNavLabel = getNavigationButtonLabel(element.type);
|
|
const navigationLabel = element.navLabel?.trim() || fallbackNavLabel;
|
|
if (element.iconUrl) {
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Navigation icon'
|
|
className='block h-full w-full object-contain'
|
|
draggable={false}
|
|
onDragStart={preventImageDragStart}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const targetPageName = element.targetPageId
|
|
? pageNameById[element.targetPageId]
|
|
: '';
|
|
return (
|
|
<div className='flex flex-col items-start gap-1'>
|
|
<div className='flex items-center gap-2'>
|
|
<span>{navigationLabel}</span>
|
|
</div>
|
|
{targetPageName ? (
|
|
<span className='text-[10px] text-gray-500'>
|
|
To: {targetPageName}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'tooltip') {
|
|
if (element.iconUrl) {
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Tooltip icon'
|
|
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
|
|
draggable={false}
|
|
onDragStart={preventImageDragStart}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='max-w-[200px] text-left'>
|
|
<p className='text-[11px] font-bold'>
|
|
{element.tooltipTitle}
|
|
</p>
|
|
<p className='text-[10px] text-gray-600 line-clamp-3'>
|
|
{element.tooltipText || 'Tooltip text'}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'description') {
|
|
if (element.iconUrl) {
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
|
alt='Description icon'
|
|
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
|
|
draggable={false}
|
|
onDragStart={preventImageDragStart}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
|
return (
|
|
<div
|
|
className='max-w-[220px] text-left p-2 rounded'
|
|
style={{ backgroundColor: bgColor }}
|
|
>
|
|
<p
|
|
className='font-bold'
|
|
style={{
|
|
fontSize: element.descriptionTitleFontSize
|
|
? `calc(${element.descriptionTitleFontSize} * 0.25)`
|
|
: '12px',
|
|
}}
|
|
>
|
|
{element.descriptionTitle || 'TITLE'}
|
|
</p>
|
|
{element.descriptionText && (
|
|
<p
|
|
className='text-gray-600 line-clamp-4'
|
|
style={{
|
|
fontSize: element.descriptionTextFontSize
|
|
? `calc(${element.descriptionTextFontSize} * 0.25)`
|
|
: '9px',
|
|
}}
|
|
>
|
|
{element.descriptionText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'gallery') {
|
|
const cards = element.galleryCards || [];
|
|
return (
|
|
<div className='w-[220px]'>
|
|
<p className='mb-1 text-left text-[10px] font-semibold text-gray-600'>
|
|
Gallery ({cards.length})
|
|
</p>
|
|
<div className='grid grid-cols-3 gap-1'>
|
|
{cards.slice(0, 6).map((card) => (
|
|
<div
|
|
key={card.id}
|
|
className='h-12 overflow-hidden rounded bg-gray-100'
|
|
>
|
|
{card.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
|
alt={card.title || 'Gallery card'}
|
|
className='h-full w-full object-cover'
|
|
/>
|
|
) : (
|
|
<div className='flex h-full items-center justify-center text-[9px] text-gray-400'>
|
|
No image
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'carousel') {
|
|
const firstSlide = (element.carouselSlides || [])[0];
|
|
return (
|
|
<div className='w-[220px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Carousel ({element.carouselSlides?.length || 0})
|
|
</p>
|
|
<div className='h-20 overflow-hidden rounded bg-gray-100'>
|
|
{firstSlide?.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
|
alt={firstSlide.caption || 'Carousel slide'}
|
|
className='h-full w-full object-cover'
|
|
/>
|
|
) : (
|
|
<div className='flex h-full items-center justify-center text-[10px] text-gray-400'>
|
|
No slide image
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className='mt-1 text-[10px] text-gray-600 line-clamp-1'>
|
|
{firstSlide?.caption || 'No caption'}
|
|
</p>
|
|
{(element.carouselPrevIconUrl || element.carouselNextIconUrl) && (
|
|
<div className='mt-1 flex items-center justify-between text-[9px] text-gray-500'>
|
|
<span className='flex items-center gap-1'>
|
|
{element.carouselPrevIconUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
|
|
alt='Previous icon'
|
|
className='h-3 w-3 object-contain'
|
|
/>
|
|
) : null}
|
|
Prev
|
|
</span>
|
|
<span className='flex items-center gap-1'>
|
|
Next
|
|
{element.carouselNextIconUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
|
|
alt='Next icon'
|
|
className='h-3 w-3 object-contain'
|
|
/>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'video_player') {
|
|
return (
|
|
<div className='w-[220px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Video player
|
|
</p>
|
|
<video
|
|
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`}
|
|
className='h-24 w-full rounded bg-black object-cover'
|
|
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
|
controls
|
|
autoPlay={Boolean(element.mediaAutoplay)}
|
|
loop={Boolean(element.mediaLoop)}
|
|
muted={Boolean(element.mediaMuted)}
|
|
playsInline
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (element.type === 'audio_player') {
|
|
return (
|
|
<div className='w-[240px] text-left'>
|
|
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
|
Audio player
|
|
</p>
|
|
<audio
|
|
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
|
|
className='w-full'
|
|
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
|
controls
|
|
autoPlay={Boolean(element.mediaAutoplay)}
|
|
loop={Boolean(element.mediaLoop)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return getElementButtonTitle(element);
|
|
};
|
|
|
|
const isElementVisibleOnCanvas = (element: CanvasElement) => {
|
|
const delay = Number(element.appearDelaySec || 0);
|
|
if (canvasElapsedSec < delay) return false;
|
|
|
|
if (element.appearDurationSec === null || element.appearDurationSec === undefined) {
|
|
return true;
|
|
}
|
|
|
|
const duration = Number(element.appearDurationSec);
|
|
if (!Number.isFinite(duration) || duration <= 0) return true;
|
|
|
|
return canvasElapsedSec <= delay + duration;
|
|
};
|
|
|
|
const isElementReadyForCanvasRender = (element: CanvasElement) => {
|
|
const isPreloadableIconElement =
|
|
(element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev' ||
|
|
element.type === 'tooltip' ||
|
|
element.type === 'description') &&
|
|
Boolean(element.iconUrl);
|
|
|
|
if (!isPreloadableIconElement) return true;
|
|
|
|
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
|
|
if (!playbackUrl) return true;
|
|
|
|
return Boolean(preloadedIconUrlMap[playbackUrl]);
|
|
};
|
|
|
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
|
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
|
|
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
|
|
const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl);
|
|
const backgroundImageSelectOptions = addFallbackAssetOption(
|
|
backgroundImageAssetOptions,
|
|
backgroundImageUrl,
|
|
`Current image · ${backgroundImageUrl}`,
|
|
);
|
|
const backgroundVideoSelectOptions = addFallbackAssetOption(
|
|
videoAssetOptions,
|
|
backgroundVideoUrl,
|
|
`Current video · ${backgroundVideoUrl}`,
|
|
);
|
|
const backgroundAudioSelectOptions = addFallbackAssetOption(
|
|
audioAssetOptions,
|
|
backgroundAudioUrl,
|
|
`Current audio · ${backgroundAudioUrl}`,
|
|
);
|
|
const hasEditorSelection =
|
|
isConstructorEditMode &&
|
|
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
|
const editorTitle =
|
|
selectedMenuItem === 'background_image'
|
|
? 'Background image'
|
|
: selectedMenuItem === 'background_video'
|
|
? 'Background video'
|
|
: selectedMenuItem === 'background_audio'
|
|
? 'Background audio'
|
|
: selectedMenuItem === 'create_transition'
|
|
? 'Create transition'
|
|
: selectedElement?.label || 'Element editor';
|
|
|
|
if (backgroundImageSrc) {
|
|
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
|
|
canvasBackgroundStyle.backgroundSize = 'cover';
|
|
canvasBackgroundStyle.backgroundPosition = 'center';
|
|
}
|
|
|
|
useEffect(() => {
|
|
const video = transitionVideoRef.current;
|
|
if (!transitionPreview || !video) return;
|
|
|
|
let startWatchdogTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let finishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let hardTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let previewBlobUrl: string | null = null;
|
|
let didFinish = false;
|
|
let didStartPlayback = false;
|
|
|
|
const sourceCandidateRaw =
|
|
transitionPreview.reverseMode === 'separate'
|
|
? transitionPreview.reverseVideoUrl || ''
|
|
: transitionPreview.videoUrl;
|
|
const sourceUrl = resolveAssetPlaybackUrl(sourceCandidateRaw);
|
|
|
|
const cleanupReverseFrame = () => {
|
|
if (reverseAnimationFrame.current !== null) {
|
|
cancelAnimationFrame(reverseAnimationFrame.current);
|
|
reverseAnimationFrame.current = null;
|
|
}
|
|
};
|
|
|
|
const clearTimers = () => {
|
|
if (startWatchdogTimer) clearTimeout(startWatchdogTimer);
|
|
if (finishTimer) clearTimeout(finishTimer);
|
|
if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer);
|
|
startWatchdogTimer = null;
|
|
finishTimer = null;
|
|
hardTimeoutTimer = null;
|
|
};
|
|
|
|
const cleanupPreviewBlobUrl = () => {
|
|
if (!previewBlobUrl) return;
|
|
URL.revokeObjectURL(previewBlobUrl);
|
|
previewBlobUrl = null;
|
|
};
|
|
|
|
const shouldLoadTransitionViaBlob = (candidateUrl: string) => {
|
|
try {
|
|
const parsedUrl = new URL(candidateUrl, window.location.origin);
|
|
const isSameOrigin = parsedUrl.origin === window.location.origin;
|
|
if (!isSameOrigin) return false;
|
|
return (
|
|
parsedUrl.pathname === '/api/file/download' ||
|
|
parsedUrl.pathname === '/file/download'
|
|
);
|
|
} catch (error) {
|
|
console.error('Transition preview URL parsing failed:', {
|
|
candidateUrl,
|
|
error,
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const buildBlobRequestUrl = (candidateUrl: string) => {
|
|
if (candidateUrl.startsWith('/api/')) {
|
|
return candidateUrl.replace(/^\/api(?=\/)/, '');
|
|
}
|
|
return candidateUrl;
|
|
};
|
|
|
|
const resolvePlayableTransitionSource = async () => {
|
|
cleanupPreviewBlobUrl();
|
|
|
|
if (!shouldLoadTransitionViaBlob(sourceUrl)) {
|
|
return sourceUrl;
|
|
}
|
|
|
|
const token =
|
|
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
|
|
const requestUrl = buildBlobRequestUrl(sourceUrl);
|
|
const response = await axios.get(requestUrl, {
|
|
responseType: 'blob',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
});
|
|
|
|
previewBlobUrl = URL.createObjectURL(response.data);
|
|
return previewBlobUrl;
|
|
};
|
|
|
|
const finishPreview = (reason: string) => {
|
|
if (didFinish) return;
|
|
didFinish = true;
|
|
clearTimers();
|
|
cleanupReverseFrame();
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
cleanupPreviewBlobUrl();
|
|
setTransitionPreview(null);
|
|
setPendingNavigationPageId((pendingPageId) => {
|
|
const nextPageId = String(pendingPageId || '').trim();
|
|
if (nextPageId) {
|
|
setActivePageId(nextPageId);
|
|
setSelectedElementId('');
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
}
|
|
return '';
|
|
});
|
|
console.info('Transition preview finished:', {
|
|
reason,
|
|
src: video.currentSrc || sourceUrl || '',
|
|
});
|
|
};
|
|
|
|
const configuredDurationSec = Number(transitionPreview.durationSec);
|
|
const getMediaErrorDetails = () => {
|
|
if (!video.error) return null;
|
|
const mediaError = video.error as MediaError & { message?: string };
|
|
return {
|
|
code: mediaError.code,
|
|
message: mediaError.message || '',
|
|
};
|
|
};
|
|
|
|
const logTransitionIssue = (reason: string, error?: unknown) => {
|
|
console.error('Transition preview issue:', {
|
|
reason,
|
|
src: video.currentSrc || sourceUrl || '',
|
|
readyState: video.readyState,
|
|
networkState: video.networkState,
|
|
duration: video.duration,
|
|
configuredDurationSec: transitionPreview.durationSec,
|
|
reverseMode: transitionPreview.reverseMode,
|
|
mediaError: getMediaErrorDetails(),
|
|
error,
|
|
});
|
|
};
|
|
|
|
const scheduleFinishByDuration = (durationSec: number) => {
|
|
if (!Number.isFinite(durationSec) || durationSec <= 0 || finishTimer) {
|
|
return;
|
|
}
|
|
finishTimer = setTimeout(() => {
|
|
finishPreview('duration-timer');
|
|
}, durationSec * 1000 + 200);
|
|
};
|
|
|
|
const runReversePreview = () => {
|
|
cleanupReverseFrame();
|
|
const duration =
|
|
Number.isFinite(video.duration) && video.duration > 0
|
|
? video.duration
|
|
: Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
|
|
? configuredDurationSec
|
|
: 0.7;
|
|
const reverseSeconds =
|
|
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
|
|
? configuredDurationSec
|
|
: duration;
|
|
const reverseMs = Math.max(reverseSeconds * 1000, 400);
|
|
const reverseRate = reverseSeconds > 0 ? duration / reverseSeconds : 1;
|
|
const startTime = performance.now();
|
|
video.pause();
|
|
video.currentTime = duration;
|
|
|
|
const step = (now: number) => {
|
|
const elapsed = now - startTime;
|
|
const nextTime = Math.max(duration - (elapsed / 1000) * reverseRate, 0);
|
|
video.currentTime = nextTime;
|
|
|
|
if (elapsed >= reverseMs || nextTime <= 0.001) {
|
|
finishPreview('reverse-complete');
|
|
return;
|
|
}
|
|
|
|
reverseAnimationFrame.current = requestAnimationFrame(step);
|
|
};
|
|
|
|
reverseAnimationFrame.current = requestAnimationFrame(step);
|
|
};
|
|
|
|
const attemptPlay = () => {
|
|
if (transitionPreview.reverseMode === 'reverse') return;
|
|
video
|
|
.play()
|
|
.catch((playError) => {
|
|
logTransitionIssue('play-failed', playError);
|
|
});
|
|
};
|
|
|
|
const loadSourceCandidate = async () => {
|
|
didStartPlayback = false;
|
|
|
|
if (startWatchdogTimer) {
|
|
clearTimeout(startWatchdogTimer);
|
|
}
|
|
|
|
try {
|
|
const playableSourceUrl = await resolvePlayableTransitionSource();
|
|
if (didFinish) return;
|
|
|
|
video.pause();
|
|
cleanupReverseFrame();
|
|
video.src = playableSourceUrl;
|
|
video.currentTime = 0;
|
|
video.load();
|
|
|
|
if (transitionPreview.reverseMode !== 'reverse') {
|
|
attemptPlay();
|
|
}
|
|
|
|
startWatchdogTimer = setTimeout(() => {
|
|
if (didStartPlayback || didFinish) return;
|
|
logTransitionIssue('playback-start-slow');
|
|
attemptPlay();
|
|
}, 12000);
|
|
} catch (error) {
|
|
logTransitionIssue('source-prepare-failed', error);
|
|
finishPreview('source-prepare-failed');
|
|
}
|
|
};
|
|
|
|
if (!sourceUrl) {
|
|
logTransitionIssue('missing-source');
|
|
finishPreview('missing-source');
|
|
return () => {
|
|
cleanupReverseFrame();
|
|
};
|
|
}
|
|
|
|
const onLoadedMetadata = () => {
|
|
if (didFinish) return;
|
|
if (transitionPreview.reverseMode === 'reverse' && !didStartPlayback) {
|
|
didStartPlayback = true;
|
|
if (startWatchdogTimer) {
|
|
clearTimeout(startWatchdogTimer);
|
|
startWatchdogTimer = null;
|
|
}
|
|
runReversePreview();
|
|
return;
|
|
}
|
|
|
|
video.currentTime = 0;
|
|
attemptPlay();
|
|
};
|
|
|
|
const onCanPlay = () => {
|
|
if (didFinish) return;
|
|
attemptPlay();
|
|
};
|
|
|
|
const onPlaying = () => {
|
|
if (didFinish) return;
|
|
didStartPlayback = true;
|
|
if (startWatchdogTimer) {
|
|
clearTimeout(startWatchdogTimer);
|
|
startWatchdogTimer = null;
|
|
}
|
|
|
|
if (transitionPreview.reverseMode !== 'reverse') {
|
|
const mediaDurationSec = Number(video.duration);
|
|
const durationSec =
|
|
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
|
|
? configuredDurationSec
|
|
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
|
|
? mediaDurationSec
|
|
: NaN;
|
|
if (Number.isFinite(durationSec) && durationSec > 0) {
|
|
scheduleFinishByDuration(durationSec);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onEnded = () => finishPreview('ended');
|
|
|
|
const onPlaybackError = (eventName: string, error?: unknown) => {
|
|
if (didFinish) return;
|
|
logTransitionIssue(eventName, error);
|
|
finishPreview(eventName);
|
|
};
|
|
|
|
const onError = () => onPlaybackError('video-error');
|
|
const onAbort = () => onPlaybackError('video-abort');
|
|
const onStalled = () => {
|
|
if (didFinish) return;
|
|
logTransitionIssue('video-stalled');
|
|
};
|
|
|
|
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
video.addEventListener('canplay', onCanPlay);
|
|
video.addEventListener('playing', onPlaying);
|
|
video.addEventListener('ended', onEnded);
|
|
video.addEventListener('error', onError);
|
|
video.addEventListener('abort', onAbort);
|
|
video.addEventListener('stalled', onStalled);
|
|
|
|
hardTimeoutTimer = setTimeout(() => {
|
|
if (didFinish) return;
|
|
logTransitionIssue('hard-timeout');
|
|
finishPreview('hard-timeout');
|
|
}, 45000);
|
|
|
|
void loadSourceCandidate();
|
|
|
|
return () => {
|
|
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
video.removeEventListener('canplay', onCanPlay);
|
|
video.removeEventListener('playing', onPlaying);
|
|
video.removeEventListener('ended', onEnded);
|
|
video.removeEventListener('error', onError);
|
|
video.removeEventListener('abort', onAbort);
|
|
video.removeEventListener('stalled', onStalled);
|
|
clearTimers();
|
|
cleanupReverseFrame();
|
|
cleanupPreviewBlobUrl();
|
|
if (!didFinish) {
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
}
|
|
};
|
|
}, [transitionPreview]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (reverseAnimationFrame.current !== null) {
|
|
cancelAnimationFrame(reverseAnimationFrame.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>
|
|
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
|
|
</title>
|
|
</Head>
|
|
<div className='relative w-screen h-screen bg-white overflow-hidden'>
|
|
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
|
|
<p className='text-xs font-semibold text-gray-700'>
|
|
{projectName || 'Loading project...'}
|
|
</p>
|
|
{errorMessage ? (
|
|
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
{successMessage ? (
|
|
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
|
|
{successMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{pages.length > 0 && isElementEditMode && (
|
|
<div className='flex items-center gap-2'>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Back to Elements'
|
|
icon={mdiExitToApp}
|
|
href={pageElementsListHref}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
icon={mdiContentSave}
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<div
|
|
className='fixed z-40 w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
|
|
style={{
|
|
left: constructorControlsPosition.x,
|
|
top: constructorControlsPosition.y,
|
|
}}
|
|
>
|
|
<div
|
|
className='flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 px-3 py-2'
|
|
onMouseDown={onConstructorControlsDragStart}
|
|
>
|
|
<span className='text-xs font-bold uppercase'>
|
|
Constructor Controls
|
|
</span>
|
|
</div>
|
|
<div className='space-y-2 p-3'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<select
|
|
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
|
|
value={activePageId}
|
|
onChange={(event) => setActivePageId(event.target.value)}
|
|
>
|
|
{pages.map((page, index) => (
|
|
<option key={page.id} value={page.id}>
|
|
{page.name || `Page ${index + 1}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Exit to Assets'
|
|
icon={mdiExitToApp}
|
|
href={
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list'
|
|
}
|
|
/>
|
|
</div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<div className='inline-flex overflow-hidden rounded border border-gray-300 bg-white text-xs font-semibold'>
|
|
<button
|
|
type='button'
|
|
className={`px-3 py-1.5 ${
|
|
isConstructorEditMode
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => setConstructorInteractionMode('edit')}
|
|
>
|
|
Edit mode
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className={`border-l border-gray-300 px-3 py-1.5 ${
|
|
!isConstructorEditMode
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => setConstructorInteractionMode('interact')}
|
|
>
|
|
Interact mode
|
|
</button>
|
|
</div>
|
|
<span className='text-[11px] text-gray-600'>
|
|
{isConstructorEditMode
|
|
? 'Drag & configure elements.'
|
|
: 'Click and interact with rendered elements.'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={canvasRef}
|
|
tabIndex={-1}
|
|
className='absolute inset-0 bg-white overflow-hidden'
|
|
style={canvasBackgroundStyle}
|
|
>
|
|
{backgroundImageSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
key={`bg_image_${backgroundImageSrc}`}
|
|
src={backgroundImageSrc}
|
|
alt='Background'
|
|
className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none'
|
|
/>
|
|
) : null}
|
|
|
|
{backgroundVideoSrc ? (
|
|
<video
|
|
key={`bg_video_${backgroundVideoSrc}`}
|
|
className='absolute inset-0 w-full h-full object-cover'
|
|
src={backgroundVideoSrc}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
/>
|
|
) : null}
|
|
|
|
{backgroundAudioSrc ? (
|
|
<audio
|
|
key={`bg_audio_${backgroundAudioSrc}`}
|
|
src={backgroundAudioSrc}
|
|
autoPlay
|
|
loop
|
|
hidden
|
|
/>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<p className='text-sm text-gray-500'>Loading constructor...</p>
|
|
</div>
|
|
) : pages.length === 0 ? (
|
|
<div className='absolute inset-0 flex items-center justify-center'>
|
|
<BaseButton
|
|
color='info'
|
|
label={isCreatingPage ? 'Creating...' : 'Create First Page'}
|
|
icon={mdiPlus}
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
/>
|
|
</div>
|
|
) : (
|
|
elements.map((element) => {
|
|
const shouldRender =
|
|
selectedElementId === element.id ||
|
|
(isElementVisibleOnCanvas(element) &&
|
|
isElementReadyForCanvasRender(element));
|
|
if (!shouldRender) return null;
|
|
|
|
const hasIconDrivenSize =
|
|
Boolean(element.iconUrl) &&
|
|
(element.type === 'tooltip' ||
|
|
element.type === 'description');
|
|
const isNavigationIconElement =
|
|
Boolean(element.iconUrl) &&
|
|
(element.type === 'navigation_next' ||
|
|
element.type === 'navigation_prev');
|
|
const hasTransparentBackground =
|
|
element.type === 'description' &&
|
|
!element.iconUrl &&
|
|
(!element.descriptionBackgroundColor ||
|
|
element.descriptionBackgroundColor === 'transparent');
|
|
|
|
return (
|
|
<button
|
|
key={element.id}
|
|
type='button'
|
|
data-constructor-element-id={element.id}
|
|
className={`absolute rounded text-xs font-semibold text-left ${
|
|
hasTransparentBackground ? '' : 'border shadow'
|
|
} ${
|
|
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2'
|
|
} ${
|
|
isNavigationIconElement ? 'flex items-center justify-center' : ''
|
|
} ${
|
|
isConstructorEditMode ? 'cursor-move' : 'cursor-pointer'
|
|
} ${
|
|
selectedElementId === element.id
|
|
? 'border-blue-500 bg-blue-50 border shadow'
|
|
: hasTransparentBackground
|
|
? 'bg-transparent'
|
|
: 'border-blue-200 bg-white/95'
|
|
}`}
|
|
style={{
|
|
...buildCanvasElementStyle(element),
|
|
left: `${element.xPercent}%`,
|
|
top: `${element.yPercent}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
onMouseDown={
|
|
isConstructorEditMode
|
|
? (event) => onElementMouseDown(event, element.id)
|
|
: undefined
|
|
}
|
|
onClick={() => onCanvasElementClick(element)}
|
|
>
|
|
{renderCanvasElementContent(element)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && hasEditorSelection && (
|
|
<div
|
|
ref={elementEditorRef}
|
|
className={`fixed z-40 ${isEditorCollapsed ? 'w-[260px]' : 'w-[380px]'} max-h-[calc(100vh-2rem)] overflow-auto rounded-lg border border-gray-200 bg-white/95 p-3 shadow-xl`}
|
|
style={{ left: editorPosition.x, top: editorPosition.y }}
|
|
>
|
|
<div
|
|
className='mb-3 flex items-center justify-between gap-2 cursor-move'
|
|
onMouseDown={onElementEditorDragStart}
|
|
>
|
|
<p className='text-xs font-bold uppercase tracking-wide text-gray-700'>
|
|
{editorTitle}
|
|
</p>
|
|
<div className='flex items-center gap-2'>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-gray-700 hover:underline'
|
|
onClick={() => setIsEditorCollapsed((prev) => !prev)}
|
|
>
|
|
{isEditorCollapsed ? 'Expand' : 'Collapse'}
|
|
</button>
|
|
{selectedElement && (
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={removeSelectedElement}
|
|
>
|
|
Remove element
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!isEditorCollapsed && (
|
|
<>
|
|
{selectedMenuItem === 'background_image' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background image
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundImageUrl}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
setBackgroundImageUrl(nextValue);
|
|
if (nextValue) setBackgroundVideoUrl('');
|
|
}}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundImageSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'background_video' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background video
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundVideoUrl}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
setBackgroundVideoUrl(nextValue);
|
|
if (nextValue) setBackgroundImageUrl('');
|
|
}}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundVideoSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{backgroundVideoDurationNote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'background_audio' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background audio (loop)
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={backgroundAudioUrl}
|
|
onChange={(event) =>
|
|
setBackgroundAudioUrl(event.target.value)
|
|
}
|
|
>
|
|
<option value=''>None</option>
|
|
{backgroundAudioSelectOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{backgroundAudioDurationNote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedMenuItem === 'create_transition' && (
|
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Create next page transition
|
|
</p>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Name'
|
|
value={newTransitionName}
|
|
onChange={(event) =>
|
|
setNewTransitionName(event.target.value)
|
|
}
|
|
/>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={newTransitionVideoUrl}
|
|
onChange={(event) =>
|
|
setNewTransitionVideoUrl(event.target.value)
|
|
}
|
|
>
|
|
<option value=''>Transition video asset</option>
|
|
{transitionVideoAssetOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='text-[11px] text-gray-500'>
|
|
Transition duration is automatic from video metadata.{' '}
|
|
{newTransitionDurationNote}
|
|
</p>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={newTransitionSupportsReverse}
|
|
onChange={(event) =>
|
|
setNewTransitionSupportsReverse(event.target.checked)
|
|
}
|
|
/>
|
|
Supports reverse playback
|
|
</label>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={createTransition}
|
|
disabled={isCreatingTransition}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>
|
|
{isCreatingTransition
|
|
? 'Creating Transition...'
|
|
: 'Create Transition'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && (
|
|
<div className='mb-2 space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Label
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.label}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ label: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Appear delay (sec)
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
type='number'
|
|
min='0'
|
|
step='0.1'
|
|
value={selectedElement.appearDelaySec ?? 0}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
appearDelaySec: normalizeAppearDelaySec(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Appear duration (sec)
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
type='number'
|
|
min='0.1'
|
|
step='0.1'
|
|
placeholder='Unlimited'
|
|
value={selectedElement.appearDurationSec ?? ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
appearDurationSec: normalizeAppearDurationSec(
|
|
event.target.value,
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
Leave empty for unlimited.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement &&
|
|
(selectedElement.type === 'navigation_next' ||
|
|
selectedElement.type === 'navigation_prev') && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Type
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.navType === 'back' ||
|
|
selectedElement.navType === 'forward'
|
|
? selectedElement.navType
|
|
: getNavigationButtonKind(selectedElement.type)
|
|
}
|
|
onChange={(event) => {
|
|
const requestedKind: NavigationButtonKind =
|
|
event.target.value === 'back' ? 'back' : 'forward';
|
|
const requestedType = getNavigationTypeFromKind(
|
|
requestedKind,
|
|
);
|
|
const nextType = allowedNavigationTypes.includes(
|
|
requestedType,
|
|
)
|
|
? requestedType
|
|
: allowedNavigationTypes[0];
|
|
updateSelectedElement(
|
|
normalizeNavigationElementType(
|
|
selectedElement,
|
|
nextType,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
<option
|
|
value='forward'
|
|
disabled={
|
|
!allowedNavigationTypes.includes('navigation_next')
|
|
}
|
|
>
|
|
Forward
|
|
</option>
|
|
<option
|
|
value='back'
|
|
disabled={
|
|
!allowedNavigationTypes.includes('navigation_prev')
|
|
}
|
|
>
|
|
Back
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Button text
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.navLabel || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
navLabel: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
iconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{selectedMediaDurationNote}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Target page
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.targetPageId || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
targetPageId: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{pages
|
|
.filter((page) => page.id !== activePageId)
|
|
.map((page, index) => (
|
|
<option key={page.id} value={page.id}>
|
|
{page.name || `Page ${index + 1}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Transition video asset
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.transitionVideoUrl || ''}
|
|
onChange={(event) => {
|
|
const nextVideoUrl = event.target.value;
|
|
const resolvedDuration =
|
|
getKnownDurationForSource(nextVideoUrl);
|
|
updateSelectedElement({
|
|
transitionVideoUrl: nextVideoUrl,
|
|
transitionDurationSec: resolvedDuration || undefined,
|
|
});
|
|
}}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
transitionVideoAssetOptions,
|
|
selectedElement.transitionVideoUrl,
|
|
`Current video · ${selectedElement.transitionVideoUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className='mt-1 text-[11px] text-gray-500'>
|
|
{selectedTransitionDurationNote}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Back transition mode
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={
|
|
selectedElement.transitionReverseMode ||
|
|
'auto_reverse'
|
|
}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
transitionReverseMode:
|
|
event.target.value === 'separate_video'
|
|
? 'separate_video'
|
|
: 'auto_reverse',
|
|
})
|
|
}
|
|
>
|
|
<option value='auto_reverse'>
|
|
Auto reverse transition video
|
|
</option>
|
|
<option value='separate_video'>
|
|
Use separate back-transition video
|
|
</option>
|
|
</select>
|
|
</div>
|
|
{selectedElement.transitionReverseMode ===
|
|
'separate_video' && (
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Back transition video asset
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.reverseVideoUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
reverseVideoUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
transitionVideoAssetOptions,
|
|
selectedElement.reverseVideoUrl,
|
|
`Current back video · ${selectedElement.reverseVideoUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<p className='text-[11px] text-gray-500'>
|
|
Transition duration is set automatically from the selected
|
|
video. {selectedTransitionDurationNote}
|
|
</p>
|
|
<div className='flex gap-2 pt-1'>
|
|
<BaseButton
|
|
small
|
|
color='lightDark'
|
|
label='Preview Forward'
|
|
onClick={() => openTransitionPreview('forward')}
|
|
/>
|
|
<BaseButton
|
|
small
|
|
color='lightDark'
|
|
label='Preview Back'
|
|
onClick={() => openTransitionPreview('back')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'tooltip' && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ iconUrl: event.target.value })
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Tooltip title
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.tooltipTitle || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
tooltipTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Tooltip text
|
|
</label>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
rows={4}
|
|
value={selectedElement.tooltipText || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
tooltipText: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'description' && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Icon
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.iconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({ iconUrl: event.target.value })
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.iconUrl,
|
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Description title
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.descriptionTitle || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Description text
|
|
</label>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
rows={5}
|
|
value={selectedElement.descriptionText || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionText: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Title font size
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.descriptionTitleFontSize || '48px'}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTitleFontSize: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. 48px'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Text font size
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.descriptionTextFontSize || '36px'}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionTextFontSize: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. 36px'
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
Background color
|
|
</label>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.descriptionBackgroundColor || 'transparent'}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
descriptionBackgroundColor: event.target.value,
|
|
})
|
|
}
|
|
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement &&
|
|
(selectedElement.type === 'video_player' ||
|
|
selectedElement.type === 'audio_player') && (
|
|
<div className='space-y-2'>
|
|
<div>
|
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
|
{selectedElement.type === 'video_player'
|
|
? 'Video asset'
|
|
: 'Audio asset'}
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.mediaUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Not selected</option>
|
|
{addFallbackAssetOption(
|
|
selectedElement.type === 'video_player'
|
|
? videoAssetOptions
|
|
: audioAssetOptions,
|
|
selectedElement.mediaUrl,
|
|
`Current media · ${selectedElement.mediaUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaAutoplay)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaAutoplay: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Autoplay
|
|
</label>
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaLoop)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaLoop: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Loop
|
|
</label>
|
|
{selectedElement.type === 'video_player' && (
|
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
|
<input
|
|
type='checkbox'
|
|
checked={Boolean(selectedElement.mediaMuted)}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
mediaMuted: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Muted
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'gallery' && (
|
|
<div className='space-y-2'>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Gallery cards
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-blue-700 hover:underline'
|
|
onClick={addGalleryCard}
|
|
>
|
|
+ Add card
|
|
</button>
|
|
</div>
|
|
{(selectedElement.galleryCards || []).map((card, index) => (
|
|
<div
|
|
key={card.id}
|
|
className='rounded border border-gray-200 p-2 space-y-2'
|
|
>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Card {index + 1}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={() => removeGalleryCard(card.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={card.imageUrl}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
imageUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Image asset</option>
|
|
{addFallbackAssetOption(
|
|
imageAssetOptions,
|
|
card.imageUrl,
|
|
`Current image · ${card.imageUrl}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Title'
|
|
value={card.title}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
title: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<textarea
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Description'
|
|
rows={3}
|
|
value={card.description}
|
|
onChange={(event) =>
|
|
updateGalleryCard(card.id, {
|
|
description: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{selectedElement && selectedElement.type === 'carousel' && (
|
|
<div className='space-y-2'>
|
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Navigation icons
|
|
</p>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.carouselPrevIconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
carouselPrevIconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Previous icon</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.carouselPrevIconUrl,
|
|
`Current prev icon · ${selectedElement.carouselPrevIconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={selectedElement.carouselNextIconUrl || ''}
|
|
onChange={(event) =>
|
|
updateSelectedElement({
|
|
carouselNextIconUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Next icon</option>
|
|
{addFallbackAssetOption(
|
|
iconAssetOptions,
|
|
selectedElement.carouselNextIconUrl,
|
|
`Current next icon · ${selectedElement.carouselNextIconUrl || ''}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-600'>
|
|
Carousel slides
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-blue-700 hover:underline'
|
|
onClick={addCarouselSlide}
|
|
>
|
|
+ Add slide
|
|
</button>
|
|
</div>
|
|
{(selectedElement.carouselSlides || []).map(
|
|
(slide, index) => (
|
|
<div
|
|
key={slide.id}
|
|
className='rounded border border-gray-200 p-2 space-y-2'
|
|
>
|
|
<div className='flex items-center justify-between'>
|
|
<p className='text-[11px] font-semibold text-gray-700'>
|
|
Slide {index + 1}
|
|
</p>
|
|
<button
|
|
type='button'
|
|
className='text-xs text-red-600 hover:underline'
|
|
onClick={() => removeCarouselSlide(slide.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
value={slide.imageUrl}
|
|
onChange={(event) =>
|
|
updateCarouselSlide(slide.id, {
|
|
imageUrl: event.target.value,
|
|
})
|
|
}
|
|
>
|
|
<option value=''>Image asset</option>
|
|
{addFallbackAssetOption(
|
|
imageAssetOptions,
|
|
slide.imageUrl,
|
|
`Current image · ${slide.imageUrl}`,
|
|
).map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
|
placeholder='Caption'
|
|
value={slide.caption}
|
|
onChange={(event) =>
|
|
updateCarouselSlide(slide.id, {
|
|
caption: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<div
|
|
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
|
|
style={{ left: menuPosition.x, top: menuPosition.y }}
|
|
>
|
|
<div
|
|
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
|
|
onMouseDown={onMenuDragStart}
|
|
>
|
|
<span className='text-xs font-bold uppercase'>
|
|
Constructor Menu
|
|
</span>
|
|
<button
|
|
type='button'
|
|
onClick={() => setIsMenuOpen((prev) => !prev)}
|
|
>
|
|
<BaseIcon path={mdiMenu} size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{isMenuOpen && (
|
|
<div className='p-2 space-y-1'>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_image')}
|
|
>
|
|
<BaseIcon path={mdiImageMultiple} size={16} />
|
|
<span>Background Image</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_video')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Background Video</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('background_audio')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Background Audio</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement(allowedNavigationTypes[0])}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>Add Navigation Button</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => selectMenuItemForEdit('create_transition')}
|
|
>
|
|
<BaseIcon path={mdiSwapHorizontal} size={16} />
|
|
<span>Add Transition</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('gallery')}
|
|
>
|
|
<BaseIcon path={mdiImageMultiple} size={16} />
|
|
<span>Add Gallery</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('carousel')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Add Carousel</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('tooltip')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Add Tooltip</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('description')}
|
|
>
|
|
<BaseIcon path={mdiText} size={16} />
|
|
<span>Add Description</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('video_player')}
|
|
>
|
|
<BaseIcon path={mdiViewCarousel} size={16} />
|
|
<span>Add Video Player</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={() => addElement('audio_player')}
|
|
>
|
|
<BaseIcon path={mdiTooltipText} size={16} />
|
|
<span>Add Audio Player</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn'
|
|
onClick={createPage}
|
|
disabled={isCreatingPage}
|
|
>
|
|
<BaseIcon path={mdiPlus} size={16} />
|
|
<span>
|
|
{isCreatingPage ? 'Creating Page...' : 'Create New Page'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn !text-blue-700'
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
>
|
|
<BaseIcon path={mdiContentSave} size={16} />
|
|
<span>{isSaving ? 'Saving...' : 'Save'}</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
className='menu-action-btn !text-red-700'
|
|
onClick={() =>
|
|
router.push(
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list',
|
|
)
|
|
}
|
|
>
|
|
<BaseIcon path={mdiExitToApp} size={16} />
|
|
<span>Exit</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{transitionPreview && (
|
|
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
|
<video
|
|
ref={transitionVideoRef}
|
|
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
|
muted
|
|
playsInline
|
|
preload='auto'
|
|
disablePictureInPicture
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<style jsx>{`
|
|
.menu-action-btn {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
padding: 6px 8px;
|
|
border-radius: 6px;
|
|
color: #1f2937;
|
|
text-align: left;
|
|
}
|
|
|
|
.menu-action-btn:hover {
|
|
background: #f3f4f6;
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
};
|
|
|
|
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default ConstructorPage;
|