39948-vm/frontend/src/pages/constructor.tsx
2026-04-03 13:45:15 +04:00

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;