1523 lines
52 KiB
TypeScript
1523 lines
52 KiB
TypeScript
import { mdiContentSave, mdiExitToApp, mdiPlus } 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 CanvasBackground from '../components/Constructor/CanvasBackground';
|
|
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
|
|
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
|
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
|
import { getPageTitle } from '../config';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
|
import { logger } from '../lib/logger';
|
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|
import { parseJsonObject } from '../lib/parseJson';
|
|
import {
|
|
resolveNavigationTarget,
|
|
hasPlayableTransition,
|
|
getNavigationDirection,
|
|
} from '../lib/navigationHelpers';
|
|
import {
|
|
mergeElementWithDefaults,
|
|
createLocalId,
|
|
normalizeAppearDelaySec,
|
|
normalizeAppearDurationSec,
|
|
ELEMENT_TYPE_LABELS,
|
|
getNavigationButtonKind,
|
|
isNavigationElementType,
|
|
isTooltipElementType,
|
|
isDescriptionElementType,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
} from '../lib/elementDefaults';
|
|
import type { PreloadPageLink, PreloadElement } from '../types/preload';
|
|
import type {
|
|
CanvasElementType,
|
|
CanvasElement,
|
|
ConstructorSchema,
|
|
ConstructorAsset as ProjectAsset,
|
|
NormalizedElementDefault,
|
|
} from '../types/constructor';
|
|
import {
|
|
normalizeElementDefault,
|
|
buildElementDefaultsMap,
|
|
} from '../types/constructor';
|
|
|
|
// Constructor-specific hooks
|
|
import {
|
|
useCanvasElapsedTime,
|
|
isElementVisibleAtTime,
|
|
} from '../hooks/useCanvasElapsedTime';
|
|
import {
|
|
useMediaDurationProbe,
|
|
buildDurationProbeTargets,
|
|
} from '../hooks/useMediaDurationProbe';
|
|
import { useIconPreload } from '../hooks/useIconPreload';
|
|
import { useOutsideClick } from '../hooks/useOutsideClick';
|
|
import { useDraggable } from '../hooks/useDraggable';
|
|
import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag';
|
|
import { useTransitionPreview } from '../hooks/useTransitionPreview';
|
|
import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
|
|
import { useConstructorElements } from '../hooks/useConstructorElements';
|
|
|
|
// Constructor helpers (extracted utilities)
|
|
import {
|
|
clamp,
|
|
getAssetLabel,
|
|
getAssetSourceValue,
|
|
isBackgroundImageAsset,
|
|
} from '../lib/constructorHelpers';
|
|
|
|
type TourPage = {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
sort_order?: number;
|
|
environment?: 'dev' | 'stage' | 'production';
|
|
source_key?: string;
|
|
requires_auth?: boolean;
|
|
ui_schema_json?: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_audio_url?: string;
|
|
background_loop?: boolean;
|
|
};
|
|
|
|
type NavigationElementType = Extract<
|
|
CanvasElementType,
|
|
'navigation_next' | 'navigation_prev'
|
|
>;
|
|
|
|
type EditorMenuItem =
|
|
| 'none'
|
|
| 'background_image'
|
|
| 'background_video'
|
|
| 'background_audio'
|
|
| 'create_transition';
|
|
|
|
type ConstructorPageProps = {
|
|
mode?: 'constructor' | 'element_edit';
|
|
};
|
|
|
|
type ConstructorInteractionMode = 'edit' | 'interact';
|
|
|
|
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
|
const labelByType = ELEMENT_TYPE_LABELS;
|
|
|
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|
const router = useRouter();
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
const isElementEditMode = mode === 'element_edit';
|
|
|
|
const projectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
const pageElementsListHref = useMemo(() => {
|
|
if (!projectId)
|
|
return '/project-element-defaults/project-element-defaults-list';
|
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
|
}, [projectId]);
|
|
|
|
const pageIdFromRoute = useMemo(() => {
|
|
const value = router.query.pageId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.pageId]);
|
|
const elementIdFromRoute = useMemo(() => {
|
|
const value = router.query.elementId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.elementId]);
|
|
|
|
const [pages, setPages] = useState<TourPage[]>([]);
|
|
const [pageLinks, setPageLinks] = useState<PreloadPageLink[]>([]);
|
|
const [allPagesPreloadElements, setAllPagesPreloadElements] = useState<
|
|
PreloadElement[]
|
|
>([]);
|
|
const [assets, setAssets] = useState<ProjectAsset[]>([]);
|
|
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
|
|
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
|
|
>({});
|
|
const [activePageId, setActivePageId] = useState('');
|
|
const [projectName, setProjectName] = useState('');
|
|
|
|
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
|
|
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
|
|
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
|
|
const [selectedMenuItem, setSelectedMenuItem] =
|
|
useState<EditorMenuItem>('none');
|
|
// Transition preview state managed by useTransitionPreview hook (below)
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
// isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook
|
|
const [newTransitionName, setNewTransitionName] = useState('');
|
|
const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState('');
|
|
const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] =
|
|
useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [constructorInteractionMode, setConstructorInteractionMode] =
|
|
useState<ConstructorInteractionMode>('edit');
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
|
const [elementEditorTab, setElementEditorTab] = useState<
|
|
'general' | 'css' | 'effects'
|
|
>('general');
|
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
|
element: CanvasElement;
|
|
initialIndex: number;
|
|
} | null>(null);
|
|
|
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
|
return ['navigation_next', 'navigation_prev'];
|
|
}, []);
|
|
|
|
// Element CRUD operations via useConstructorElements hook
|
|
const {
|
|
elements,
|
|
setElements,
|
|
selectedElementId,
|
|
selectedElement,
|
|
selectElement,
|
|
clearSelection,
|
|
addElement,
|
|
updateSelectedElement,
|
|
removeSelectedElement,
|
|
galleryCards,
|
|
galleryInfoSpans,
|
|
carouselSlides,
|
|
updateElementPosition,
|
|
normalizeNavigationType,
|
|
} = useConstructorElements({
|
|
initialElements: [],
|
|
elementDefaultsByType: uiElementDefaultsByType,
|
|
allowedNavigationTypes,
|
|
initialSelectedElementId: elementIdFromRoute,
|
|
onElementSelected: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onSelectionCleared: useCallback(() => {
|
|
setSelectedMenuItem('none');
|
|
}, []),
|
|
onElementAdded: useCallback(() => {
|
|
setSuccessMessage('Element added. Drag it to set position.');
|
|
setErrorMessage('');
|
|
}, []),
|
|
onElementRemoved: useCallback(() => {
|
|
setSuccessMessage('Element removed.');
|
|
}, []),
|
|
});
|
|
|
|
// Draggable panels using useDraggable hook
|
|
const {
|
|
position: constructorControlsPosition,
|
|
onDragStart: onConstructorControlsDragStart,
|
|
} = useDraggable({
|
|
initialPosition: { x: 20, y: 20 },
|
|
elementWidth: 460,
|
|
elementHeight: 64,
|
|
});
|
|
|
|
const { position: menuPosition, onDragStart: onMenuDragStart } = useDraggable(
|
|
{
|
|
initialPosition: { x: 9999, y: 10 }, // Top right corner (x will be clamped)
|
|
elementWidth: 240,
|
|
elementHeight: 60,
|
|
},
|
|
);
|
|
|
|
const { position: editorPosition, onDragStart: onElementEditorDragStart } =
|
|
useDraggable({
|
|
initialPosition: { x: 0, y: 72 },
|
|
elementWidth: isEditorCollapsed ? 260 : 380,
|
|
elementHeight: 60,
|
|
});
|
|
|
|
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
|
const didSetInitialCanvasFocus = useRef(false);
|
|
const selectedElementIdRef = useRef<string>('');
|
|
selectedElementIdRef.current = selectedElementId;
|
|
|
|
const activePage = useMemo(
|
|
() => pages.find((item) => item.id === activePageId) || null,
|
|
[activePageId, pages],
|
|
);
|
|
|
|
// Transition preview state management
|
|
const {
|
|
preview: transitionPreview,
|
|
pendingPageId: pendingNavigationPageId,
|
|
openPreview: openTransitionPreviewForElement,
|
|
openPreviewWithTarget,
|
|
closePreview: closeTransitionPreview,
|
|
} = useTransitionPreview({
|
|
isNavigationElementType,
|
|
onError: setErrorMessage,
|
|
});
|
|
|
|
// Canvas elapsed time for element visibility timing
|
|
const { elapsedSec: canvasElapsedSec } = useCanvasElapsedTime({
|
|
pageId: activePageId,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
// Element dragging with percentage positioning
|
|
const { onElementDragStart, cancelDrag: cancelElementDrag } =
|
|
useCanvasElementDrag({
|
|
canvasRef,
|
|
onPositionChange: updateElementPosition,
|
|
enabled: constructorInteractionMode === 'edit',
|
|
});
|
|
|
|
// Preload orchestrator for better DX when previewing pages
|
|
// Preloads neighbor page assets and transition videos
|
|
// Uses allPagesPreloadElements (extracted in loadData) for proper neighbor preloading
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages: pages.map((p) => ({
|
|
id: p.id,
|
|
background_image_url: p.background_image_url,
|
|
background_video_url: p.background_video_url,
|
|
background_audio_url: p.background_audio_url,
|
|
})),
|
|
pageLinks,
|
|
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading
|
|
currentPageId: activePageId,
|
|
enabled: !isLoading && !!activePageId,
|
|
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
|
|
});
|
|
|
|
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
|
|
const pageSwitch = usePageSwitch({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
// Helper to switch pages without flash
|
|
// Uses usePageSwitch hook to resolve blob URLs from preload cache
|
|
// Also updates storage path state for editing/saving purposes
|
|
const switchToPage = useCallback(
|
|
async (page: TourPage | null) => {
|
|
// Mark this page as initialized to prevent redundant effect calls
|
|
if (page) {
|
|
lastInitializedPageIdRef.current = page.id;
|
|
}
|
|
|
|
// Update storage path state (for editing and saving)
|
|
setBackgroundImageUrl(page?.background_image_url || '');
|
|
setBackgroundVideoUrl(page?.background_video_url || '');
|
|
setBackgroundAudioUrl(page?.background_audio_url || '');
|
|
|
|
// Use hook to resolve and set blob URLs for display
|
|
await pageSwitch.switchToPage(
|
|
page
|
|
? {
|
|
id: page.id,
|
|
background_image_url: page.background_image_url,
|
|
background_video_url: page.background_video_url,
|
|
background_audio_url: page.background_audio_url,
|
|
}
|
|
: null,
|
|
() => {
|
|
if (page) {
|
|
setActivePageId(page.id);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
[pageSwitch],
|
|
);
|
|
|
|
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
|
videoRef: transitionVideoRef,
|
|
transition: transitionPreview
|
|
? {
|
|
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
|
|
storageKey: transitionPreview.storageKey,
|
|
reverseMode: transitionPreview.reverseMode,
|
|
reverseVideoUrl: transitionPreview.reverseVideoUrl
|
|
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
|
|
: undefined,
|
|
durationSec: transitionPreview.durationSec,
|
|
targetPageId: pendingNavigationPageId || undefined,
|
|
displayName: transitionPreview.title,
|
|
}
|
|
: null,
|
|
onComplete: async (targetPageId) => {
|
|
const video = transitionVideoRef.current;
|
|
if (targetPageId) {
|
|
const targetPage = pages.find((p) => p.id === targetPageId) || null;
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch
|
|
await switchToPage(targetPage);
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
closeTransitionPreview();
|
|
});
|
|
});
|
|
} else {
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
closeTransitionPreview();
|
|
}
|
|
},
|
|
timeouts: {
|
|
playbackStartMs: 3000,
|
|
hardTimeoutMs: 45000,
|
|
},
|
|
features: {
|
|
useBlobUrl: true,
|
|
preDecodeImages: false, // We handle image loading via usePageSwitch
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
},
|
|
});
|
|
|
|
// Use shared background transition hook for direct navigation clearing
|
|
// (No fade-out needed in constructor - transitions complete immediately)
|
|
useBackgroundTransition({ pageSwitch });
|
|
|
|
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]);
|
|
|
|
// Icon preloading for smooth rendering
|
|
const { preloadedUrlMap: preloadedIconUrlMap } = useIconPreload({
|
|
iconUrls: iconPreloadTargets,
|
|
enabled: !isLoading,
|
|
});
|
|
|
|
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' &&
|
|
asset.type !== 'transition' &&
|
|
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),
|
|
}));
|
|
|
|
return taggedAssets;
|
|
}, [assets]);
|
|
const iconAssetOptions = useMemo(
|
|
() =>
|
|
assets
|
|
.filter(
|
|
(asset) =>
|
|
asset.type === 'icon' &&
|
|
asset.asset_type === 'image' &&
|
|
getAssetSourceValue(asset),
|
|
)
|
|
.map((asset) => ({
|
|
value: getAssetSourceValue(asset),
|
|
label: getAssetLabel(asset),
|
|
})),
|
|
[assets],
|
|
);
|
|
// Media duration probing with caching
|
|
const durationProbeTargets = useMemo(
|
|
() =>
|
|
buildDurationProbeTargets({
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
selectedElement,
|
|
newTransitionVideoUrl,
|
|
elements,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
isNavigationElementType,
|
|
}),
|
|
[
|
|
backgroundAudioUrl,
|
|
backgroundVideoUrl,
|
|
elements,
|
|
newTransitionVideoUrl,
|
|
selectedElement,
|
|
],
|
|
);
|
|
|
|
const { getDuration, getDurationNote } = useMediaDurationProbe({
|
|
targets: durationProbeTargets,
|
|
});
|
|
|
|
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
|
|
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl);
|
|
const selectedMediaDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isMediaElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.mediaUrl || '');
|
|
}, [getDurationNote, selectedElement]);
|
|
|
|
const newTransitionDurationNote = getDurationNote(newTransitionVideoUrl);
|
|
|
|
const selectedTransitionDurationNote = useMemo(() => {
|
|
if (!selectedElement || !isNavigationElementType(selectedElement.type)) {
|
|
return 'Duration: unknown';
|
|
}
|
|
return getDurationNote(selectedElement.transitionVideoUrl || '');
|
|
}, [getDurationNote, 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 = getDuration(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;
|
|
});
|
|
}, [getDuration]);
|
|
|
|
const loadData = useCallback(
|
|
async (preservePageId?: string) => {
|
|
if (!projectId || !router.isReady || !isAuthReady) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
|
|
const [
|
|
projectResponse,
|
|
pagesResponse,
|
|
assetsResponse,
|
|
uiElementsResponse,
|
|
] = await Promise.all([
|
|
axios.get(`/projects/${projectId}`),
|
|
axios.get(
|
|
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`,
|
|
),
|
|
axios.get(
|
|
`/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${projectId}`,
|
|
),
|
|
axios.get(
|
|
`/project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`,
|
|
),
|
|
]);
|
|
|
|
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
|
|
? pagesResponse.data.rows
|
|
: [];
|
|
const assetRows: ProjectAsset[] = Array.isArray(
|
|
assetsResponse?.data?.rows,
|
|
)
|
|
? assetsResponse.data.rows
|
|
: [];
|
|
setProjectName(projectResponse?.data?.name || '');
|
|
setPages(pageRows);
|
|
|
|
// Extract page links and preload elements using shared utility
|
|
const {
|
|
pageLinks: syntheticPageLinks,
|
|
preloadElements: allPreloadElements,
|
|
} = extractPageLinksAndElements(pageRows);
|
|
|
|
setPageLinks(syntheticPageLinks);
|
|
setAllPagesPreloadElements(allPreloadElements);
|
|
setAssets(assetRows);
|
|
|
|
// Process project element defaults using shared utilities
|
|
const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows)
|
|
? uiElementsResponse.data.rows
|
|
: [];
|
|
const normalizedDefaults = uiElementRows
|
|
.map((row: Record<string, unknown>) => normalizeElementDefault(row))
|
|
.filter(
|
|
(
|
|
d: NormalizedElementDefault | null,
|
|
): d is NormalizedElementDefault => d !== null,
|
|
);
|
|
const defaultsByType = buildElementDefaultsMap(normalizedDefaults);
|
|
setUiElementDefaultsByType(defaultsByType);
|
|
|
|
// Preserve current page if specified and it still exists, otherwise use route or first page
|
|
const preservedPageExists =
|
|
preservePageId && pageRows.some((p: any) => p.id === preservePageId);
|
|
const defaultPageId = preservedPageExists
|
|
? preservePageId
|
|
: pageIdFromRoute || pageRows[0]?.id || '';
|
|
setActivePageId(defaultPageId);
|
|
setIsMenuOpen(false);
|
|
} catch (error: any) {
|
|
if (error?.response?.status === 401) {
|
|
const message = 'Your session has expired. Please sign in again.';
|
|
logger.error(
|
|
'Unauthorized constructor request:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
error?.response?.data?.message ||
|
|
error?.message ||
|
|
'Failed to load constructor data.';
|
|
logger.error(
|
|
'Failed to load constructor data:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
setErrorMessage(message);
|
|
setPages([]);
|
|
setAssets([]);
|
|
setUiElementDefaultsByType({});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[isAuthReady, pageIdFromRoute, projectId, router],
|
|
);
|
|
|
|
// Page actions (save, create page, save to stage)
|
|
const {
|
|
isSaving,
|
|
isSavingToStage,
|
|
isCreatingPage,
|
|
isCreatingTransition,
|
|
saveConstructor,
|
|
saveToStage,
|
|
createPage,
|
|
createTransition,
|
|
} = useConstructorPageActions({
|
|
projectId,
|
|
pages,
|
|
activePage,
|
|
activePageId,
|
|
elements,
|
|
backgroundImageUrl,
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
onReload: loadData,
|
|
onSetActivePageId: setActivePageId,
|
|
onSetMenuOpen: setIsMenuOpen,
|
|
onError: setErrorMessage,
|
|
onSuccess: setSuccessMessage,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof window === 'undefined') return;
|
|
|
|
const token =
|
|
sessionStorage.getItem('token') || localStorage.getItem('token');
|
|
if (!token) {
|
|
setIsAuthReady(false);
|
|
setErrorMessage('Please sign in to continue.');
|
|
router.replace('/login');
|
|
return;
|
|
}
|
|
|
|
setIsAuthReady(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) return;
|
|
if (projectId) return;
|
|
router.replace(
|
|
isElementEditMode
|
|
? '/project-element-defaults/project-element-defaults-list'
|
|
: '/projects/projects-list',
|
|
);
|
|
}, [isElementEditMode, projectId, router]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// Panel initial positions are handled by useDraggable hooks
|
|
|
|
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 (!activePage) {
|
|
setElements([]);
|
|
clearSelection();
|
|
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,
|
|
galleryHeaderImageUrl:
|
|
typeof item.galleryHeaderImageUrl === 'string'
|
|
? item.galleryHeaderImageUrl
|
|
: undefined,
|
|
galleryTitle:
|
|
typeof item.galleryTitle === 'string'
|
|
? item.galleryTitle
|
|
: undefined,
|
|
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
|
|
? item.galleryInfoSpans.map((span: any) => ({
|
|
id: String(span?.id || createLocalId()),
|
|
text: String(span?.text || ''),
|
|
}))
|
|
: undefined,
|
|
galleryColumns:
|
|
typeof item.galleryColumns === 'number'
|
|
? item.galleryColumns
|
|
: 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
|
|
: '',
|
|
// Gallery Carousel Settings
|
|
galleryCarouselPrevIconUrl:
|
|
typeof item.galleryCarouselPrevIconUrl === 'string'
|
|
? item.galleryCarouselPrevIconUrl
|
|
: '',
|
|
galleryCarouselNextIconUrl:
|
|
typeof item.galleryCarouselNextIconUrl === 'string'
|
|
? item.galleryCarouselNextIconUrl
|
|
: '',
|
|
galleryCarouselBackIconUrl:
|
|
typeof item.galleryCarouselBackIconUrl === 'string'
|
|
? item.galleryCarouselBackIconUrl
|
|
: '',
|
|
galleryCarouselBackLabel:
|
|
typeof item.galleryCarouselBackLabel === 'string'
|
|
? item.galleryCarouselBackLabel
|
|
: '',
|
|
galleryCarouselPrevX:
|
|
typeof item.galleryCarouselPrevX === 'number'
|
|
? item.galleryCarouselPrevX
|
|
: undefined,
|
|
galleryCarouselPrevY:
|
|
typeof item.galleryCarouselPrevY === 'number'
|
|
? item.galleryCarouselPrevY
|
|
: undefined,
|
|
galleryCarouselNextX:
|
|
typeof item.galleryCarouselNextX === 'number'
|
|
? item.galleryCarouselNextX
|
|
: undefined,
|
|
galleryCarouselNextY:
|
|
typeof item.galleryCarouselNextY === 'number'
|
|
? item.galleryCarouselNextY
|
|
: undefined,
|
|
galleryCarouselBackX:
|
|
typeof item.galleryCarouselBackX === 'number'
|
|
? item.galleryCarouselBackX
|
|
: undefined,
|
|
galleryCarouselBackY:
|
|
typeof item.galleryCarouselBackY === 'number'
|
|
? item.galleryCarouselBackY
|
|
: undefined,
|
|
tooltipTitle:
|
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
|
tooltipText:
|
|
typeof item.tooltipText === 'string' ? item.tooltipText : '',
|
|
descriptionTitle:
|
|
typeof item.descriptionTitle === 'string'
|
|
? item.descriptionTitle
|
|
: '',
|
|
descriptionText:
|
|
typeof item.descriptionText === 'string'
|
|
? item.descriptionText
|
|
: '',
|
|
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
|
|
navType:
|
|
item.navType === 'back' || item.navType === 'forward'
|
|
? item.navType
|
|
: isNavigationElementType(elementType)
|
|
? getNavigationButtonKind(
|
|
elementType as NavigationElementType,
|
|
)
|
|
: undefined,
|
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
|
targetPageSlug:
|
|
typeof item.targetPageSlug === 'string'
|
|
? item.targetPageSlug
|
|
: '',
|
|
targetPageId:
|
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
|
transitionVideoUrl:
|
|
typeof item.transitionVideoUrl === 'string'
|
|
? item.transitionVideoUrl
|
|
: '',
|
|
transitionReverseMode:
|
|
item.transitionReverseMode === 'separate_video'
|
|
? 'separate_video'
|
|
: ('auto_reverse' as const),
|
|
reverseVideoUrl:
|
|
typeof item.reverseVideoUrl === 'string'
|
|
? item.reverseVideoUrl
|
|
: '',
|
|
transitionDurationSec: item.transitionDurationSec
|
|
? Number(item.transitionDurationSec)
|
|
: undefined,
|
|
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
|
|
mediaAutoplay:
|
|
typeof item.mediaAutoplay === 'boolean'
|
|
? item.mediaAutoplay
|
|
: true,
|
|
mediaLoop:
|
|
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
|
|
mediaMuted:
|
|
typeof item.mediaMuted === 'boolean'
|
|
? item.mediaMuted
|
|
: isVideoPlayerElementType(item.type),
|
|
};
|
|
|
|
return mergeElementWithDefaults(
|
|
normalizedElement,
|
|
uiElementDefaultsByType[elementType],
|
|
{ preferElementValues: true },
|
|
);
|
|
})
|
|
: [];
|
|
|
|
setElements(normalizedElements);
|
|
setSelectedMenuItem('none');
|
|
|
|
const currentSelectedId = selectedElementIdRef.current;
|
|
if (!normalizedElements.length) {
|
|
clearSelection();
|
|
} else if (
|
|
elementIdFromRoute &&
|
|
normalizedElements.some((element) => element.id === elementIdFromRoute)
|
|
) {
|
|
selectElement(elementIdFromRoute);
|
|
} else if (
|
|
currentSelectedId &&
|
|
!normalizedElements.some((element) => element.id === currentSelectedId)
|
|
) {
|
|
// Current selection no longer valid
|
|
clearSelection();
|
|
}
|
|
// If current selection is still valid, do nothing (keep current)
|
|
// Set storage paths for editing/saving
|
|
setBackgroundImageUrl(activePage.background_image_url || '');
|
|
setBackgroundVideoUrl(activePage.background_video_url || '');
|
|
setBackgroundAudioUrl(activePage.background_audio_url || '');
|
|
// Resolve blob URLs via hook for display (handles initial load and route changes)
|
|
// Only call if this page wasn't already initialized via switchToPage function
|
|
if (lastInitializedPageIdRef.current !== activePage.id) {
|
|
lastInitializedPageIdRef.current = activePage.id;
|
|
pageSwitch.switchToPage({
|
|
id: activePage.id,
|
|
background_image_url: activePage.background_image_url,
|
|
background_video_url: activePage.background_video_url,
|
|
background_audio_url: activePage.background_audio_url,
|
|
});
|
|
}
|
|
}, [
|
|
activePage,
|
|
elementIdFromRoute,
|
|
uiElementDefaultsByType,
|
|
pageSwitch.switchToPage,
|
|
clearSelection,
|
|
selectElement,
|
|
setElements,
|
|
]);
|
|
|
|
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 normalizeNavigationType(element, forcedType);
|
|
});
|
|
return hasChanges ? nextElements : prev;
|
|
});
|
|
}, [allowedNavigationTypes, normalizeNavigationType, setElements]);
|
|
|
|
// Element drag is now handled by useCanvasElementDrag hook
|
|
|
|
useEffect(() => {
|
|
if (isConstructorEditMode) return;
|
|
cancelElementDrag();
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [isConstructorEditMode, cancelElementDrag, clearSelection]);
|
|
|
|
// Outside click detection to clear element/menu selection
|
|
useOutsideClick({
|
|
containerRef: elementEditorRef,
|
|
ignoreDataAttribute: 'data-constructor-element-id',
|
|
selectedValue: selectedElementId,
|
|
onOutsideClick: useCallback(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
}, [clearSelection]),
|
|
enabled:
|
|
isConstructorEditMode &&
|
|
(!!selectedElementId || selectedMenuItem !== 'none'),
|
|
});
|
|
|
|
// Thin wrappers for hook functions (handle additional state like selectedMenuItem)
|
|
const selectElementForEdit = useCallback(
|
|
(elementId: string) => {
|
|
selectElement(elementId);
|
|
// Note: setSelectedMenuItem('none') is handled by onElementSelected callback
|
|
},
|
|
[selectElement],
|
|
);
|
|
|
|
const selectMenuItemForEdit = useCallback(
|
|
(item: EditorMenuItem) => {
|
|
clearSelection();
|
|
setSelectedMenuItem(item);
|
|
},
|
|
[clearSelection],
|
|
);
|
|
|
|
// createPage, saveConstructor, saveToStage are now provided by useConstructorPageActions hook
|
|
|
|
const onElementMouseDown = (event: React.MouseEvent, elementId: string) => {
|
|
if (!isConstructorEditMode) return;
|
|
|
|
const currentElement = elements.find((item) => item.id === elementId);
|
|
if (!currentElement) return;
|
|
|
|
// Select the element for editing
|
|
selectElementForEdit(elementId);
|
|
|
|
// Start drag with current position
|
|
onElementDragStart(
|
|
event,
|
|
elementId,
|
|
currentElement.xPercent,
|
|
currentElement.yPercent,
|
|
);
|
|
};
|
|
|
|
// openTransitionPreviewForElement is now provided by useTransitionPreview hook
|
|
|
|
const openTransitionPreview = (direction: 'forward' | 'back') => {
|
|
if (
|
|
!selectedElement ||
|
|
(selectedElement.type !== 'navigation_next' &&
|
|
selectedElement.type !== 'navigation_prev')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
openTransitionPreviewForElement(selectedElement, direction);
|
|
};
|
|
|
|
const onCanvasElementClick = (element: CanvasElement) => {
|
|
if (!isConstructorEditMode) {
|
|
if (isNavigationElementType(element.type)) {
|
|
// Disable navigation while transition is playing or buffering
|
|
if (transitionPreview || isReverseBuffering) {
|
|
return;
|
|
}
|
|
if (element.navDisabled) {
|
|
return;
|
|
}
|
|
|
|
// Use shared navigation helpers
|
|
const direction = getNavigationDirection(element);
|
|
const navTarget = resolveNavigationTarget(element, pages);
|
|
|
|
if (!navTarget) {
|
|
setErrorMessage(
|
|
'No target page configured for this navigation button.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Check if transition can be played using shared helper
|
|
if (!hasPlayableTransition(element, direction)) {
|
|
closeTransitionPreview();
|
|
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
|
switchToPage(navTarget.page).then(() => {
|
|
clearSelection();
|
|
setSelectedMenuItem('none');
|
|
setErrorMessage('');
|
|
});
|
|
return;
|
|
}
|
|
|
|
openPreviewWithTarget(element, direction, navTarget.pageId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
selectElementForEdit(element.id);
|
|
};
|
|
|
|
// Handler for gallery card clicks
|
|
const handleGalleryCardClick = useCallback(
|
|
(element: CanvasElement, cardIndex: number) => {
|
|
if (element.galleryCards && element.galleryCards.length > 0) {
|
|
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Handler for gallery carousel button position changes (constructor only)
|
|
const handleCarouselButtonPositionChange = useCallback(
|
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
|
if (!activeGalleryCarousel) return;
|
|
|
|
const positionPatch =
|
|
button === 'prev'
|
|
? { galleryCarouselPrevX: x, galleryCarouselPrevY: y }
|
|
: button === 'next'
|
|
? { galleryCarouselNextX: x, galleryCarouselNextY: y }
|
|
: { galleryCarouselBackX: x, galleryCarouselBackY: y };
|
|
|
|
updateSelectedElement(positionPatch);
|
|
|
|
// Update the active carousel element to reflect the new positions
|
|
setActiveGalleryCarousel((prev) =>
|
|
prev
|
|
? { ...prev, element: { ...prev.element, ...positionPatch } }
|
|
: null,
|
|
);
|
|
},
|
|
[activeGalleryCarousel, updateSelectedElement],
|
|
);
|
|
|
|
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
|
isElementVisibleAtTime(
|
|
canvasElapsedSec,
|
|
element.appearDelaySec,
|
|
element.appearDurationSec,
|
|
);
|
|
|
|
const isElementReadyForCanvasRender = (element: CanvasElement) => {
|
|
const isPreloadableIconElement =
|
|
(isNavigationElementType(element.type) ||
|
|
isTooltipElementType(element.type) ||
|
|
isDescriptionElementType(element.type)) &&
|
|
Boolean(element.iconUrl);
|
|
|
|
if (!isPreloadableIconElement) return true;
|
|
|
|
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
|
|
if (!playbackUrl) return true;
|
|
|
|
return Boolean(preloadedIconUrlMap[playbackUrl]);
|
|
};
|
|
|
|
// URL resolver that uses preloaded blob URLs when available
|
|
const resolveUrlWithBlob = useCallback(
|
|
(url: string | undefined): string => {
|
|
if (!url) return '';
|
|
|
|
// Try to get blob URL from preload orchestrator (instant display)
|
|
// Check storage key first (most reliable), then resolved URL
|
|
const blobUrl =
|
|
preloadOrchestrator.getReadyBlobUrl(url) ||
|
|
preloadOrchestrator.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
|
|
if (blobUrl) return blobUrl;
|
|
|
|
// Fall back to standard resolution
|
|
return resolveAssetPlaybackUrl(url);
|
|
},
|
|
[preloadOrchestrator],
|
|
);
|
|
|
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
|
// Prefer hook's blob URLs, then try cached blob URLs, finally fall back to direct URLs
|
|
const backgroundImageSrc =
|
|
pageSwitch.currentBgImageUrl || resolveUrlWithBlob(backgroundImageUrl);
|
|
const backgroundVideoSrc =
|
|
pageSwitch.currentBgVideoUrl || resolveUrlWithBlob(backgroundVideoUrl);
|
|
const backgroundAudioSrc =
|
|
pageSwitch.currentBgAudioUrl || resolveUrlWithBlob(backgroundAudioUrl);
|
|
|
|
const hasEditorSelection =
|
|
isConstructorEditMode &&
|
|
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
|
const editorTitle =
|
|
selectedMenuItem === 'background_image'
|
|
? 'Background image'
|
|
: selectedMenuItem === 'background_video'
|
|
? 'Background video'
|
|
: selectedMenuItem === 'background_audio'
|
|
? 'Background audio'
|
|
: selectedMenuItem === 'create_transition'
|
|
? 'Create transition'
|
|
: selectedElement?.label || 'Element editor';
|
|
|
|
if (backgroundImageSrc) {
|
|
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
|
|
canvasBackgroundStyle.backgroundSize = 'cover';
|
|
canvasBackgroundStyle.backgroundPosition = 'center';
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>
|
|
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
|
|
</title>
|
|
</Head>
|
|
<div className='relative w-screen h-screen bg-black overflow-hidden'>
|
|
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
|
|
<p className='text-xs font-semibold text-gray-700'>
|
|
{projectName || 'Loading project...'}
|
|
</p>
|
|
{errorMessage ? (
|
|
<p className='rounded bg-red-50 px-2 py-1 text-xs text-red-600'>
|
|
{errorMessage}
|
|
</p>
|
|
) : null}
|
|
{successMessage ? (
|
|
<p className='rounded bg-green-50 px-2 py-1 text-xs text-green-700'>
|
|
{successMessage}
|
|
</p>
|
|
) : null}
|
|
|
|
{pages.length > 0 && isElementEditMode && (
|
|
<div className='flex items-center gap-2'>
|
|
<BaseButton
|
|
color='lightDark'
|
|
label='Back to Elements'
|
|
icon={mdiExitToApp}
|
|
href={pageElementsListHref}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
icon={mdiContentSave}
|
|
onClick={saveConstructor}
|
|
disabled={isSaving}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<ConstructorControlsPanel
|
|
projectId={projectId}
|
|
pages={pages}
|
|
activePageId={activePageId}
|
|
interactionMode={constructorInteractionMode}
|
|
position={constructorControlsPosition}
|
|
onPageChange={(pageId) => {
|
|
const page = pages.find((p) => p.id === pageId);
|
|
if (page) switchToPage(page);
|
|
}}
|
|
onModeChange={setConstructorInteractionMode}
|
|
onDragStart={onConstructorControlsDragStart}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
ref={canvasRef}
|
|
tabIndex={-1}
|
|
className='absolute inset-0 bg-black overflow-clip'
|
|
style={canvasBackgroundStyle}
|
|
>
|
|
<BackdropPortalProvider>
|
|
<CanvasBackground
|
|
backgroundImageUrl={backgroundImageSrc}
|
|
backgroundVideoUrl={backgroundVideoSrc}
|
|
backgroundAudioUrl={backgroundAudioSrc}
|
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
|
isSwitching={pageSwitch.isSwitching}
|
|
isNewBgReady={pageSwitch.isNewBgReady}
|
|
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
|
/>
|
|
|
|
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
|
<div className='absolute inset-0 z-10'>
|
|
{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 isNavDisabled =
|
|
isNavigationElementType(element.type) &&
|
|
(element.navDisabled ||
|
|
Boolean(transitionPreview) ||
|
|
isReverseBuffering);
|
|
|
|
return (
|
|
<CanvasElementComponent
|
|
key={element.id}
|
|
element={element}
|
|
isSelected={selectedElementId === element.id}
|
|
isEditMode={isConstructorEditMode}
|
|
isDisabled={isNavDisabled}
|
|
onClick={() => onCanvasElementClick(element)}
|
|
onMouseDown={(event) =>
|
|
onElementMouseDown(event, element.id)
|
|
}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
onGalleryCardClick={(cardIndex) =>
|
|
handleGalleryCardClick(element, cardIndex)
|
|
}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</BackdropPortalProvider>
|
|
</div>
|
|
|
|
{pages.length > 0 && hasEditorSelection && (
|
|
<ElementEditorPanel
|
|
elementEditorRef={elementEditorRef}
|
|
position={editorPosition}
|
|
isCollapsed={isEditorCollapsed}
|
|
onToggleCollapse={() => setIsEditorCollapsed((prev) => !prev)}
|
|
onDragStart={onElementEditorDragStart}
|
|
title={editorTitle}
|
|
activeTab={elementEditorTab}
|
|
onTabChange={setElementEditorTab}
|
|
selectedElement={selectedElement}
|
|
selectedMenuItem={selectedMenuItem}
|
|
onRemoveElement={removeSelectedElement}
|
|
onUpdateElement={updateSelectedElement}
|
|
backgroundImageUrl={backgroundImageUrl}
|
|
backgroundVideoUrl={backgroundVideoUrl}
|
|
backgroundAudioUrl={backgroundAudioUrl}
|
|
onBackgroundImageChange={setBackgroundImageUrl}
|
|
onBackgroundVideoChange={setBackgroundVideoUrl}
|
|
onBackgroundAudioChange={setBackgroundAudioUrl}
|
|
newTransitionName={newTransitionName}
|
|
newTransitionVideoUrl={newTransitionVideoUrl}
|
|
newTransitionSupportsReverse={newTransitionSupportsReverse}
|
|
isCreatingTransition={isCreatingTransition}
|
|
onNewTransitionNameChange={setNewTransitionName}
|
|
onNewTransitionVideoUrlChange={setNewTransitionVideoUrl}
|
|
onNewTransitionSupportsReverseChange={
|
|
setNewTransitionSupportsReverse
|
|
}
|
|
onCreateTransition={() =>
|
|
createTransition({
|
|
name: newTransitionName,
|
|
videoUrl: newTransitionVideoUrl,
|
|
supportsReverse: newTransitionSupportsReverse,
|
|
durationSec: getDuration(newTransitionVideoUrl),
|
|
})
|
|
}
|
|
backgroundVideoDurationNote={backgroundVideoDurationNote}
|
|
backgroundAudioDurationNote={backgroundAudioDurationNote}
|
|
newTransitionDurationNote={newTransitionDurationNote}
|
|
selectedMediaDurationNote={selectedMediaDurationNote}
|
|
selectedTransitionDurationNote={selectedTransitionDurationNote}
|
|
backgroundImageAssetOptions={backgroundImageAssetOptions}
|
|
videoAssetOptions={videoAssetOptions}
|
|
audioAssetOptions={audioAssetOptions}
|
|
transitionVideoAssetOptions={transitionVideoAssetOptions}
|
|
iconAssetOptions={iconAssetOptions}
|
|
imageAssetOptions={imageAssetOptions}
|
|
allowedNavigationTypes={allowedNavigationTypes}
|
|
pages={pages}
|
|
activePageId={activePageId}
|
|
onPreviewTransition={openTransitionPreview}
|
|
galleryCards={galleryCards}
|
|
galleryInfoSpans={galleryInfoSpans}
|
|
carouselSlides={carouselSlides}
|
|
normalizeNavigationType={normalizeNavigationType}
|
|
getDuration={getDuration}
|
|
/>
|
|
)}
|
|
|
|
{pages.length > 0 && !isElementEditMode && (
|
|
<ConstructorMenu
|
|
position={menuPosition}
|
|
isOpen={isMenuOpen}
|
|
allowedNavigationTypes={allowedNavigationTypes}
|
|
isCreatingPage={isCreatingPage}
|
|
isSaving={isSaving}
|
|
isSavingToStage={isSavingToStage}
|
|
onDragStart={onMenuDragStart}
|
|
onToggleOpen={() => setIsMenuOpen((prev) => !prev)}
|
|
onSelectMenuItem={selectMenuItemForEdit}
|
|
onAddElement={addElement}
|
|
onCreatePage={createPage}
|
|
onSave={saveConstructor}
|
|
onSaveToStage={saveToStage}
|
|
onExit={() =>
|
|
router.push(
|
|
projectId
|
|
? `/projects/${projectId}`
|
|
: '/projects/projects-list',
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<TransitionPreviewOverlay
|
|
videoRef={transitionVideoRef}
|
|
isActive={Boolean(transitionPreview)}
|
|
isBuffering={isReverseBuffering}
|
|
/>
|
|
|
|
{/* Gallery Carousel Overlay */}
|
|
{activeGalleryCarousel && (
|
|
<GalleryCarouselOverlay
|
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
|
initialIndex={activeGalleryCarousel.initialIndex}
|
|
onClose={() => setActiveGalleryCarousel(null)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
|
|
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
|
|
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
|
|
backLabel={
|
|
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
|
}
|
|
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
|
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
|
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
|
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
|
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
|
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
|
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
|
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
|
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
|
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
|
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
|
isEditMode={isConstructorEditMode}
|
|
onButtonPositionChange={handleCarouselButtonPositionChange}
|
|
/>
|
|
)}
|
|
|
|
<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;
|