improved constructor.tsx page structure

This commit is contained in:
Dmitri 2026-03-29 16:03:25 +04:00
parent eac21c84b3
commit 25e6a1f5d2
41 changed files with 5580 additions and 2647 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
/**
* AssetSelectCompact Component
*
* Compact asset selector for constructor forms.
* Displays a label and select dropdown with asset options.
*/
import React from 'react';
import type { AssetOption } from './types';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface AssetSelectCompactProps {
label: string;
value: string;
options: AssetOption[];
placeholder?: string;
durationNote?: string;
fallbackLabel?: string;
onChange: (value: string) => void;
}
const AssetSelectCompact: React.FC<AssetSelectCompactProps> = ({
label,
value,
options,
placeholder = 'Not selected',
durationNote,
fallbackLabel,
onChange,
}) => {
const selectOptions = addFallbackAssetOption(
options,
value,
fallbackLabel || `Current value · ${value}`,
);
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{label}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={value}
onChange={(event) => onChange(event.target.value)}
>
<option value=''>{placeholder}</option>
{selectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{durationNote && (
<p className='mt-1 text-[11px] text-gray-500'>{durationNote}</p>
)}
</div>
);
};
export default AssetSelectCompact;

View File

@ -0,0 +1,73 @@
/**
* BackgroundSettingsEditor Component
*
* Compact editor for background image, video, or audio settings.
* Used in the element editor panel for background menu items.
*/
import React from 'react';
import type { AssetOption } from './types';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio';
value: string;
options: AssetOption[];
durationNote?: string;
onChange: (value: string) => void;
onClearImage?: () => void;
}
const LABELS: Record<string, string> = {
image: 'Background image',
video: 'Background video',
audio: 'Background audio (loop)',
};
const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
type,
value,
options,
durationNote,
onChange,
onClearImage,
}) => {
const label = LABELS[type] || 'Background';
const selectOptions = addFallbackAssetOption(
options,
value,
`Current ${type} · ${value}`,
);
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{label}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={value}
onChange={(event) => {
const nextValue = event.target.value;
onChange(nextValue);
// For image, if switching to a new image, clear video (handled externally)
if (type === 'image' && nextValue && onClearImage) {
onClearImage();
}
}}
>
<option value=''>None</option>
{selectOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{durationNote && (
<p className='mt-1 text-[11px] text-gray-500'>{durationNote}</p>
)}
</div>
);
};
export default BackgroundSettingsEditor;

View File

@ -0,0 +1,110 @@
/**
* CanvasBackground Component
*
* Background image, video, and audio for the constructor canvas.
* Handles blob URLs, Next.js Image optimization, and previous background overlay.
*/
import React from 'react';
import NextImage from 'next/image';
interface CanvasBackgroundProps {
backgroundImageUrl?: string;
backgroundVideoUrl?: string;
backgroundAudioUrl?: string;
previousBgImageUrl?: string;
isSwitching?: boolean;
isNewBgReady?: boolean;
onBackgroundReady?: () => void;
}
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
previousBgImageUrl,
isSwitching = false,
isNewBgReady = false,
onBackgroundReady,
}) => {
const handleLoad = () => {
onBackgroundReady?.();
};
const handleError = () => {
onBackgroundReady?.();
};
return (
<>
{/* Background image */}
{backgroundImageUrl && (
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
onLoad={handleLoad}
onError={handleError}
/>
) : (
<NextImage
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
fill
sizes='100vw'
className='object-cover'
draggable={false}
unoptimized
onLoad={handleLoad}
onError={handleError}
/>
)}
</div>
)}
{/* Previous background overlay - shows during page switch until new bg is ready */}
{previousBgImageUrl && isSwitching && !isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${previousBgImageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
{/* Background video */}
{backgroundVideoUrl && (
<video
key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 w-full h-full object-cover'
src={backgroundVideoUrl}
autoPlay
loop
muted
playsInline
/>
)}
{/* Background audio */}
{backgroundAudioUrl && (
<audio
key={`bg_audio_${backgroundAudioUrl}`}
src={backgroundAudioUrl}
autoPlay
loop
hidden
/>
)}
</>
);
};
export default CanvasBackground;

View File

@ -0,0 +1,96 @@
/**
* CanvasElement Component
*
* Individual element rendered on the constructor canvas.
* Handles positioning, styling, and interaction modes.
*/
import React from 'react';
import ElementContentRenderer from '../UiElements/ElementContentRenderer';
import { buildElementStyle } from '../../lib/elementStyles';
import {
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isGalleryElementType,
isCarouselElementType,
} from '../../lib/elementDefaults';
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
interface CanvasElementProps {
element: CanvasElementType;
isSelected: boolean;
isEditMode: boolean;
isDisabled?: boolean;
onClick: () => void;
onMouseDown?: (event: React.MouseEvent) => void;
/** Optional URL resolver for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
}
const CanvasElement: React.FC<CanvasElementProps> = ({
element,
isSelected,
isEditMode,
isDisabled = false,
onClick,
onMouseDown,
resolveUrl,
}) => {
const hasIconDrivenSize =
Boolean(element.iconUrl) &&
(isTooltipElementType(element.type) ||
isDescriptionElementType(element.type) ||
isNavigationElementType(element.type));
const isNavigationIconElement =
Boolean(element.iconUrl) && isNavigationElementType(element.type);
const hasTransparentBackground =
(isDescriptionElementType(element.type) &&
!element.iconUrl &&
(!element.descriptionBackgroundColor ||
element.descriptionBackgroundColor === 'transparent')) ||
(isNavigationElementType(element.type) && Boolean(element.iconUrl)) ||
isTooltipElementType(element.type) ||
isGalleryElementType(element.type) ||
isCarouselElementType(element.type);
const elementStyle = buildElementStyle(element);
return (
<button
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' : ''} ${
isEditMode
? 'cursor-move'
: isDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
isSelected
? 'border-blue-500 bg-blue-50 border shadow'
: hasTransparentBackground
? 'bg-transparent'
: 'border-blue-200 bg-white/95'
}`}
style={{
...elementStyle,
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
>
<ElementContentRenderer element={element} resolveUrl={resolveUrl} />
</button>
);
};
export default CanvasElement;

View File

@ -0,0 +1,77 @@
/**
* ConstructorControlsPanel Component
*
* Draggable panel with page selector, mode toggle, and exit button.
* Used in constructor for top-level navigation controls.
*/
import React from 'react';
import BaseButton from '../BaseButton';
import { mdiExitToApp } from '@mdi/js';
import PageSelector from './PageSelector';
import InteractionModeToggle from './InteractionModeToggle';
import type { Position, TourPage, ConstructorInteractionMode } from './types';
interface ConstructorControlsPanelProps {
projectId: string;
pages: TourPage[];
activePageId: string;
interactionMode: ConstructorInteractionMode;
position: Position;
onPageChange: (pageId: string) => void;
onModeChange: (mode: ConstructorInteractionMode) => void;
onDragStart: (event: React.MouseEvent) => void;
}
const ConstructorControlsPanel: React.FC<ConstructorControlsPanelProps> = ({
projectId,
pages,
activePageId,
interactionMode,
position,
onPageChange,
onModeChange,
onDragStart,
}) => {
return (
<div
className='fixed z-40 w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
style={{
left: position.x,
top: position.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={onDragStart}
>
<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'>
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
/>
<BaseButton
color='lightDark'
label='Exit to Assets'
icon={mdiExitToApp}
href={
projectId ? `/projects/${projectId}` : '/projects/projects-list'
}
/>
</div>
<InteractionModeToggle
mode={interactionMode}
onModeChange={onModeChange}
/>
</div>
</div>
);
};
export default ConstructorControlsPanel;

View File

@ -0,0 +1,172 @@
/**
* ConstructorMenu Component
*
* Draggable menu panel with actions for adding elements, backgrounds, etc.
*/
import React from 'react';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import {
mdiMenu,
mdiImageMultiple,
mdiViewCarousel,
mdiTooltipText,
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiContentSave,
mdiExitToApp,
} from '@mdi/js';
import MenuActionButton from './MenuActionButton';
import type {
Position,
EditorMenuItem,
CanvasElementType,
NavigationElementType,
} from './types';
interface ConstructorMenuProps {
position: Position;
isOpen: boolean;
allowedNavigationTypes: NavigationElementType[];
isCreatingPage: boolean;
isSaving: boolean;
isSavingToStage: boolean;
onDragStart: (event: React.MouseEvent) => void;
onToggleOpen: () => void;
onSelectMenuItem: (item: EditorMenuItem) => void;
onAddElement: (type: CanvasElementType) => void;
onCreatePage: () => void;
onSave: () => void;
onSaveToStage: () => void;
onExit: () => void;
}
const ConstructorMenu: React.FC<ConstructorMenuProps> = ({
position,
isOpen,
allowedNavigationTypes,
isCreatingPage,
isSaving,
isSavingToStage,
onDragStart,
onToggleOpen,
onSelectMenuItem,
onAddElement,
onCreatePage,
onSave,
onSaveToStage,
onExit,
}) => {
return (
<div
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: position.x, top: position.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={onDragStart}
>
<span className='text-xs font-bold uppercase'>Constructor Menu</span>
<button type='button' onClick={onToggleOpen}>
<BaseIcon path={mdiMenu} size={18} />
</button>
</div>
{isOpen && (
<div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() => onSelectMenuItem('background_image')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Background Video'
onClick={() => onSelectMenuItem('background_video')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Background Audio'
onClick={() => onSelectMenuItem('background_audio')}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Add Navigation Button'
onClick={() => onAddElement(allowedNavigationTypes[0])}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Add Transition'
onClick={() => onSelectMenuItem('create_transition')}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Add Gallery'
onClick={() => onAddElement('gallery')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Add Carousel'
onClick={() => onAddElement('carousel')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Add Tooltip'
onClick={() => onAddElement('tooltip')}
/>
<MenuActionButton
icon={mdiText}
label='Add Description'
onClick={() => onAddElement('description')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Add Video Player'
onClick={() => onAddElement('video_player')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Add Audio Player'
onClick={() => onAddElement('audio_player')}
/>
<MenuActionButton
icon={mdiPlus}
label={isCreatingPage ? 'Creating Page...' : 'Create New Page'}
onClick={onCreatePage}
disabled={isCreatingPage}
/>
<div className='pt-2 border-t border-gray-200 space-y-1'>
<div className='flex gap-1'>
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={onSave}
disabled={isSaving}
/>
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
</div>
<MenuActionButton
icon={mdiExitToApp}
label='Exit'
onClick={onExit}
className='!text-red-700'
/>
</div>
</div>
)}
</div>
);
};
export default ConstructorMenu;

View File

@ -0,0 +1,92 @@
/**
* CreateTransitionForm Component
*
* Form for creating a new transition (legacy functionality).
* Transitions are now typically stored directly on navigation elements.
*/
import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiSwapHorizontal } from '@mdi/js';
import type { AssetOption } from './types';
interface CreateTransitionFormProps {
name: string;
videoUrl: string;
supportsReverse: boolean;
videoOptions: AssetOption[];
durationNote: string;
isCreating: boolean;
onNameChange: (name: string) => void;
onVideoUrlChange: (url: string) => void;
onSupportsReverseChange: (value: boolean) => void;
onSubmit: () => void;
}
const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
name,
videoUrl,
supportsReverse,
videoOptions,
durationNote,
isCreating,
onNameChange,
onVideoUrlChange,
onSupportsReverseChange,
onSubmit,
}) => {
return (
<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={name}
onChange={(event) => onNameChange(event.target.value)}
/>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={videoUrl}
onChange={(event) => onVideoUrlChange(event.target.value)}
>
<option value=''>Transition video asset</option>
{videoOptions.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. {durationNote}
</p>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={supportsReverse}
onChange={(event) => onSupportsReverseChange(event.target.checked)}
/>
Supports reverse playback
</label>
<button
type='button'
className='menu-action-btn'
onClick={onSubmit}
disabled={isCreating}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>
{isCreating ? 'Creating Transition...' : 'Create Transition'}
</span>
</button>
</div>
);
};
export default CreateTransitionForm;

View File

@ -0,0 +1,57 @@
/**
* ElementEditorHeader Component
*
* Draggable header for the element editor panel.
* Includes title, collapse toggle, and remove button.
*/
import React from 'react';
interface ElementEditorHeaderProps {
title: string;
isCollapsed: boolean;
showRemoveButton: boolean;
onToggleCollapse: () => void;
onRemove: () => void;
onDragStart: (event: React.MouseEvent) => void;
}
const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
title,
isCollapsed,
showRemoveButton,
onToggleCollapse,
onRemove,
onDragStart,
}) => {
return (
<div
className='mb-3 flex items-center justify-between gap-2 cursor-move'
onMouseDown={onDragStart}
>
<p className='text-xs font-bold uppercase tracking-wide text-gray-700'>
{title}
</p>
<div className='flex items-center gap-2'>
<button
type='button'
className='text-xs text-gray-700 hover:underline'
onClick={onToggleCollapse}
>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
{showRemoveButton && (
<button
type='button'
className='text-xs text-red-600 hover:underline'
onClick={onRemove}
>
Remove element
</button>
)}
</div>
</div>
);
};
export default ElementEditorHeader;

View File

@ -0,0 +1,591 @@
/**
* ElementEditorPanel Component
*
* Renders the element editor sidebar in the constructor.
* Handles element settings, background settings, and transition creation.
*/
import React from 'react';
import {
ElementSettingsTabsCompact,
StyleSettingsSectionCompact,
EffectsSettingsSectionCompact,
CommonSettingsSectionCompact,
TooltipSettingsSectionCompact,
DescriptionSettingsSectionCompact,
MediaSettingsSectionCompact,
GallerySettingsSectionCompact,
CarouselSettingsSectionCompact,
extractNumericValue,
} from '../ElementSettings';
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
import CreateTransitionForm from './CreateTransitionForm';
import ElementEditorHeader from './ElementEditorHeader';
import NavigationSettingsSectionCompact from '../ElementSettings/NavigationSettingsSectionCompact';
import {
normalizeAppearDelaySec,
normalizeAppearDurationSec,
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isMediaElementType,
isVideoPlayerElementType,
} from '../../lib/elementDefaults';
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
CarouselSlide,
} from '../../types/constructor';
type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
type TourPage = {
id: string;
name?: string;
slug?: string;
sort_order?: number;
};
interface AssetOption {
value: string;
label: string;
}
interface ElementEditorPanelProps {
// Refs and positioning
elementEditorRef: React.RefObject<HTMLDivElement | null>;
position: { x: number; y: number };
isCollapsed: boolean;
onToggleCollapse: () => void;
onDragStart: (event: React.MouseEvent) => void;
// Editor state
title: string;
activeTab: 'general' | 'css' | 'effects';
onTabChange: (tab: 'general' | 'css' | 'effects') => void;
// Selected element
selectedElement: CanvasElement | null;
selectedMenuItem: EditorMenuItem;
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
// Background settings
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundAudioUrl: string;
onBackgroundImageChange: (value: string) => void;
onBackgroundVideoChange: (value: string) => void;
onBackgroundAudioChange: (value: string) => void;
// Transition creation
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
isCreatingTransition: boolean;
onNewTransitionNameChange: (value: string) => void;
onNewTransitionVideoUrlChange: (value: string) => void;
onNewTransitionSupportsReverseChange: (value: boolean) => void;
onCreateTransition: () => void;
// Duration notes
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
newTransitionDurationNote: string;
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
// Asset options
backgroundImageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
audioAssetOptions: AssetOption[];
transitionVideoAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
imageAssetOptions: AssetOption[];
// Navigation settings
allowedNavigationTypes: NavigationElementType[];
pages: TourPage[];
activePageId: string;
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Gallery/Carousel operations
galleryCards: {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
};
carouselSlides: {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
// Navigation type normalization
normalizeNavigationType: (
element: CanvasElement,
nextType: NavigationElementType,
) => CanvasElement;
// Duration resolver
getDuration: (url: string) => number | undefined;
}
/**
* Handle CSS property changes with unit conversion
*/
const handleCssPropertyChange = (
prop: string,
value: string | number | boolean,
onUpdateElement: (patch: Partial<CanvasElement>) => void,
) => {
const numericProps = [
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'border',
'borderRadius',
];
const getUnit = (p: string) => {
if (['width', 'minWidth', 'maxWidth'].includes(p)) return 'vw';
if (['height', 'minHeight', 'maxHeight'].includes(p)) return 'vh';
if (['border', 'borderRadius'].includes(p)) return 'px';
return '';
};
if (numericProps.includes(prop)) {
const trimmed = String(value || '').trim();
if (prop === 'border') {
onUpdateElement({
[prop]: trimmed ? `${trimmed}px solid currentColor` : 'none',
});
} else if (prop === 'borderRadius') {
onUpdateElement({
[prop]: trimmed ? `${trimmed}px` : undefined,
});
} else {
const unit = getUnit(prop);
onUpdateElement({
[prop]: trimmed ? `${trimmed}${unit}` : undefined,
});
}
} else {
onUpdateElement({
[prop]: value || undefined,
});
}
};
export function ElementEditorPanel({
elementEditorRef,
position,
isCollapsed,
onToggleCollapse,
onDragStart,
title,
activeTab,
onTabChange,
selectedElement,
selectedMenuItem,
onRemoveElement,
onUpdateElement,
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
onBackgroundImageChange,
onBackgroundVideoChange,
onBackgroundAudioChange,
newTransitionName,
newTransitionVideoUrl,
newTransitionSupportsReverse,
isCreatingTransition,
onNewTransitionNameChange,
onNewTransitionVideoUrlChange,
onNewTransitionSupportsReverseChange,
onCreateTransition,
backgroundVideoDurationNote,
backgroundAudioDurationNote,
newTransitionDurationNote,
selectedMediaDurationNote,
selectedTransitionDurationNote,
backgroundImageAssetOptions,
videoAssetOptions,
audioAssetOptions,
transitionVideoAssetOptions,
iconAssetOptions,
imageAssetOptions,
allowedNavigationTypes,
pages,
activePageId,
onPreviewTransition,
galleryCards,
carouselSlides,
normalizeNavigationType,
getDuration,
}: ElementEditorPanelProps) {
return (
<div
ref={elementEditorRef}
className={`fixed z-40 ${isCollapsed ? '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: position.x, top: position.y }}
>
<ElementEditorHeader
title={title}
isCollapsed={isCollapsed}
showRemoveButton={Boolean(selectedElement)}
onToggleCollapse={onToggleCollapse}
onRemove={onRemoveElement}
onDragStart={onDragStart}
/>
{!isCollapsed && (
<>
{selectedMenuItem === 'background_image' && (
<BackgroundSettingsEditor
type='image'
value={backgroundImageUrl}
options={backgroundImageAssetOptions}
onChange={(value) => {
onBackgroundImageChange(value);
if (value) onBackgroundVideoChange('');
}}
/>
)}
{selectedMenuItem === 'background_video' && (
<BackgroundSettingsEditor
type='video'
value={backgroundVideoUrl}
options={videoAssetOptions}
durationNote={backgroundVideoDurationNote}
onChange={(value) => {
onBackgroundVideoChange(value);
if (value) onBackgroundImageChange('');
}}
/>
)}
{selectedMenuItem === 'background_audio' && (
<BackgroundSettingsEditor
type='audio'
value={backgroundAudioUrl}
options={audioAssetOptions}
durationNote={backgroundAudioDurationNote}
onChange={onBackgroundAudioChange}
/>
)}
{selectedMenuItem === 'create_transition' && (
<CreateTransitionForm
name={newTransitionName}
videoUrl={newTransitionVideoUrl}
supportsReverse={newTransitionSupportsReverse}
videoOptions={transitionVideoAssetOptions}
durationNote={newTransitionDurationNote}
isCreating={isCreatingTransition}
onNameChange={onNewTransitionNameChange}
onVideoUrlChange={onNewTransitionVideoUrlChange}
onSupportsReverseChange={onNewTransitionSupportsReverseChange}
onSubmit={onCreateTransition}
/>
)}
{selectedElement && (
<>
<ElementSettingsTabsCompact
activeTab={activeTab}
onTabChange={(tab) =>
onTabChange(tab as 'general' | 'css' | 'effects')
}
tabs={[
{ id: 'general', label: 'General' },
{ id: 'css', label: 'CSS' },
{ id: 'effects', label: 'Effects' },
]}
/>
{activeTab === 'general' && (
<>
<CommonSettingsSectionCompact
label={selectedElement.label}
xPercent={String(selectedElement.xPercent ?? 50)}
yPercent={String(selectedElement.yPercent ?? 50)}
appearDelaySec={String(selectedElement.appearDelaySec ?? 0)}
appearDurationSec={
selectedElement.appearDurationSec != null
? String(selectedElement.appearDurationSec)
: ''
}
showPosition={false}
onChange={(prop, value) => {
if (prop === 'label') {
onUpdateElement({ label: value });
} else if (prop === 'appearDelaySec') {
onUpdateElement({
appearDelaySec: normalizeAppearDelaySec(value),
});
} else if (prop === 'appearDurationSec') {
onUpdateElement({
appearDurationSec: normalizeAppearDurationSec(value),
});
}
}}
/>
{isNavigationElementType(selectedElement.type) && (
<NavigationSettingsSectionCompact
type={
selectedElement.type as
| 'navigation_next'
| 'navigation_prev'
}
navType={selectedElement.navType}
navLabel={selectedElement.navLabel || ''}
navDisabled={selectedElement.navDisabled || false}
iconUrl={selectedElement.iconUrl || ''}
targetPageSlug={selectedElement.targetPageSlug || ''}
transitionVideoUrl={
selectedElement.transitionVideoUrl || ''
}
transitionReverseMode={
selectedElement.transitionReverseMode || 'auto_reverse'
}
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={iconAssetOptions}
transitionVideoOptions={transitionVideoAssetOptions}
pages={pages}
activePageId={activePageId}
selectedMediaDurationNote={selectedMediaDurationNote}
selectedTransitionDurationNote={
selectedTransitionDurationNote
}
onChange={(prop, value) => {
if (prop === 'type') {
const nextType = value as NavigationElementType;
onUpdateElement(
normalizeNavigationType(selectedElement, nextType),
);
} else if (prop === 'transitionVideoUrl') {
const nextVideoUrl = value as string;
const resolvedDuration = getDuration(nextVideoUrl);
onUpdateElement({
transitionVideoUrl: nextVideoUrl,
transitionDurationSec:
resolvedDuration || undefined,
});
} else if (prop === 'targetPageSlug') {
onUpdateElement({
targetPageSlug: value as string,
targetPageId: '',
});
} else {
onUpdateElement({
[prop]: value,
});
}
}}
onPreviewTransition={onPreviewTransition}
/>
)}
{selectedElement &&
isTooltipElementType(selectedElement.type) && (
<TooltipSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
tooltipTitle={selectedElement.tooltipTitle || ''}
tooltipText={selectedElement.tooltipText || ''}
iconAssetOptions={iconAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isDescriptionElementType(selectedElement.type) && (
<DescriptionSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
descriptionTitle={
selectedElement.descriptionTitle || ''
}
descriptionText={selectedElement.descriptionText || ''}
descriptionTitleFontSize={
selectedElement.descriptionTitleFontSize || '48px'
}
descriptionTextFontSize={
selectedElement.descriptionTextFontSize || '36px'
}
descriptionTitleFontFamily={
selectedElement.descriptionTitleFontFamily ||
'inherit'
}
descriptionTextFontFamily={
selectedElement.descriptionTextFontFamily || 'inherit'
}
descriptionTitleColor={
selectedElement.descriptionTitleColor || '#000000'
}
descriptionTextColor={
selectedElement.descriptionTextColor || '#4B5563'
}
descriptionBackgroundColor={
selectedElement.descriptionBackgroundColor ||
'transparent'
}
iconAssetOptions={iconAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isMediaElementType(selectedElement.type) && (
<MediaSettingsSectionCompact
mediaType={
isVideoPlayerElementType(selectedElement.type)
? 'video'
: 'audio'
}
mediaUrl={selectedElement.mediaUrl || ''}
mediaAutoplay={Boolean(selectedElement.mediaAutoplay)}
mediaLoop={Boolean(selectedElement.mediaLoop)}
mediaMuted={Boolean(selectedElement.mediaMuted)}
videoAssetOptions={videoAssetOptions}
audioAssetOptions={audioAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isGalleryElementType(selectedElement.type) && (
<GallerySettingsSectionCompact
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={imageAssetOptions}
onAddCard={galleryCards.add}
onUpdateCard={galleryCards.update}
onRemoveCard={galleryCards.remove}
/>
)}
{selectedElement &&
isCarouselElementType(selectedElement.type) && (
<CarouselSettingsSectionCompact
carouselSlides={selectedElement.carouselSlides || []}
carouselPrevIconUrl={
selectedElement.carouselPrevIconUrl || ''
}
carouselNextIconUrl={
selectedElement.carouselNextIconUrl || ''
}
iconAssetOptions={iconAssetOptions}
imageAssetOptions={imageAssetOptions}
onUpdateElement={onUpdateElement}
onAddSlide={carouselSlides.add}
onUpdateSlide={carouselSlides.update}
onRemoveSlide={carouselSlides.remove}
/>
)}
</>
)}
{/* CSS Styles Tab */}
{activeTab === 'css' && (
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(selectedElement.minWidth),
maxWidth: extractNumericValue(selectedElement.maxWidth),
minHeight: extractNumericValue(selectedElement.minHeight),
maxHeight: extractNumericValue(selectedElement.maxHeight),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: selectedElement.gap || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity || '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor: selectedElement.backgroundColor || '',
color: selectedElement.color || '',
fontFamily: selectedElement.fontFamily || '',
}}
onChange={(prop, value) =>
handleCssPropertyChange(prop, value, onUpdateElement)
}
/>
)}
{/* Effects Tab */}
{activeTab === 'effects' && (
<EffectsSettingsSectionCompact
values={{
appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration:
selectedElement.appearAnimationDuration || '',
appearAnimationEasing:
selectedElement.appearAnimationEasing || '',
hoverScale: selectedElement.hoverScale || '',
hoverOpacity: selectedElement.hoverOpacity || '',
hoverBackgroundColor:
selectedElement.hoverBackgroundColor || '',
hoverColor: selectedElement.hoverColor || '',
hoverBoxShadow: selectedElement.hoverBoxShadow || '',
hoverTransitionDuration:
selectedElement.hoverTransitionDuration || '',
focusScale: selectedElement.focusScale || '',
focusOpacity: selectedElement.focusOpacity || '',
focusOutline: selectedElement.focusOutline || '',
focusBoxShadow: selectedElement.focusBoxShadow || '',
activeScale: selectedElement.activeScale || '',
activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor:
selectedElement.activeBackgroundColor || '',
}}
onChange={(prop, value) => {
onUpdateElement({
[prop]: value || undefined,
});
}}
/>
)}
</>
)}
</>
)}
</div>
);
}
export default ElementEditorPanel;

View File

@ -0,0 +1,56 @@
/**
* InteractionModeToggle Component
*
* Toggle between Edit mode and Interact mode in constructor.
*/
import React from 'react';
import type { ConstructorInteractionMode } from './types';
interface InteractionModeToggleProps {
mode: ConstructorInteractionMode;
onModeChange: (mode: ConstructorInteractionMode) => void;
}
const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
mode,
onModeChange,
}) => {
const isEditMode = mode === 'edit';
return (
<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 ${
isEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => onModeChange('edit')}
>
Edit mode
</button>
<button
type='button'
className={`border-l border-gray-300 px-3 py-1.5 ${
!isEditMode
? 'bg-blue-600 text-white'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => onModeChange('interact')}
>
Interact mode
</button>
</div>
<span className='text-[11px] text-gray-600'>
{isEditMode
? 'Drag & configure elements.'
: 'Click and interact with rendered elements.'}
</span>
</div>
);
};
export default InteractionModeToggle;

View File

@ -0,0 +1,39 @@
/**
* MenuActionButton Component
*
* Compact button for constructor menu actions.
* Used in ConstructorMenu for adding elements, backgrounds, etc.
*/
import React from 'react';
import BaseIcon from '../BaseIcon';
interface MenuActionButtonProps {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
className?: string;
}
const MenuActionButton: React.FC<MenuActionButtonProps> = ({
icon,
label,
onClick,
disabled = false,
className = '',
}) => {
return (
<button
type='button'
className={`menu-action-btn ${className}`}
onClick={onClick}
disabled={disabled}
>
<BaseIcon path={icon} size={16} />
<span>{label}</span>
</button>
);
};
export default MenuActionButton;

View File

@ -0,0 +1,39 @@
/**
* PageSelector Component
*
* Dropdown for selecting the active page in constructor.
*/
import React from 'react';
import type { TourPage } from './types';
interface PageSelectorProps {
pages: TourPage[];
activePageId: string;
onPageChange: (pageId: string) => void;
disabled?: boolean;
}
const PageSelector: React.FC<PageSelectorProps> = ({
pages,
activePageId,
onPageChange,
disabled = false,
}) => {
return (
<select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm'
value={activePageId}
onChange={(event) => onPageChange(event.target.value)}
disabled={disabled}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
);
};
export default PageSelector;

View File

@ -0,0 +1,42 @@
/**
* TransitionPreviewOverlay Component
*
* Full-screen overlay for transition video preview.
* Designed to work with useTransitionPlayback hook which manages
* video src and playback externally via the videoRef.
*/
import React from 'react';
interface TransitionPreviewOverlayProps {
/** Reference to the video element - useTransitionPlayback manages src and playback */
videoRef: React.RefObject<HTMLVideoElement | null>;
/** Whether the overlay is visible */
isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */
isBuffering?: boolean;
}
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef,
isActive,
isBuffering = false,
}) => {
if (!isActive) return null;
return (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={videoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{ opacity: isBuffering ? 0 : 1 }}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
);
};
export default TransitionPreviewOverlay;

View File

@ -0,0 +1,9 @@
/**
* Constructor Components Index
*
* Types only - import components directly from their files:
* import CanvasElement from './Constructor/CanvasElement';
* import ConstructorMenu from './Constructor/ConstructorMenu';
*/
export * from './types';

View File

@ -0,0 +1,312 @@
/**
* Constructor Component Types
*
* Shared types for constructor components.
*/
import type {
CanvasElement,
CanvasElementType,
ConstructorAsset,
AssetOption,
GalleryCard,
CarouselSlide,
NavigationButtonKind,
} from '../../types/constructor';
/**
* Constructor interaction mode
*/
export type ConstructorInteractionMode = 'edit' | 'interact';
/**
* Editor menu item types
*/
export type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
/**
* Editor tab types
*/
export type ElementEditorTab = 'general' | 'css' | 'effects';
/**
* Tour page type
*/
export interface 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;
}
/**
* Navigation element type
*/
export type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
/**
* Position in pixels
*/
export interface Position {
x: number;
y: number;
}
/**
* Page selector props
*/
export interface PageSelectorProps {
pages: TourPage[];
activePageId: string;
onPageChange: (pageId: string) => void;
disabled?: boolean;
}
/**
* Interaction mode toggle props
*/
export interface InteractionModeToggleProps {
mode: ConstructorInteractionMode;
onModeChange: (mode: ConstructorInteractionMode) => void;
}
/**
* Controls panel props
*/
export interface ConstructorControlsPanelProps {
projectId: string;
projectName: string;
pages: TourPage[];
activePageId: string;
interactionMode: ConstructorInteractionMode;
position: Position;
onPositionChange: (position: Position) => void;
onPageChange: (pageId: string) => void;
onModeChange: (mode: ConstructorInteractionMode) => void;
onDragStart: (event: React.MouseEvent) => void;
}
/**
* Canvas element props
*/
export interface CanvasElementProps {
element: CanvasElement;
isSelected: boolean;
isEditMode: boolean;
isDisabled?: boolean;
canvasElapsedSec: number;
preloadedIconUrl: boolean;
onClick: (element: CanvasElement) => void;
onMouseDown?: (event: React.MouseEvent, elementId: string) => void;
}
/**
* Canvas background props
*/
export interface CanvasBackgroundProps {
backgroundImageUrl?: string;
backgroundVideoUrl?: string;
backgroundAudioUrl?: string;
previousBgImageUrl?: string;
isSwitching?: boolean;
isNewBgReady?: boolean;
onBackgroundReady?: () => void;
}
/**
* Constructor canvas props
*/
export interface ConstructorCanvasProps {
canvasRef: React.RefObject<HTMLDivElement>;
elements: CanvasElement[];
selectedElementId: string;
isEditMode: boolean;
isLoading: boolean;
canvasElapsedSec: number;
preloadedIconUrlMap: Record<string, boolean>;
background: CanvasBackgroundProps;
transitionPreview: boolean;
isReverseBuffering: boolean;
onElementClick: (element: CanvasElement) => void;
onElementMouseDown: (event: React.MouseEvent, elementId: string) => void;
onCreatePage: () => void;
isCreatingPage: boolean;
}
/**
* Element editor header props
*/
export interface ElementEditorHeaderProps {
title: string;
isCollapsed: boolean;
showRemoveButton: boolean;
onToggleCollapse: () => void;
onRemove: () => void;
onDragStart: (event: React.MouseEvent) => void;
}
/**
* Background settings editor props
*/
export interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio';
value: string;
options: AssetOption[];
durationNote?: string;
onChange: (value: string) => void;
onImageClear?: () => void;
}
/**
* Create transition form props
*/
export interface CreateTransitionFormProps {
name: string;
videoUrl: string;
supportsReverse: boolean;
videoOptions: AssetOption[];
durationNote: string;
isCreating: boolean;
onNameChange: (name: string) => void;
onVideoUrlChange: (url: string) => void;
onSupportsReverseChange: (value: boolean) => void;
onSubmit: () => void;
}
/**
* Element editor panel props
*/
export interface ElementEditorPanelProps {
position: Position;
isCollapsed: boolean;
activeTab: ElementEditorTab;
selectedElement: CanvasElement | null;
selectedMenuItem: EditorMenuItem;
// Background settings
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundAudioUrl: string;
backgroundImageOptions: AssetOption[];
backgroundVideoOptions: AssetOption[];
backgroundAudioOptions: AssetOption[];
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
// Transition form
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
transitionVideoOptions: AssetOption[];
newTransitionDurationNote: string;
isCreatingTransition: boolean;
// Asset options for elements
imageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
audioAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
// Pages for navigation target selection
pages: TourPage[];
activePageId: string;
// Duration notes
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
// Callbacks
onPositionChange: (position: Position) => void;
onDragStart: (event: React.MouseEvent) => void;
onToggleCollapse: () => void;
onTabChange: (tab: ElementEditorTab) => void;
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
onUpdateBackgroundImage: (url: string) => void;
onUpdateBackgroundVideo: (url: string) => void;
onUpdateBackgroundAudio: (url: string) => void;
onUpdateTransitionName: (name: string) => void;
onUpdateTransitionVideoUrl: (url: string) => void;
onUpdateTransitionSupportsReverse: (value: boolean) => void;
onCreateTransition: () => void;
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Gallery operations
onAddGalleryCard: () => void;
onUpdateGalleryCard: (cardId: string, patch: Partial<GalleryCard>) => void;
onRemoveGalleryCard: (cardId: string) => void;
// Carousel operations
onAddCarouselSlide: () => void;
onUpdateCarouselSlide: (
slideId: string,
patch: Partial<CarouselSlide>,
) => void;
onRemoveCarouselSlide: (slideId: string) => void;
}
/**
* Constructor menu props
*/
export interface ConstructorMenuProps {
position: Position;
isOpen: boolean;
allowedNavigationTypes: NavigationElementType[];
isCreatingPage: boolean;
onPositionChange: (position: Position) => void;
onDragStart: (event: React.MouseEvent) => void;
onToggleOpen: () => void;
onSelectMenuItem: (item: EditorMenuItem) => void;
onAddElement: (type: CanvasElementType) => void;
onCreatePage: () => void;
onSave: () => void;
onSaveToStage: () => void;
isSaving: boolean;
isSavingToStage: boolean;
}
/**
* Menu action button props
*/
export interface MenuActionButtonProps {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
}
/**
* Transition preview overlay props
*/
export interface TransitionPreviewOverlayProps {
videoRef: React.RefObject<HTMLVideoElement>;
isActive: boolean;
title?: string;
}
// Re-export types from constructor.ts
export type {
CanvasElement,
CanvasElementType,
ConstructorAsset,
AssetOption,
GalleryCard,
CarouselSlide,
NavigationButtonKind,
};

View File

@ -0,0 +1,156 @@
/**
* CarouselSettingsSectionCompact
*
* Compact carousel element settings for constructor sidebar.
* Navigation icons and slide management.
*/
import React from 'react';
import type { CarouselSlide, AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface CarouselSettingsSectionCompactProps {
carouselSlides: CarouselSlide[];
carouselPrevIconUrl: string;
carouselNextIconUrl: string;
iconAssetOptions: AssetOption[];
imageAssetOptions: AssetOption[];
onUpdateElement: (patch: {
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
}) => void;
onAddSlide: () => void;
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
onRemoveSlide: (slideId: string) => void;
}
const CarouselSettingsSectionCompact: React.FC<
CarouselSettingsSectionCompactProps
> = ({
carouselSlides,
carouselPrevIconUrl,
carouselNextIconUrl,
iconAssetOptions,
imageAssetOptions,
onUpdateElement,
onAddSlide,
onUpdateSlide,
onRemoveSlide,
}) => {
return (
<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={carouselPrevIconUrl}
onChange={(event) =>
onUpdateElement({ carouselPrevIconUrl: event.target.value })
}
>
<option value=''>Previous icon</option>
{addFallbackAssetOption(
iconAssetOptions,
carouselPrevIconUrl,
`Current prev icon · ${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={carouselNextIconUrl}
onChange={(event) =>
onUpdateElement({ carouselNextIconUrl: event.target.value })
}
>
<option value=''>Next icon</option>
{addFallbackAssetOption(
iconAssetOptions,
carouselNextIconUrl,
`Current next icon · ${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={onAddSlide}
>
+ Add slide
</button>
</div>
{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={() => onRemoveSlide(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) =>
onUpdateSlide(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) =>
onUpdateSlide(slide.id, { caption: event.target.value })
}
/>
</div>
))}
{carouselSlides.length === 0 && (
<p className='text-[11px] text-gray-500'>
No slides yet. Click &quot;+ Add slide&quot; to create one.
</p>
)}
</div>
);
};
export default CarouselSettingsSectionCompact;

View File

@ -0,0 +1,68 @@
/**
* CommonSettingsSectionCompact
*
* Compact version of common settings fields for constructor sidebar.
* Label and appear timing in a space-efficient layout.
*/
import React from 'react';
import type { CommonSettingsSectionProps } from './types';
const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
label,
appearDelaySec,
appearDurationSec,
onChange,
showLabel = true,
}) => {
return (
<div className='mb-2 space-y-2'>
{showLabel && (
<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={label}
onChange={(event) => onChange('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={appearDelaySec ?? 0}
onChange={(event) => onChange('appearDelaySec', 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={appearDurationSec ?? ''}
onChange={(event) =>
onChange('appearDurationSec', event.target.value)
}
/>
<p className='mt-1 text-[11px] text-gray-500'>
Leave empty for unlimited.
</p>
</div>
</div>
);
};
export default CommonSettingsSectionCompact;

View File

@ -0,0 +1,191 @@
/**
* DescriptionSettingsSectionCompact
*
* Compact description element settings for constructor sidebar.
* Icon, title, text, font sizes, font families, colors, and background.
*/
import React from 'react';
import type { AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface DescriptionSettingsSectionCompactProps {
iconUrl: string;
descriptionTitle: string;
descriptionText: string;
descriptionTitleFontSize: string;
descriptionTextFontSize: string;
descriptionTitleFontFamily: string;
descriptionTextFontFamily: string;
descriptionTitleColor: string;
descriptionTextColor: string;
descriptionBackgroundColor: string;
iconAssetOptions: AssetOption[];
onChange: (prop: string, value: string) => void;
}
const DescriptionSettingsSectionCompact: React.FC<
DescriptionSettingsSectionCompactProps
> = ({
iconUrl,
descriptionTitle,
descriptionText,
descriptionTitleFontSize,
descriptionTextFontSize,
descriptionTitleFontFamily,
descriptionTextFontFamily,
descriptionTitleColor,
descriptionTextColor,
descriptionBackgroundColor,
iconAssetOptions,
onChange,
}) => {
return (
<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={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
iconUrl,
`Current icon · ${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={descriptionTitle}
onChange={(event) => onChange('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={descriptionText}
onChange={(event) => onChange('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={descriptionTitleFontSize}
onChange={(event) =>
onChange('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={descriptionTextFontSize}
onChange={(event) =>
onChange('descriptionTextFontSize', event.target.value)
}
placeholder='e.g. 36px'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title font family
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={descriptionTitleFontFamily}
onChange={(event) =>
onChange('descriptionTitleFontFamily', event.target.value)
}
placeholder='e.g. Arial, Helvetica, sans-serif'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text font family
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={descriptionTextFontFamily}
onChange={(event) =>
onChange('descriptionTextFontFamily', event.target.value)
}
placeholder='e.g. Arial, Helvetica, sans-serif'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Title color
</label>
<input
type='color'
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
value={descriptionTitleColor || '#000000'}
onChange={(event) =>
onChange('descriptionTitleColor', event.target.value)
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text color
</label>
<input
type='color'
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
value={descriptionTextColor || '#4B5563'}
onChange={(event) =>
onChange('descriptionTextColor', event.target.value)
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Background color
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={descriptionBackgroundColor}
onChange={(event) =>
onChange('descriptionBackgroundColor', event.target.value)
}
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
/>
</div>
</div>
);
};
export default DescriptionSettingsSectionCompact;

View File

@ -0,0 +1,109 @@
/**
* GallerySettingsSectionCompact
*
* Compact gallery element settings for constructor sidebar.
* Card management with image, title, and description fields.
*/
import React from 'react';
import type { GalleryCard, AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface GallerySettingsSectionCompactProps {
galleryCards: GalleryCard[];
imageAssetOptions: AssetOption[];
onAddCard: () => void;
onUpdateCard: (cardId: string, patch: Partial<GalleryCard>) => void;
onRemoveCard: (cardId: string) => void;
}
const GallerySettingsSectionCompact: React.FC<
GallerySettingsSectionCompactProps
> = ({
galleryCards,
imageAssetOptions,
onAddCard,
onUpdateCard,
onRemoveCard,
}) => {
return (
<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={onAddCard}
>
+ Add card
</button>
</div>
{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={() => onRemoveCard(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) =>
onUpdateCard(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) =>
onUpdateCard(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) =>
onUpdateCard(card.id, { description: event.target.value })
}
/>
</div>
))}
{galleryCards.length === 0 && (
<p className='text-[11px] text-gray-500'>
No cards yet. Click &quot;+ Add card&quot; to create one.
</p>
)}
</div>
);
};
export default GallerySettingsSectionCompact;

View File

@ -0,0 +1,95 @@
/**
* MediaSettingsSectionCompact
*
* Compact media (video/audio) player settings for constructor sidebar.
* Media asset selection and playback options.
*/
import React from 'react';
import type { AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface MediaSettingsSectionCompactProps {
mediaType: 'video' | 'audio';
mediaUrl: string;
mediaAutoplay: boolean;
mediaLoop: boolean;
mediaMuted: boolean;
videoAssetOptions: AssetOption[];
audioAssetOptions: AssetOption[];
onChange: (prop: string, value: string | boolean) => void;
}
const MediaSettingsSectionCompact: React.FC<
MediaSettingsSectionCompactProps
> = ({
mediaType,
mediaUrl,
mediaAutoplay,
mediaLoop,
mediaMuted,
videoAssetOptions,
audioAssetOptions,
onChange,
}) => {
const assetOptions =
mediaType === 'video' ? videoAssetOptions : audioAssetOptions;
const assetLabel = mediaType === 'video' ? 'Video asset' : 'Audio asset';
return (
<div className='space-y-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{assetLabel}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={mediaUrl}
onChange={(event) => onChange('mediaUrl', event.target.value)}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
assetOptions,
mediaUrl,
`Current media · ${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={mediaAutoplay}
onChange={(event) => onChange('mediaAutoplay', event.target.checked)}
/>
Autoplay
</label>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={mediaLoop}
onChange={(event) => onChange('mediaLoop', event.target.checked)}
/>
Loop
</label>
{mediaType === 'video' && (
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={mediaMuted}
onChange={(event) => onChange('mediaMuted', event.target.checked)}
/>
Muted
</label>
)}
</div>
);
};
export default MediaSettingsSectionCompact;

View File

@ -0,0 +1,289 @@
/**
* NavigationSettingsSectionCompact
*
* Compact navigation element settings for constructor sidebar.
* Includes type, button text, icon, target page, and transition settings.
*/
import React from 'react';
import BaseButton from '../BaseButton';
import type {
AssetOption,
NavigationButtonKind,
CanvasElementType,
} from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
interface NavigationSettingsSectionCompactProps {
type: NavigationElementType;
navType?: NavigationButtonKind;
navLabel: string;
navDisabled: boolean;
iconUrl: string;
targetPageSlug: string;
transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string;
allowedNavigationTypes: NavigationElementType[];
iconAssetOptions: AssetOption[];
transitionVideoOptions: AssetOption[];
pages: Array<{ id: string; slug?: string; name?: string }>;
activePageId: string;
selectedMediaDurationNote?: string;
selectedTransitionDurationNote?: string;
onChange: (
prop: string,
value:
| string
| boolean
| number
| Partial<{
type: NavigationElementType;
navType: NavigationButtonKind;
label?: string;
navLabel?: string;
}>,
) => void;
onPreviewTransition?: (direction: 'forward' | 'back') => void;
}
const NavigationSettingsSectionCompact: React.FC<
NavigationSettingsSectionCompactProps
> = ({
type,
navType,
navLabel,
navDisabled,
iconUrl,
targetPageSlug,
transitionVideoUrl,
transitionReverseMode,
reverseVideoUrl,
allowedNavigationTypes,
iconAssetOptions,
transitionVideoOptions,
pages,
activePageId,
selectedMediaDurationNote,
selectedTransitionDurationNote,
onChange,
onPreviewTransition,
}) => {
const currentKind: NavigationButtonKind =
navType || (type === 'navigation_prev' ? 'back' : 'forward');
return (
<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={currentKind}
onChange={(event) => {
const requestedKind: NavigationButtonKind =
event.target.value === 'back' ? 'back' : 'forward';
const requestedType: NavigationElementType =
requestedKind === 'back' ? 'navigation_prev' : 'navigation_next';
const nextType = allowedNavigationTypes.includes(requestedType)
? requestedType
: allowedNavigationTypes[0];
onChange('type', 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={navLabel}
onChange={(event) => onChange('navLabel', event.target.value)}
/>
</div>
<div>
<label className='flex items-center gap-2 text-[11px] font-semibold text-gray-600'>
<input
type='checkbox'
checked={navDisabled}
onChange={(event) => onChange('navDisabled', event.target.checked)}
/>
Disabled
</label>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Icon
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
iconUrl,
`Current icon · ${iconUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedMediaDurationNote && (
<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={targetPageSlug}
onChange={(event) => {
onChange('targetPageSlug', event.target.value);
onChange('targetPageId', ''); // Clear legacy ID
}}
>
<option value=''>Not selected</option>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionVideoUrl}
onChange={(event) => {
onChange('transitionVideoUrl', event.target.value);
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
transitionVideoUrl,
`Current video · ${transitionVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedTransitionDurationNote && (
<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={transitionReverseMode}
onChange={(event) =>
onChange(
'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>
{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={reverseVideoUrl}
onChange={(event) =>
onChange('reverseVideoUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
reverseVideoUrl,
`Current back video · ${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.
</p>
{onPreviewTransition && (
<div className='flex gap-2 pt-1'>
<BaseButton
small
color='lightDark'
label='Preview Forward'
onClick={() => onPreviewTransition('forward')}
/>
<BaseButton
small
color='lightDark'
label='Preview Back'
onClick={() => onPreviewTransition('back')}
/>
</div>
)}
</div>
);
};
export default NavigationSettingsSectionCompact;

View File

@ -0,0 +1,73 @@
/**
* TooltipSettingsSectionCompact
*
* Compact tooltip element settings for constructor sidebar.
* Icon, title, and text fields.
*/
import React from 'react';
import type { AssetOption } from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface TooltipSettingsSectionCompactProps {
iconUrl: string;
tooltipTitle: string;
tooltipText: string;
iconAssetOptions: AssetOption[];
onChange: (prop: string, value: string) => void;
}
const TooltipSettingsSectionCompact: React.FC<
TooltipSettingsSectionCompactProps
> = ({ iconUrl, tooltipTitle, tooltipText, iconAssetOptions, onChange }) => {
return (
<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={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
iconAssetOptions,
iconUrl,
`Current icon · ${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={tooltipTitle}
onChange={(event) => onChange('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={tooltipText}
onChange={(event) => onChange('tooltipText', event.target.value)}
/>
</div>
</div>
);
};
export default TooltipSettingsSectionCompact;

View File

@ -14,12 +14,19 @@ export { default as StyleSettingsSectionCompact } from './StyleSettingsSectionCo
export { default as EffectsSettingsSection } from './EffectsSettingsSection'; export { default as EffectsSettingsSection } from './EffectsSettingsSection';
export { default as EffectsSettingsSectionCompact } from './EffectsSettingsSectionCompact'; export { default as EffectsSettingsSectionCompact } from './EffectsSettingsSectionCompact';
export { default as CommonSettingsSection } from './CommonSettingsSection'; export { default as CommonSettingsSection } from './CommonSettingsSection';
export { default as CommonSettingsSectionCompact } from './CommonSettingsSectionCompact';
export { default as NavigationSettingsSection } from './NavigationSettingsSection'; export { default as NavigationSettingsSection } from './NavigationSettingsSection';
export { default as NavigationSettingsSectionCompact } from './NavigationSettingsSectionCompact';
export { default as TooltipSettingsSection } from './TooltipSettingsSection'; export { default as TooltipSettingsSection } from './TooltipSettingsSection';
export { default as TooltipSettingsSectionCompact } from './TooltipSettingsSectionCompact';
export { default as DescriptionSettingsSection } from './DescriptionSettingsSection'; export { default as DescriptionSettingsSection } from './DescriptionSettingsSection';
export { default as DescriptionSettingsSectionCompact } from './DescriptionSettingsSectionCompact';
export { default as MediaSettingsSection } from './MediaSettingsSection'; export { default as MediaSettingsSection } from './MediaSettingsSection';
export { default as MediaSettingsSectionCompact } from './MediaSettingsSectionCompact';
export { default as GallerySettingsSection } from './GallerySettingsSection'; export { default as GallerySettingsSection } from './GallerySettingsSection';
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
export { default as CarouselSettingsSection } from './CarouselSettingsSection'; export { default as CarouselSettingsSection } from './CarouselSettingsSection';
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
// Hook // Hook
export { useElementSettingsForm } from './useElementSettingsForm'; export { useElementSettingsForm } from './useElementSettingsForm';

View File

@ -0,0 +1,111 @@
/**
* FormFieldCompact Component
*
* Compact form field with label for constructor and settings editors.
* Supports text, number, textarea, and color input types.
*/
import React from 'react';
type FieldType = 'text' | 'number' | 'textarea' | 'color' | 'checkbox';
interface FormFieldCompactProps {
label: string;
value: string | number | boolean;
type?: FieldType;
placeholder?: string;
hint?: string;
min?: number;
max?: number;
step?: number;
rows?: number;
disabled?: boolean;
onChange: (value: string | boolean) => void;
}
const FormFieldCompact: React.FC<FormFieldCompactProps> = ({
label,
value,
type = 'text',
placeholder,
hint,
min,
max,
step,
rows = 4,
disabled = false,
onChange,
}) => {
if (type === 'checkbox') {
return (
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={Boolean(value)}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
if (type === 'color') {
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{label}
</label>
<input
type='color'
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
value={String(value || '#000000')}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
{hint && <p className='mt-1 text-[11px] text-gray-500'>{hint}</p>}
</div>
);
}
if (type === 'textarea') {
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{label}
</label>
<textarea
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
rows={rows}
value={String(value || '')}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
{hint && <p className='mt-1 text-[11px] text-gray-500'>{hint}</p>}
</div>
);
}
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{label}
</label>
<input
type={type}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={String(value ?? '')}
placeholder={placeholder}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
{hint && <p className='mt-1 text-[11px] text-gray-500'>{hint}</p>}
</div>
);
};
export default FormFieldCompact;

View File

@ -47,20 +47,16 @@ export default function RuntimePresentation({
environment, environment,
}: RuntimePresentationProps) { }: RuntimePresentationProps) {
// Use shared hook for loading project and pages data // Use shared hook for loading project and pages data
const { const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
project, {
pages, projectSlug,
isLoading, environment,
error, apiHeaders: {
initialPageId, 'X-Runtime-Project-Slug': projectSlug,
} = usePageDataLoader({ 'X-Runtime-Environment': environment,
projectSlug, },
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
}, },
}); );
const [selectedPageId, setSelectedPageId] = useState<string | null>(null); const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]); const [pageHistory, setPageHistory] = useState<string[]>([]);
@ -303,7 +299,9 @@ export default function RuntimePresentation({
const handleElementClick = useCallback( const handleElementClick = useCallback(
(element: any) => { (element: any) => {
// Block navigation while transition is actively playing or buffering // Block navigation while transition is actively playing or buffering
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) { if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
) {
return; return;
} }
@ -331,10 +329,28 @@ export default function RuntimePresentation({
[navigateToPage, pages, transitionPhase, isBuffering], [navigateToPage, pages, transitionPhase, isBuffering],
); );
// URL resolver that uses preloaded blob URLs when available (instant display)
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],
);
// Render element content based on type // Render element content based on type
// Use shared ElementContentRenderer for WYSIWYG consistency with constructor // Use shared ElementContentRenderer for WYSIWYG consistency with constructor
const renderElementContent = (element: any) => ( const renderElementContent = (element: any) => (
<ElementContentRenderer element={element} /> <ElementContentRenderer element={element} resolveUrl={resolveUrlWithBlob} />
); );
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs) // Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)

View File

@ -28,6 +28,8 @@ import {
export interface ElementContentRendererProps { export interface ElementContentRendererProps {
element: CanvasElement; element: CanvasElement;
/** Optional URL resolver - use for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
} }
/** /**
@ -37,7 +39,10 @@ export interface ElementContentRendererProps {
*/ */
export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
element, element,
resolveUrl,
}) => { }) => {
// Use custom resolver if provided, otherwise fallback to standard resolution
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// Navigation buttons (navigation_next, navigation_prev) // Navigation buttons (navigation_next, navigation_prev)
if (isNavigationElementType(element.type)) { if (isNavigationElementType(element.type)) {
if (element.iconUrl) { if (element.iconUrl) {
@ -50,7 +55,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolve(element.iconUrl)}
alt='Navigation' alt='Navigation'
style={imgStyle} style={imgStyle}
draggable={false} draggable={false}
@ -77,7 +82,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolve(element.iconUrl)}
alt='Tooltip' alt='Tooltip'
style={imgStyle} style={imgStyle}
draggable={false} draggable={false}
@ -104,7 +109,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolve(element.iconUrl)}
alt='Description' alt='Description'
style={imgStyle} style={imgStyle}
draggable={false} draggable={false}
@ -144,7 +149,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
<video <video
className='w-full h-full object-cover rounded' className='w-full h-full object-cover rounded'
src={resolveAssetPlaybackUrl(element.mediaUrl)} src={resolve(element.mediaUrl)}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
@ -159,7 +164,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
<audio <audio
className='w-full' className='w-full'
src={resolveAssetPlaybackUrl(element.mediaUrl)} src={resolve(element.mediaUrl)}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
@ -173,11 +178,14 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'> <div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
{cards.map((card) => ( {cards.map((card) => (
<div key={card.id} className='relative aspect-square min-w-[40px] min-h-[40px]'> <div
key={card.id}
className='relative aspect-square min-w-[40px] min-h-[40px]'
>
{card.imageUrl && ( {card.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(card.imageUrl)} src={resolve(card.imageUrl)}
alt={card.title || ''} alt={card.title || ''}
className='absolute inset-0 w-full h-full object-cover rounded' className='absolute inset-0 w-full h-full object-cover rounded'
draggable={false} draggable={false}
@ -198,7 +206,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
{firstSlide?.imageUrl && ( {firstSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)} src={resolve(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'} alt={firstSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded' className='w-full h-full object-cover rounded'
draggable={false} draggable={false}
@ -220,7 +228,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
<div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'> <div className='absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8'>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)} src={resolve(element.carouselPrevIconUrl)}
alt='Previous' alt='Previous'
className='w-full h-full object-contain' className='w-full h-full object-contain'
draggable={false} draggable={false}
@ -231,7 +239,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
<div className='absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8'> <div className='absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8'>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)} src={resolve(element.carouselNextIconUrl)}
alt='Next' alt='Next'
className='w-full h-full object-contain' className='w-full h-full object-contain'
draggable={false} draggable={false}
@ -253,7 +261,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolve(element.iconUrl)}
alt='Logo' alt='Logo'
style={imgStyle} style={imgStyle}
draggable={false} draggable={false}
@ -262,9 +270,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
} }
// Text-only logo - no background here, parent element handles styling // Text-only logo - no background here, parent element handles styling
return ( return (
<span className='px-4 py-2 font-bold'> <span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
{element.label || 'LOGO'}
</span>
); );
} }
@ -279,7 +285,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolve(element.iconUrl)}
alt='Hotspot' alt='Hotspot'
style={imgStyle} style={imgStyle}
draggable={false} draggable={false}
@ -295,17 +301,13 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
// Popup - no background here, parent element handles styling // Popup - no background here, parent element handles styling
if (isPopupElementType(element.type)) { if (isPopupElementType(element.type)) {
return ( return (
<span className='px-4 py-2 text-sm'> <span className='px-4 py-2 text-sm'>{element.label || 'Popup'}</span>
{element.label || 'Popup'}
</span>
); );
} }
// Fallback for unknown types - no background here, parent element handles styling // Fallback for unknown types - no background here, parent element handles styling
return ( return (
<span className='px-4 py-2 text-sm'> <span className='px-4 py-2 text-sm'>{element.label || element.type}</span>
{element.label || element.type}
</span>
); );
}; };

View File

@ -31,3 +31,14 @@ export type {
UsePageDataLoaderOptions, UsePageDataLoaderOptions,
UsePageDataLoaderResult, UsePageDataLoaderResult,
} from './usePageDataLoader'; } from './usePageDataLoader';
// Constructor hooks - import directly for better tree-shaking:
// import { useOutsideClick } from '../hooks/useOutsideClick';
// import { useCanvasElapsedTime } from '../hooks/useCanvasElapsedTime';
// import { useDraggable } from '../hooks/useDraggable';
// import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag';
// import { useMediaDurationProbe } from '../hooks/useMediaDurationProbe';
// import { useIconPreload } from '../hooks/useIconPreload';
// import { useTransitionPreview } from '../hooks/useTransitionPreview';
// import { useConstructorElements } from '../hooks/useConstructorElements';
// import { useConstructorPageActions } from '../hooks/useConstructorPageActions';

View File

@ -0,0 +1,111 @@
/**
* useCanvasElapsedTime Hook
*
* Tracks elapsed time since page load for element visibility timing.
* Used in constructor.tsx to control element appear/disappear based on timing.
*/
import { useState, useEffect, useRef } from 'react';
interface UseCanvasElapsedTimeOptions {
/** Identifier for the current page (resets timer on change) */
pageId: string;
/** Whether the timer is active */
enabled?: boolean;
/** Update interval in milliseconds (default: 100ms) */
intervalMs?: number;
}
interface UseCanvasElapsedTimeResult {
/** Current elapsed time in seconds since page load */
elapsedSec: number;
/** Reset the elapsed time to zero */
reset: () => void;
/** Timestamp when the current page started (for external calculations) */
startedAt: number;
}
/**
* Hook to track elapsed time since page load.
* Resets when pageId changes. Used for element visibility timing.
*
* @example
* const { elapsedSec } = useCanvasElapsedTime({
* pageId: activePageId,
* enabled: !isLoading,
* });
*
* // Check if element should be visible
* const isVisible = elapsedSec >= element.appearDelaySec;
*/
export function useCanvasElapsedTime({
pageId,
enabled = true,
intervalMs = 100,
}: UseCanvasElapsedTimeOptions): UseCanvasElapsedTimeResult {
const [elapsedSec, setElapsedSec] = useState(0);
const startedAtRef = useRef<number>(Date.now());
// Reset timer when pageId changes
useEffect(() => {
if (typeof window === 'undefined') return;
if (!enabled || !pageId) {
setElapsedSec(0);
return;
}
startedAtRef.current = Date.now();
setElapsedSec(0);
const intervalId = window.setInterval(() => {
const elapsed = (Date.now() - startedAtRef.current) / 1000;
setElapsedSec(elapsed > 0 ? elapsed : 0);
}, intervalMs);
return () => window.clearInterval(intervalId);
}, [pageId, enabled, intervalMs]);
const reset = () => {
startedAtRef.current = Date.now();
setElapsedSec(0);
};
return {
elapsedSec,
reset,
startedAt: startedAtRef.current,
};
}
/**
* Check if an element should be visible based on timing settings.
*
* @param elapsedSec - Current elapsed time in seconds
* @param appearDelaySec - Delay before element appears (default: 0)
* @param appearDurationSec - Duration element is visible (null = infinite)
* @returns Whether the element should be visible
*/
export function isElementVisibleAtTime(
elapsedSec: number,
appearDelaySec?: number,
appearDurationSec?: number | null,
): boolean {
const delay = Number(appearDelaySec || 0);
// Not yet visible
if (elapsedSec < delay) return false;
// No duration limit - always visible after delay
if (appearDurationSec === null || appearDurationSec === undefined) {
return true;
}
const duration = Number(appearDurationSec);
if (!Number.isFinite(duration) || duration <= 0) return true;
// Check if still within visibility window
return elapsedSec <= delay + duration;
}
export default useCanvasElapsedTime;

View File

@ -0,0 +1,150 @@
/**
* useCanvasElementDrag Hook
*
* Handles dragging of canvas elements with percentage-based positioning.
* Used in constructor.tsx for repositioning UI elements on the canvas.
*/
import { useRef, useEffect, useCallback, RefObject } from 'react';
interface ElementDragState {
id: string;
pointerOffsetX: number;
pointerOffsetY: number;
}
interface UseCanvasElementDragOptions {
/** Ref to the canvas container element */
canvasRef: RefObject<HTMLElement | null>;
/** Callback when an element's position changes */
onPositionChange: (
elementId: string,
xPercent: number,
yPercent: number,
) => void;
/** Whether dragging is enabled (e.g., only in edit mode) */
enabled?: boolean;
}
interface UseCanvasElementDragResult {
/** Handler to attach to element's onMouseDown */
onElementDragStart: (
event: React.MouseEvent,
elementId: string,
currentXPercent: number,
currentYPercent: number,
) => void;
/** Whether currently dragging an element */
isDragging: boolean;
/** ID of the currently dragged element (or null) */
draggedElementId: string | null;
/** Cancel any active drag */
cancelDrag: () => void;
}
/**
* Clamp a value between min and max
*/
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Hook for dragging canvas elements with percentage-based positioning.
* Converts pixel coordinates to percentages relative to canvas bounds.
*
* @example
* const { onElementDragStart, isDragging } = useCanvasElementDrag({
* canvasRef,
* onPositionChange: (id, x, y) => {
* setElements(prev => prev.map(el =>
* el.id === id ? { ...el, xPercent: x, yPercent: y } : el
* ));
* },
* enabled: isEditMode,
* });
*
* return (
* <button
* onMouseDown={(e) => onElementDragStart(e, element.id, element.xPercent, element.yPercent)}
* >
* {element.label}
* </button>
* );
*/
export function useCanvasElementDrag({
canvasRef,
onPositionChange,
enabled = true,
}: UseCanvasElementDragOptions): UseCanvasElementDragResult {
const dragRef = useRef<ElementDragState | null>(null);
// Handle pointer move
useEffect(() => {
if (!enabled) {
dragRef.current = null;
return;
}
const onPointerMove = (event: MouseEvent) => {
if (!dragRef.current || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const rawX = event.clientX - rect.left - dragRef.current.pointerOffsetX;
const rawY = event.clientY - rect.top - dragRef.current.pointerOffsetY;
const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100);
const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100);
onPositionChange(dragRef.current.id, nextXPercent, nextYPercent);
};
const onPointerUp = () => {
dragRef.current = null;
};
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
return () => {
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
};
}, [enabled, canvasRef, onPositionChange]);
const onElementDragStart = useCallback(
(
event: React.MouseEvent,
elementId: string,
currentXPercent: number,
currentYPercent: number,
) => {
if (!enabled) return;
event.preventDefault();
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const elementLeftPx = (currentXPercent / 100) * rect.width;
const elementTopPx = (currentYPercent / 100) * rect.height;
dragRef.current = {
id: elementId,
pointerOffsetX: event.clientX - rect.left - elementLeftPx,
pointerOffsetY: event.clientY - rect.top - elementTopPx,
};
},
[enabled, canvasRef],
);
const cancelDrag = useCallback(() => {
dragRef.current = null;
}, []);
return {
onElementDragStart,
isDragging: dragRef.current !== null,
draggedElementId: dragRef.current?.id || null,
cancelDrag,
};
}
export default useCanvasElementDrag;

View File

@ -0,0 +1,397 @@
/**
* useConstructorElements Hook
*
* Manages element CRUD operations with defaults merging.
* Used in constructor.tsx for adding, updating, and removing UI elements.
*/
import { useState, useCallback, useMemo } from 'react';
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
CarouselSlide,
} from '../types/constructor';
import {
createDefaultElement,
createLocalId,
mergeElementWithDefaults,
isGalleryElementType,
isCarouselElementType,
isNavigationElementType,
getNavigationButtonLabel,
getNavigationButtonKind,
ELEMENT_TYPE_LABELS,
} from '../lib/elementDefaults';
type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
interface UseConstructorElementsOptions {
/** Initial elements array */
initialElements?: CanvasElement[];
/** Element defaults by type from project_element_defaults */
elementDefaultsByType?: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
>;
/** Allowed navigation types for this context */
allowedNavigationTypes?: NavigationElementType[];
/** Callback when elements change */
onElementsChange?: (elements: CanvasElement[]) => void;
// Constructor-specific callbacks
/** Initial selected element ID (for route parameter) */
initialSelectedElementId?: string;
/** Callback when an element is selected */
onElementSelected?: (elementId: string) => void;
/** Callback when selection is cleared */
onSelectionCleared?: () => void;
/** Callback when an element is added */
onElementAdded?: (element: CanvasElement) => void;
/** Callback when an element is removed */
onElementRemoved?: (elementId: string) => void;
}
interface UseConstructorElementsResult {
/** Current elements array */
elements: CanvasElement[];
/** Set elements directly */
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
/** Currently selected element ID */
selectedElementId: string;
/** Currently selected element (or null) */
selectedElement: CanvasElement | null;
/** Select an element for editing */
selectElement: (elementId: string) => void;
/** Clear selection */
clearSelection: () => void;
/** Add a new element of the given type */
addElement: (type: CanvasElementType) => void;
/** Update the selected element with a partial patch */
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
/** Update an element by ID with a partial patch */
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
/** Remove the selected element */
removeSelectedElement: () => void;
/** Remove an element by ID */
removeElement: (elementId: string) => void;
/** Gallery card operations */
galleryCards: {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
};
/** Carousel slide operations */
carouselSlides: {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
/** Update element position (for drag operations) */
updateElementPosition: (
elementId: string,
xPercent: number,
yPercent: number,
) => void;
/** Normalize navigation element type when switching between forward/back */
normalizeNavigationType: (
element: CanvasElement,
nextType: NavigationElementType,
) => CanvasElement;
}
/**
* Normalize navigation element type when switching between forward/back.
*/
const normalizeNavigationElementType = (
element: CanvasElement,
nextType: NavigationElementType,
): CanvasElement => {
if (!isNavigationElementType(element.type)) return element;
const labelByType = ELEMENT_TYPE_LABELS;
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,
};
};
/**
* Hook for managing constructor elements with CRUD operations.
*
* @example
* const {
* elements,
* selectedElement,
* selectElement,
* addElement,
* updateSelectedElement,
* removeSelectedElement,
* } = useConstructorElements({
* initialElements: parsedElements,
* elementDefaultsByType: projectDefaults,
* allowedNavigationTypes: ['navigation_next', 'navigation_prev'],
* });
*/
export function useConstructorElements({
initialElements = [],
elementDefaultsByType = {},
allowedNavigationTypes = ['navigation_next', 'navigation_prev'],
onElementsChange,
initialSelectedElementId,
onElementSelected,
onSelectionCleared,
onElementAdded,
onElementRemoved,
}: UseConstructorElementsOptions = {}): UseConstructorElementsResult {
const [elements, setElements] = useState<CanvasElement[]>(initialElements);
// Initialize selectedElementId from route param if element exists in initialElements
const [selectedElementId, setSelectedElementId] = useState(() => {
if (
initialSelectedElementId &&
initialElements.some((el) => el.id === initialSelectedElementId)
) {
return initialSelectedElementId;
}
return '';
});
const selectedElement = useMemo(
() => elements.find((el) => el.id === selectedElementId) || null,
[elements, selectedElementId],
);
const selectElement = useCallback(
(elementId: string) => {
setSelectedElementId(elementId);
onElementSelected?.(elementId);
},
[onElementSelected],
);
const clearSelection = useCallback(() => {
setSelectedElementId('');
onSelectionCleared?.();
}, [onSelectionCleared]);
const addElement = useCallback(
(type: CanvasElementType) => {
const effectiveType: CanvasElementType = isNavigationElementType(type)
? allowedNavigationTypes.includes(type as NavigationElementType)
? type
: allowedNavigationTypes[0]
: type;
const baseElement = createDefaultElement(effectiveType, elements.length);
const defaults = elementDefaultsByType[effectiveType];
const newElement = mergeElementWithDefaults(baseElement, defaults);
setElements((prev) => {
const next = [...prev, newElement];
onElementsChange?.(next);
return next;
});
setSelectedElementId(newElement.id);
onElementSelected?.(newElement.id);
onElementAdded?.(newElement);
},
[
allowedNavigationTypes,
elements.length,
elementDefaultsByType,
onElementsChange,
onElementSelected,
onElementAdded,
],
);
const updateSelectedElement = useCallback(
(patch: Partial<CanvasElement>) => {
if (!selectedElementId) return;
setElements((prev) => {
const next = prev.map((el) =>
el.id === selectedElementId ? { ...el, ...patch } : el,
);
onElementsChange?.(next);
return next;
});
},
[selectedElementId, onElementsChange],
);
const updateElement = useCallback(
(elementId: string, patch: Partial<CanvasElement>) => {
setElements((prev) => {
const next = prev.map((el) =>
el.id === elementId ? { ...el, ...patch } : el,
);
onElementsChange?.(next);
return next;
});
},
[onElementsChange],
);
const removeSelectedElement = useCallback(() => {
if (!selectedElementId) return;
const removedId = selectedElementId;
let nextSelectedId = '';
setElements((prev) => {
const filtered = prev.filter((el) => el.id !== selectedElementId);
nextSelectedId = filtered[0]?.id || '';
onElementsChange?.(filtered);
return filtered;
});
if (nextSelectedId) {
setSelectedElementId(nextSelectedId);
onElementSelected?.(nextSelectedId);
} else {
setSelectedElementId('');
onSelectionCleared?.();
}
onElementRemoved?.(removedId);
}, [
selectedElementId,
onElementsChange,
onElementSelected,
onSelectionCleared,
onElementRemoved,
]);
const removeElement = useCallback(
(elementId: string) => {
setElements((prev) => {
const next = prev.filter((el) => el.id !== elementId);
onElementsChange?.(next);
return next;
});
if (selectedElementId === elementId) {
setSelectedElementId('');
}
},
[selectedElementId, onElementsChange],
);
const updateElementPosition = useCallback(
(elementId: string, xPercent: number, yPercent: number) => {
setElements((prev) =>
prev.map((el) =>
el.id === elementId ? { ...el, xPercent, yPercent } : el,
),
);
},
[],
);
// Gallery card operations
const galleryCards = useMemo(
() => ({
add: () => {
if (!selectedElement || !isGalleryElementType(selectedElement.type))
return;
const nextCards = [
...(selectedElement.galleryCards || []),
{
id: createLocalId(),
imageUrl: '',
title: `Card ${(selectedElement.galleryCards || []).length + 1}`,
description: '',
},
];
updateSelectedElement({ galleryCards: nextCards });
},
update: (cardId: string, patch: Partial<GalleryCard>) => {
if (!selectedElement || !isGalleryElementType(selectedElement.type))
return;
const nextCards = (selectedElement.galleryCards || []).map((card) =>
card.id === cardId ? { ...card, ...patch } : card,
);
updateSelectedElement({ galleryCards: nextCards });
},
remove: (cardId: string) => {
if (!selectedElement || !isGalleryElementType(selectedElement.type))
return;
const nextCards = (selectedElement.galleryCards || []).filter(
(card) => card.id !== cardId,
);
updateSelectedElement({ galleryCards: nextCards });
},
}),
[selectedElement, updateSelectedElement],
);
// Carousel slide operations
const carouselSlides = useMemo(
() => ({
add: () => {
if (!selectedElement || !isCarouselElementType(selectedElement.type))
return;
const nextSlides = [
...(selectedElement.carouselSlides || []),
{
id: createLocalId(),
imageUrl: '',
caption: `Slide ${(selectedElement.carouselSlides || []).length + 1}`,
},
];
updateSelectedElement({ carouselSlides: nextSlides });
},
update: (slideId: string, patch: Partial<CarouselSlide>) => {
if (!selectedElement || !isCarouselElementType(selectedElement.type))
return;
const nextSlides = (selectedElement.carouselSlides || []).map(
(slide) => (slide.id === slideId ? { ...slide, ...patch } : slide),
);
updateSelectedElement({ carouselSlides: nextSlides });
},
remove: (slideId: string) => {
if (!selectedElement || !isCarouselElementType(selectedElement.type))
return;
const nextSlides = (selectedElement.carouselSlides || []).filter(
(slide) => slide.id !== slideId,
);
updateSelectedElement({ carouselSlides: nextSlides });
},
}),
[selectedElement, updateSelectedElement],
);
return {
elements,
setElements,
selectedElementId,
selectedElement,
selectElement,
clearSelection,
addElement,
updateSelectedElement,
updateElement,
removeSelectedElement,
removeElement,
galleryCards,
carouselSlides,
updateElementPosition,
normalizeNavigationType: normalizeNavigationElementType,
};
}
export { normalizeNavigationElementType };
export default useConstructorElements;

View File

@ -0,0 +1,353 @@
/**
* useConstructorPageActions Hook
*
* Handles page create/save/publish operations in the constructor.
* Manages async state and API calls for page operations.
*/
import { useState, useCallback } from 'react';
import axios from 'axios';
import type { CanvasElement } from '../types/constructor';
import { createLocalId } from '../lib/elementDefaults';
import { parseJsonObject } from '../lib/parseJson';
import { logger } from '../lib/logger';
interface 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;
}
interface UseConstructorPageActionsOptions {
/** Current project ID */
projectId: string;
/** Array of all pages */
pages: TourPage[];
/** Currently active page */
activePage: TourPage | null;
/** Current active page ID */
activePageId: string;
/** Current elements array */
elements: CanvasElement[];
/** Current background URLs */
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundAudioUrl: string;
/** Callback to reload data after operations */
onReload: (preservePageId?: string) => Promise<void>;
/** Callback to set active page ID */
onSetActivePageId: (pageId: string) => void;
/** Callback to set menu open state */
onSetMenuOpen?: (isOpen: boolean) => void;
/** Callback for error messages */
onError?: (message: string) => void;
/** Callback for success messages */
onSuccess?: (message: string) => void;
}
interface UseConstructorPageActionsResult {
/** Whether save operation is in progress */
isSaving: boolean;
/** Whether save-to-stage operation is in progress */
isSavingToStage: boolean;
/** Whether page creation is in progress */
isCreatingPage: boolean;
/** Whether transition creation is in progress */
isCreatingTransition: boolean;
/** Save current constructor state */
saveConstructor: () => Promise<void>;
/** Save dev content to stage environment */
saveToStage: () => Promise<void>;
/** Create a new page */
createPage: () => Promise<void>;
/** Create a transition (legacy - transitions are now stored on elements) */
createTransition: (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => Promise<void>;
}
/**
* Hook for managing constructor page operations.
*
* @example
* const {
* isSaving,
* saveConstructor,
* createPage,
* isCreatingPage,
* } = useConstructorPageActions({
* projectId,
* pages,
* activePage,
* activePageId,
* elements,
* backgroundImageUrl,
* backgroundVideoUrl,
* backgroundAudioUrl,
* onReload: loadData,
* onSetActivePageId: setActivePageId,
* onError: setErrorMessage,
* onSuccess: setSuccessMessage,
* });
*/
export function useConstructorPageActions({
projectId,
pages,
activePage,
activePageId,
elements,
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
onReload,
onSetActivePageId,
onSetMenuOpen,
onError,
onSuccess,
}: UseConstructorPageActionsOptions): UseConstructorPageActionsResult {
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
const saveConstructor = useCallback(async () => {
if (!activePageId) {
onError?.('Select a page before saving.');
return;
}
try {
setIsSaving(true);
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),
},
});
onSuccess?.(
'Constructor settings saved. Element positions are stored in percentages.',
);
await onReload(activePageId);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save constructor changes.';
logger.error(
'Failed to save constructor changes:',
error instanceof Error ? error : { error },
);
onError?.(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,
onError,
onReload,
onSuccess,
]);
const saveToStage = useCallback(async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
}
// First save current changes
await saveConstructor();
try {
setIsSavingToStage(true);
await axios.post('/publish/save-to-stage', { projectId });
onSuccess?.(
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save to stage.';
logger.error(
'Failed to save to stage:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsSavingToStage(false);
}
}, [projectId, saveConstructor, onError, onSuccess]);
const createPage = useCallback(async () => {
if (!projectId) {
onError?.('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);
const response = await axios.post('/tour_pages', { data: payload });
const createdPage = response?.data;
await onReload();
if (createdPage?.id) {
onSetActivePageId(createdPage.id);
}
onSetMenuOpen?.(true);
onSuccess?.('New page created. You can now configure it in constructor.');
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to create page.';
logger.error(
'Failed to create page from constructor:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsCreatingPage(false);
}
}, [
activePage?.environment,
onError,
onReload,
onSetActivePageId,
onSetMenuOpen,
onSuccess,
pages,
projectId,
]);
const createTransition = useCallback(
async (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => {
if (!projectId) {
onError?.('Project is required.');
return;
}
const sanitizedVideoUrl = String(params.videoUrl || '').trim();
if (!sanitizedVideoUrl) {
onError?.('Select a transition video asset first.');
return;
}
if (!params.durationSec) {
onError?.(
'Could not resolve transition video duration yet. Please wait a moment and try again.',
);
return;
}
try {
setIsCreatingTransition(true);
// Transitions are now stored directly in navigation elements as transitionVideoUrl
// This method is kept for backwards compatibility but just shows a message
onSuccess?.(
'Transition video can be set directly on navigation elements.',
);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to create transition.';
logger.error(
'Failed to create transition from constructor:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsCreatingTransition(false);
}
},
[projectId, onError, onSuccess],
);
return {
isSaving,
isSavingToStage,
isCreatingPage,
isCreatingTransition,
saveConstructor,
saveToStage,
createPage,
createTransition,
};
}
export default useConstructorPageActions;

View File

@ -0,0 +1,200 @@
/**
* useDraggable Hook
*
* Generic draggable panel management with pointer tracking.
* Used in constructor.tsx for draggable controls, menu, and editor panels.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
interface Position {
x: number;
y: number;
}
interface DragState {
pointerOffsetX: number;
pointerOffsetY: number;
}
interface UseDraggableOptions {
/** Initial position */
initialPosition?: Position;
/** Minimum x position (default: 0) */
minX?: number;
/** Minimum y position (default: 0) */
minY?: number;
/** Maximum x position (calculated from window if not provided) */
maxX?: number;
/** Maximum y position (calculated from window if not provided) */
maxY?: number;
/** Element width for calculating max bounds */
elementWidth?: number;
/** Element height for calculating max bounds */
elementHeight?: number;
}
interface UseDraggableResult {
/** Current position */
position: Position;
/** Set position directly */
setPosition: (position: Position) => void;
/** Whether currently dragging */
isDragging: boolean;
/** Handler to attach to drag handle's onMouseDown */
onDragStart: (event: React.MouseEvent) => void;
/** Handler to attach to drag handle (alternative that ignores button clicks) */
onDragStartIgnoreButtons: (event: React.MouseEvent) => void;
}
/**
* Clamp a value between min and max
*/
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Hook for making elements draggable with mouse.
* Handles pointer tracking and bounds clamping.
*
* @example
* const { position, onDragStart, isDragging } = useDraggable({
* initialPosition: { x: 20, y: 20 },
* elementWidth: 400,
* });
*
* return (
* <div style={{ left: position.x, top: position.y }}>
* <div onMouseDown={onDragStart}>Drag Handle</div>
* <div>Content</div>
* </div>
* );
*/
export function useDraggable({
initialPosition = { x: 0, y: 0 },
minX = 0,
minY = 0,
maxX,
maxY,
elementWidth = 0,
elementHeight = 0,
}: UseDraggableOptions = {}): UseDraggableResult {
const [position, setPosition] = useState<Position>(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<DragState | null>(null);
// Calculate max bounds
const getMaxX = useCallback(() => {
if (maxX !== undefined) return maxX;
if (typeof window === 'undefined') return Infinity;
return Math.max(window.innerWidth - elementWidth, 0);
}, [maxX, elementWidth]);
const getMaxY = useCallback(() => {
if (maxY !== undefined) return maxY;
if (typeof window === 'undefined') return Infinity;
return Math.max(window.innerHeight - elementHeight, 0);
}, [maxY, elementHeight]);
// Pointer move handler
useEffect(() => {
const onPointerMove = (event: MouseEvent) => {
if (!dragRef.current) return;
const nextX = clamp(
event.clientX - dragRef.current.pointerOffsetX,
minX,
getMaxX(),
);
const nextY = clamp(
event.clientY - dragRef.current.pointerOffsetY,
minY,
getMaxY(),
);
setPosition({ x: nextX, y: nextY });
};
const onPointerUp = () => {
if (dragRef.current) {
dragRef.current = null;
setIsDragging(false);
}
};
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
return () => {
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
};
}, [minX, minY, getMaxX, getMaxY]);
// Initialize position from window dimensions and clamp to bounds
useEffect(() => {
if (typeof window === 'undefined') return;
// Clamp initial position to viewport bounds
const clampedX = clamp(initialPosition.x, minX, getMaxX());
const clampedY = clamp(initialPosition.y, minY, getMaxY());
// Update if position needs adjustment
if (position.x !== clampedX || position.y !== clampedY) {
setPosition({ x: clampedX, y: clampedY });
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onDragStart = useCallback((event: React.MouseEvent) => {
const targetRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
dragRef.current = {
pointerOffsetX: event.clientX - targetRect.left,
pointerOffsetY: event.clientY - targetRect.top,
};
setIsDragging(true);
}, []);
const onDragStartIgnoreButtons = useCallback(
(event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('button')) return;
onDragStart(event);
},
[onDragStart],
);
return {
position,
setPosition,
isDragging,
onDragStart,
onDragStartIgnoreButtons,
};
}
/**
* Create multiple draggable instances with shared pointer tracking.
* More efficient than multiple useDraggable hooks when only one can drag at a time.
*/
export function useMultipleDraggables(
configs: Record<string, UseDraggableOptions>,
): Record<string, UseDraggableResult> {
// This is a convenience wrapper - in practice, constructor.tsx
// manages multiple drag refs manually for efficiency.
// Individual useDraggable hooks work fine for most cases.
const results: Record<string, UseDraggableResult> = {};
for (const [key, config] of Object.entries(configs)) {
// eslint-disable-next-line react-hooks/rules-of-hooks
results[key] = useDraggable(config);
}
return results;
}
export default useDraggable;

View File

@ -0,0 +1,176 @@
/**
* useIconPreload Hook
*
* Preloads icon images for smooth rendering without flash.
* Used in constructor.tsx to preload navigation/tooltip/description icons.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
interface UseIconPreloadOptions {
/** Array of icon URLs to preload */
iconUrls: string[];
/** Whether preloading is enabled */
enabled?: boolean;
}
interface UseIconPreloadResult {
/** Map of URL to preload status (true if ready) */
preloadedUrlMap: Record<string, boolean>;
/** Check if a specific URL is preloaded */
isPreloaded: (url: string) => boolean;
/** Number of icons currently being preloaded */
pendingCount: number;
/** Whether all icons are preloaded */
allPreloaded: boolean;
}
/**
* Hook for preloading icon images.
* Prevents flash when icons are first rendered by pre-decoding them.
*
* @example
* const iconUrls = elements
* .filter(el => el.iconUrl)
* .map(el => resolveAssetPlaybackUrl(el.iconUrl));
*
* const { isPreloaded } = useIconPreload({ iconUrls });
*
* // Only render element if icon is ready
* if (element.iconUrl && !isPreloaded(element.iconUrl)) return null;
*/
export function useIconPreload({
iconUrls,
enabled = true,
}: UseIconPreloadOptions): UseIconPreloadResult {
const [preloadedUrlMap, setPreloadedUrlMap] = useState<
Record<string, boolean>
>({});
const preloadedUrlsRef = useRef<Set<string>>(new Set());
// Clean up preloaded set when target URLs change
useEffect(() => {
if (typeof window === 'undefined') return;
const targetSet = new Set(iconUrls);
const nextPreloaded = new Set<string>();
// Keep only URLs that are still in the target list
preloadedUrlsRef.current.forEach((url) => {
if (targetSet.has(url)) {
nextPreloaded.add(url);
}
});
preloadedUrlsRef.current = nextPreloaded;
// Update state map
setPreloadedUrlMap(() => {
const nextMap: Record<string, boolean> = {};
nextPreloaded.forEach((url) => {
nextMap[url] = true;
});
return nextMap;
});
}, [iconUrls]);
// Preload icons
useEffect(() => {
if (typeof window === 'undefined') return;
if (!enabled || !iconUrls.length) return;
let isCancelled = false;
const preloadImages: HTMLImageElement[] = [];
iconUrls.forEach((url) => {
// Skip if already preloaded
if (preloadedUrlsRef.current.has(url)) return;
const image = new Image();
const markReady = () => {
if (isCancelled) return;
preloadedUrlsRef.current.add(url);
setPreloadedUrlMap((prev) => {
if (prev[url]) return prev;
return { ...prev, [url]: true };
});
};
image.onload = markReady;
image.onerror = () => {
logger.error('Failed to preload icon asset:', { url });
markReady(); // Mark as ready anyway to avoid blocking
};
image.src = url;
preloadImages.push(image);
});
return () => {
isCancelled = true;
preloadImages.forEach((image) => {
image.onload = null;
image.onerror = null;
});
};
}, [iconUrls, enabled]);
const isPreloaded = useCallback(
(url: string): boolean => {
return Boolean(preloadedUrlMap[url]);
},
[preloadedUrlMap],
);
const pendingCount = useMemo(() => {
return iconUrls.filter((url) => !preloadedUrlMap[url]).length;
}, [iconUrls, preloadedUrlMap]);
const allPreloaded = useMemo(() => {
return iconUrls.every((url) => preloadedUrlMap[url]);
}, [iconUrls, preloadedUrlMap]);
return {
preloadedUrlMap,
isPreloaded,
pendingCount,
allPreloaded,
};
}
/**
* Build icon preload targets from elements.
* Utility to extract icon URLs that need preloading.
*/
export function buildIconPreloadTargets(
elements: Array<{ type: string; iconUrl?: string }>,
typeCheckers: {
isNavigationElementType: (type: string) => boolean;
isTooltipElementType: (type: string) => boolean;
isDescriptionElementType: (type: string) => boolean;
},
): string[] {
const {
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
} = typeCheckers;
const urls = elements
.filter(
(element) =>
(isNavigationElementType(element.type) ||
isTooltipElementType(element.type) ||
isDescriptionElementType(element.type)) &&
Boolean(element.iconUrl),
)
.map((element) => resolveAssetPlaybackUrl(element.iconUrl))
.filter(Boolean) as string[];
return Array.from(new Set(urls));
}
export default useIconPreload;

View File

@ -0,0 +1,217 @@
/**
* useMediaDurationProbe Hook
*
* Probes media durations with caching and deduplication.
* Used in constructor.tsx for displaying video/audio duration info.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
formatDurationNote,
resolveDurationWithFallback,
} from '../lib/mediaHelpers';
import { logger } from '../lib/logger';
interface DurationProbeTarget {
source: string;
mediaType: 'video' | 'audio';
}
interface UseMediaDurationProbeOptions {
/** Array of media sources to probe */
targets: DurationProbeTarget[];
}
interface UseMediaDurationProbeResult {
/** Map of source URL to resolved duration (seconds) or null */
durationBySource: Record<string, number | null>;
/** Get known duration for a source (or null if unknown/pending) */
getDuration: (source: string) => number | null;
/** Get formatted duration note for a source */
getDurationNote: (source: string) => string;
/** Whether any probes are in progress */
isProbing: boolean;
}
/**
* Hook for probing and caching media durations.
* Automatically deduplicates in-flight requests and caches results.
*
* @example
* const { getDurationNote, getDuration } = useMediaDurationProbe({
* targets: [
* { source: backgroundVideoUrl, mediaType: 'video' },
* { source: backgroundAudioUrl, mediaType: 'audio' },
* ],
* });
*
* return <p>{getDurationNote(backgroundVideoUrl)}</p>;
*/
export function useMediaDurationProbe({
targets,
}: UseMediaDurationProbeOptions): UseMediaDurationProbeResult {
const [durationBySource, setDurationBySource] = useState<
Record<string, number | null>
>({});
const [isProbing, setIsProbing] = useState(false);
const inFlightRef = useRef<Set<string>>(new Set());
// Probe targets for duration
useEffect(() => {
if (typeof window === 'undefined') return;
let isCancelled = false;
let activeProbes = 0;
targets.forEach(({ source, mediaType }) => {
const normalizedSource = String(source || '').trim();
if (!normalizedSource) return;
// Skip if already resolved
if (durationBySource[normalizedSource] !== undefined) return;
// Skip if already in flight
const probeKey = `${mediaType}:${normalizedSource}`;
if (inFlightRef.current.has(probeKey)) return;
inFlightRef.current.add(probeKey);
activeProbes++;
setIsProbing(true);
resolveDurationWithFallback(normalizedSource, mediaType)
.then((duration) => {
if (isCancelled) return;
setDurationBySource((prev) => ({
...prev,
[normalizedSource]:
Number.isFinite(duration) && Number(duration) > 0
? Number(duration)
: null,
}));
})
.catch((error) => {
logger.error(
'Failed to resolve media duration:',
error instanceof Error ? error : { error },
);
if (isCancelled) return;
setDurationBySource((prev) => ({
...prev,
[normalizedSource]: null,
}));
})
.finally(() => {
inFlightRef.current.delete(probeKey);
activeProbes--;
if (activeProbes === 0) {
setIsProbing(false);
}
});
});
return () => {
isCancelled = true;
};
}, [targets, durationBySource]);
const getDuration = useCallback(
(source: string): number | null => {
const normalizedSource = String(source || '').trim();
if (!normalizedSource) return null;
const duration = durationBySource[normalizedSource];
if (Number.isFinite(duration) && Number(duration) > 0) {
return Number(duration);
}
return null;
},
[durationBySource],
);
const getDurationNote = useCallback(
(source: string): string => {
return formatDurationNote(getDuration(source));
},
[getDuration],
);
return {
durationBySource,
getDuration,
getDurationNote,
isProbing,
};
}
/**
* Build duration probe targets from constructor state.
* Utility to simplify target array creation in constructor.tsx.
*/
export function buildDurationProbeTargets({
backgroundVideoUrl,
backgroundAudioUrl,
selectedElement,
newTransitionVideoUrl,
elements,
isMediaElementType,
isVideoPlayerElementType,
isNavigationElementType,
}: {
backgroundVideoUrl?: string;
backgroundAudioUrl?: string;
selectedElement?: {
type: string;
mediaUrl?: string;
} | null;
newTransitionVideoUrl?: string;
elements?: Array<{
type: string;
transitionVideoUrl?: string;
reverseVideoUrl?: string;
}>;
isMediaElementType: (type: string) => boolean;
isVideoPlayerElementType: (type: string) => boolean;
isNavigationElementType: (type: string) => boolean;
}): DurationProbeTarget[] {
const targets: DurationProbeTarget[] = [];
if (backgroundVideoUrl) {
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
}
if (backgroundAudioUrl) {
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
}
if (
selectedElement &&
isMediaElementType(selectedElement.type) &&
selectedElement.mediaUrl
) {
targets.push({
source: selectedElement.mediaUrl,
mediaType: isVideoPlayerElementType(selectedElement.type)
? '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;
}
export default useMediaDurationProbe;

View File

@ -0,0 +1,88 @@
/**
* useOutsideClick Hook
*
* Detects clicks outside specified elements to clear selection.
* Used in constructor.tsx to deselect elements when clicking outside.
*/
import { useEffect, useCallback, RefObject } from 'react';
interface UseOutsideClickOptions {
/** Ref to the element whose outside clicks should be detected */
containerRef: RefObject<HTMLElement | null>;
/** Additional refs to ignore (clicking these won't trigger onOutsideClick) */
ignoreRefs?: RefObject<HTMLElement | null>[];
/** Data attribute to check on clicked elements (if present, won't trigger) */
ignoreDataAttribute?: string;
/** Current selected value to check (e.g., element ID) */
selectedValue?: string;
/** Callback when click outside is detected */
onOutsideClick: () => void;
/** Whether the hook is active */
enabled?: boolean;
}
/**
* Hook to detect clicks outside a container element.
* Useful for closing panels, deselecting elements, etc.
*
* @example
* useOutsideClick({
* containerRef: panelRef,
* ignoreRefs: [buttonRef],
* onOutsideClick: () => setSelectedId(''),
* enabled: !!selectedId,
* });
*/
export function useOutsideClick({
containerRef,
ignoreRefs = [],
ignoreDataAttribute,
selectedValue,
onOutsideClick,
enabled = true,
}: UseOutsideClickOptions): void {
const handleMouseDown = useCallback(
(event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) return;
// Check if click is inside the container
if (containerRef.current?.contains(target)) return;
// Check if click is inside any ignored refs
for (const ref of ignoreRefs) {
if (ref.current?.contains(target)) return;
}
// Check for data attribute on clicked element or ancestors
if (ignoreDataAttribute) {
const clickedElement = target.closest(`[${ignoreDataAttribute}]`);
if (clickedElement) {
const attributeValue =
clickedElement.getAttribute(ignoreDataAttribute);
// If selected value matches clicked element's attribute, don't trigger
if (selectedValue && attributeValue === selectedValue) return;
}
}
onOutsideClick();
},
[
containerRef,
ignoreRefs,
ignoreDataAttribute,
selectedValue,
onOutsideClick,
],
);
useEffect(() => {
if (!enabled) return;
window.addEventListener('mousedown', handleMouseDown);
return () => window.removeEventListener('mousedown', handleMouseDown);
}, [enabled, handleMouseDown]);
}
export default useOutsideClick;

View File

@ -0,0 +1,200 @@
/**
* useTransitionPreview Hook
*
* Manages transition video preview state in the constructor.
* Used to preview forward and reverse transitions before navigation.
*/
import { useState, useCallback, useMemo } from 'react';
/**
* Transition preview state
*/
export interface TransitionPreviewState {
/** Resolved video URL for playback */
videoUrl: string;
/** Raw storage path for cache lookup */
storageKey: string;
/** Playback mode: none (forward), reverse (auto-reverse), separate (use reverseVideoUrl) */
reverseMode: 'none' | 'reverse' | 'separate';
/** Resolved URL for separate reverse video */
reverseVideoUrl?: string;
/** Raw storage path for reverse video cache lookup */
reverseStorageKey?: string;
/** Duration in seconds */
durationSec?: number;
/** Display title for the preview */
title: string;
}
/**
* Navigation element with transition configuration
*/
interface TransitionElement {
type: string;
label?: string;
navLabel?: string;
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
}
interface UseTransitionPreviewOptions {
/** Function to check if element type is a navigation type */
isNavigationElementType: (type: string) => boolean;
/** Callback when error occurs (no video configured, etc.) */
onError?: (message: string) => void;
}
interface UseTransitionPreviewResult {
/** Current preview state (null if not previewing) */
preview: TransitionPreviewState | null;
/** Pending navigation page ID (used for actual navigation after preview) */
pendingPageId: string;
/** Open transition preview for an element */
openPreview: (
element: TransitionElement,
direction: 'forward' | 'back',
) => void;
/** Open transition preview with a specific target page */
openPreviewWithTarget: (
element: TransitionElement,
direction: 'forward' | 'back',
targetPageId: string,
) => void;
/** Close the preview */
closePreview: () => void;
/** Whether a preview is currently active */
isActive: boolean;
}
/**
* Hook for managing transition preview state.
* Handles opening previews for forward and back navigation.
*
* @example
* const {
* preview,
* pendingPageId,
* openPreview,
* closePreview,
* isActive,
* } = useTransitionPreview({
* isNavigationElementType,
* onError: setErrorMessage,
* });
*
* // Open preview when clicking element
* const handleClick = () => {
* openPreviewWithTarget(element, 'forward', targetPage.id);
* };
*
* // Use with useTransitionPlayback hook
* useTransitionPlayback({
* transition: preview,
* onComplete: (targetId) => {
* switchToPage(targetId);
* closePreview();
* },
* });
*/
export function useTransitionPreview({
isNavigationElementType,
onError,
}: UseTransitionPreviewOptions): UseTransitionPreviewResult {
const [preview, setPreview] = useState<TransitionPreviewState | null>(null);
const [pendingPageId, setPendingPageId] = useState('');
const openPreview = useCallback(
(element: TransitionElement, direction: 'forward' | 'back') => {
if (!isNavigationElementType(element.type)) {
return;
}
// Check if transition video is configured
if (!element.transitionVideoUrl) {
onError?.(
'Select transition video asset to preview transition playback.',
);
return;
}
// Check for separate reverse video if needed
if (
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
) {
onError?.(
'Select back-transition asset or switch reverse mode to Auto Reverse.',
);
return;
}
const previewState: TransitionPreviewState = {
videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw storage path for cache lookup
reverseMode:
direction === 'forward'
? 'none'
: element.transitionReverseMode === 'separate_video'
? 'separate'
: 'reverse',
reverseVideoUrl: element.reverseVideoUrl,
reverseStorageKey: element.reverseVideoUrl,
durationSec: element.transitionDurationSec,
title: `${element.navLabel || element.label || 'Transition'} · ${direction}`,
};
setPreview(previewState);
},
[isNavigationElementType, onError],
);
const openPreviewWithTarget = useCallback(
(
element: TransitionElement,
direction: 'forward' | 'back',
targetPageId: string,
) => {
setPendingPageId(targetPageId);
openPreview(element, direction);
},
[openPreview],
);
const closePreview = useCallback(() => {
setPreview(null);
setPendingPageId('');
}, []);
const isActive = useMemo(() => preview !== null, [preview]);
return {
preview,
pendingPageId,
openPreview,
openPreviewWithTarget,
closePreview,
isActive,
};
}
/**
* Get navigation direction from element configuration.
* Maps navType and element type to forward/back.
*/
export function getTransitionDirection(element: {
type: string;
navType?: 'forward' | 'back';
}): 'forward' | 'back' {
if (element.navType === 'back') return 'back';
if (element.navType === 'forward') return 'forward';
// Infer from element type
if (element.type === 'navigation_prev') return 'back';
return 'forward';
}
export default useTransitionPreview;

View File

@ -0,0 +1,285 @@
/**
* Constructor Helpers
*
* Utility functions for the constructor page.
* Extracted from constructor.tsx for reusability.
*/
import type {
CanvasElement,
ConstructorAsset as ProjectAsset,
AssetOption,
} from '../types/constructor';
import {
isGalleryElementType,
isCarouselElementType,
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isMediaElementType,
getNavigationButtonLabel,
} from './elementDefaults';
/**
* Clamp a number between min and max
*/
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Get trimmed CSS value as string.
* Handles null/undefined gracefully.
*/
export const getTrimmedCssValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
/**
* Format asset label for display in select dropdowns.
* Shows asset name with source path suffix.
*/
export const getAssetLabel = (asset: ProjectAsset): string => {
const baseName = asset.name?.trim() || 'Untitled asset';
const source = String(asset.storage_key || asset.cdn_url || '').trim();
return `${baseName}${source ? ` · ${source}` : ''}`;
};
/**
* Get asset source value (storage_key or cdn_url).
* Used as the value in asset select dropdowns.
*/
export const getAssetSourceValue = (asset: ProjectAsset): string =>
String(asset.storage_key || asset.cdn_url || '').trim();
/**
* Check if an asset is likely a background image based on name/type.
* Used to filter assets for background image selection.
*/
export const isBackgroundImageAsset = (asset: ProjectAsset): boolean => {
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;
};
/**
* Add current value as a fallback option if not already present.
* Ensures the currently selected value is always available in the dropdown.
*/
export 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}`,
},
];
};
/**
* Get element button title for display in constructor menu.
* Shows element-specific info like card/slide count.
*/
export const getElementButtonTitle = (element: CanvasElement): string => {
if (isGalleryElementType(element.type)) {
return `${element.label} (${element.galleryCards?.length || 0})`;
}
if (isCarouselElementType(element.type)) {
return `${element.label} (${element.carouselSlides?.length || 0})`;
}
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
if (isDescriptionElementType(element.type))
return element.descriptionTitle ?? '';
if (isNavigationElementType(element.type)) {
type NavigationElementType = 'navigation_next' | 'navigation_prev';
return (
element.navLabel?.trim() ||
getNavigationButtonLabel(element.type as NavigationElementType)
);
}
if (isMediaElementType(element.type) && element.mediaUrl) {
return `${element.label} · configured`;
}
return element.label;
};
/**
* Build asset options for a specific asset type.
* Filters assets by type and creates label/value pairs.
*/
export const buildAssetOptions = (
assets: ProjectAsset[],
assetType: 'image' | 'video' | 'audio',
additionalFilter?: (asset: ProjectAsset) => boolean,
): AssetOption[] => {
return assets
.filter((asset) => {
if (asset.asset_type !== assetType) return false;
if (!getAssetSourceValue(asset)) return false;
if (additionalFilter && !additionalFilter(asset)) return false;
return true;
})
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
};
/**
* Build background image asset options.
* Filters for image assets that appear to be backgrounds.
*/
export const buildBackgroundImageOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'image', isBackgroundImageAsset);
};
/**
* Build video asset options.
*/
export const buildVideoAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'video');
};
/**
* Build audio asset options.
*/
export const buildAudioAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'audio');
};
/**
* Build transition video asset options.
* Prefers assets marked as type='transition', falls back to tagged or all videos.
*/
export const buildTransitionVideoOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
// First try assets marked as transition type
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;
// Fall back to assets with [TRANSITION] tag in name
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;
// Fall back to all video assets
return buildVideoAssetOptions(assets);
};
/**
* Build icon asset options.
* Filters for image assets marked as type='icon'.
*/
export const buildIconAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
};
/**
* Build all image asset options (no filtering).
*/
export const buildImageAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'image');
};
/**
* Extract numeric value from CSS value string.
* Used for compact style inputs that expect raw numbers.
*
* @example
* extractNumericValue('24vw'); // '24'
* extractNumericValue('100px'); // '100'
* extractNumericValue(''); // ''
*/
export const extractNumericValue = (value?: string): string => {
if (!value) return '';
const match = String(value).match(/^(-?\d+\.?\d*)/);
return match ? match[1] : '';
};
/**
* Build page name lookup map by ID.
*/
export const buildPageNameById = (
pages: Array<{ id: string; name?: string }>,
): Record<string, string> => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.id)] = page.name || `Page ${index + 1}`;
});
return acc;
};
/**
* Build page name lookup map by slug.
*/
export const buildPageNameBySlug = (
pages: Array<{ slug?: string; name?: string }>,
): Record<string, string> => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
if (page.slug) {
acc[String(page.slug)] = page.name || `Page ${index + 1}`;
}
});
return acc;
};

View File

@ -512,7 +512,10 @@ export const buildElementSettings = (
} }
// Gallery type settings // Gallery type settings
if (isGalleryElementType(elementType) && Array.isArray(element.galleryCards)) { if (
isGalleryElementType(elementType) &&
Array.isArray(element.galleryCards)
) {
settings.galleryCards = element.galleryCards.map((card, i) => ({ settings.galleryCards = element.galleryCards.map((card, i) => ({
id: String(card.id || createLocalId()), id: String(card.id || createLocalId()),
imageUrl: card.imageUrl || '', imageUrl: card.imageUrl || '',

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ export interface RuntimePage extends PreloadPage {
name?: string; name?: string;
sort_order?: number; sort_order?: number;
ui_schema_json?: string; ui_schema_json?: string;
environment?: 'dev' | 'stage' | 'production';
} }
/** /**