improved constructor.tsx page structure
This commit is contained in:
parent
eac21c84b3
commit
25e6a1f5d2
File diff suppressed because one or more lines are too long
61
frontend/src/components/Constructor/AssetSelectCompact.tsx
Normal file
61
frontend/src/components/Constructor/AssetSelectCompact.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
110
frontend/src/components/Constructor/CanvasBackground.tsx
Normal file
110
frontend/src/components/Constructor/CanvasBackground.tsx
Normal 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;
|
||||||
96
frontend/src/components/Constructor/CanvasElement.tsx
Normal file
96
frontend/src/components/Constructor/CanvasElement.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
172
frontend/src/components/Constructor/ConstructorMenu.tsx
Normal file
172
frontend/src/components/Constructor/ConstructorMenu.tsx
Normal 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;
|
||||||
92
frontend/src/components/Constructor/CreateTransitionForm.tsx
Normal file
92
frontend/src/components/Constructor/CreateTransitionForm.tsx
Normal 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;
|
||||||
57
frontend/src/components/Constructor/ElementEditorHeader.tsx
Normal file
57
frontend/src/components/Constructor/ElementEditorHeader.tsx
Normal 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;
|
||||||
591
frontend/src/components/Constructor/ElementEditorPanel.tsx
Normal file
591
frontend/src/components/Constructor/ElementEditorPanel.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
39
frontend/src/components/Constructor/MenuActionButton.tsx
Normal file
39
frontend/src/components/Constructor/MenuActionButton.tsx
Normal 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;
|
||||||
39
frontend/src/components/Constructor/PageSelector.tsx
Normal file
39
frontend/src/components/Constructor/PageSelector.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
9
frontend/src/components/Constructor/index.ts
Normal file
9
frontend/src/components/Constructor/index.ts
Normal 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';
|
||||||
312
frontend/src/components/Constructor/types.ts
Normal file
312
frontend/src/components/Constructor/types.ts
Normal 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,
|
||||||
|
};
|
||||||
@ -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 "+ Add slide" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CarouselSettingsSectionCompact;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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 "+ Add card" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GallerySettingsSectionCompact;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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';
|
||||||
|
|||||||
111
frontend/src/components/FormFieldCompact.tsx
Normal file
111
frontend/src/components/FormFieldCompact.tsx
Normal 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;
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
111
frontend/src/hooks/useCanvasElapsedTime.ts
Normal file
111
frontend/src/hooks/useCanvasElapsedTime.ts
Normal 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;
|
||||||
150
frontend/src/hooks/useCanvasElementDrag.ts
Normal file
150
frontend/src/hooks/useCanvasElementDrag.ts
Normal 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;
|
||||||
397
frontend/src/hooks/useConstructorElements.ts
Normal file
397
frontend/src/hooks/useConstructorElements.ts
Normal 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;
|
||||||
353
frontend/src/hooks/useConstructorPageActions.ts
Normal file
353
frontend/src/hooks/useConstructorPageActions.ts
Normal 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;
|
||||||
200
frontend/src/hooks/useDraggable.ts
Normal file
200
frontend/src/hooks/useDraggable.ts
Normal 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;
|
||||||
176
frontend/src/hooks/useIconPreload.ts
Normal file
176
frontend/src/hooks/useIconPreload.ts
Normal 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;
|
||||||
217
frontend/src/hooks/useMediaDurationProbe.ts
Normal file
217
frontend/src/hooks/useMediaDurationProbe.ts
Normal 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;
|
||||||
88
frontend/src/hooks/useOutsideClick.ts
Normal file
88
frontend/src/hooks/useOutsideClick.ts
Normal 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;
|
||||||
200
frontend/src/hooks/useTransitionPreview.ts
Normal file
200
frontend/src/hooks/useTransitionPreview.ts
Normal 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;
|
||||||
285
frontend/src/lib/constructorHelpers.ts
Normal file
285
frontend/src/lib/constructorHelpers.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user