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 EffectsSettingsSectionCompact } from './EffectsSettingsSectionCompact';
|
||||
export { default as CommonSettingsSection } from './CommonSettingsSection';
|
||||
export { default as CommonSettingsSectionCompact } from './CommonSettingsSectionCompact';
|
||||
export { default as NavigationSettingsSection } from './NavigationSettingsSection';
|
||||
export { default as NavigationSettingsSectionCompact } from './NavigationSettingsSectionCompact';
|
||||
export { default as TooltipSettingsSection } from './TooltipSettingsSection';
|
||||
export { default as TooltipSettingsSectionCompact } from './TooltipSettingsSectionCompact';
|
||||
export { default as DescriptionSettingsSection } from './DescriptionSettingsSection';
|
||||
export { default as DescriptionSettingsSectionCompact } from './DescriptionSettingsSectionCompact';
|
||||
export { default as MediaSettingsSection } from './MediaSettingsSection';
|
||||
export { default as MediaSettingsSectionCompact } from './MediaSettingsSectionCompact';
|
||||
export { default as GallerySettingsSection } from './GallerySettingsSection';
|
||||
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
|
||||
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
|
||||
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
||||
|
||||
// Hook
|
||||
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,
|
||||
}: RuntimePresentationProps) {
|
||||
// Use shared hook for loading project and pages data
|
||||
const {
|
||||
project,
|
||||
pages,
|
||||
isLoading,
|
||||
error,
|
||||
initialPageId,
|
||||
} = usePageDataLoader({
|
||||
projectSlug,
|
||||
environment,
|
||||
apiHeaders: {
|
||||
'X-Runtime-Project-Slug': projectSlug,
|
||||
'X-Runtime-Environment': environment,
|
||||
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
|
||||
{
|
||||
projectSlug,
|
||||
environment,
|
||||
apiHeaders: {
|
||||
'X-Runtime-Project-Slug': projectSlug,
|
||||
'X-Runtime-Environment': environment,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
@ -303,7 +299,9 @@ export default function RuntimePresentation({
|
||||
const handleElementClick = useCallback(
|
||||
(element: any) => {
|
||||
// Block navigation while transition is actively playing or buffering
|
||||
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
|
||||
if (
|
||||
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -331,10 +329,28 @@ export default function RuntimePresentation({
|
||||
[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
|
||||
// Use shared ElementContentRenderer for WYSIWYG consistency with constructor
|
||||
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)
|
||||
|
||||
@ -28,6 +28,8 @@ import {
|
||||
|
||||
export interface ElementContentRendererProps {
|
||||
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> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
}) => {
|
||||
// Use custom resolver if provided, otherwise fallback to standard resolution
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
// Navigation buttons (navigation_next, navigation_prev)
|
||||
if (isNavigationElementType(element.type)) {
|
||||
if (element.iconUrl) {
|
||||
@ -50,7 +55,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Navigation'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
@ -77,7 +82,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Tooltip'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
@ -104,7 +109,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Description'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
@ -144,7 +149,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
<video
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
@ -159,7 +164,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
<audio
|
||||
className='w-full'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
src={resolve(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
@ -173,11 +178,14 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
|
||||
{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 && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
||||
src={resolve(card.imageUrl)}
|
||||
alt={card.title || ''}
|
||||
className='absolute inset-0 w-full h-full object-cover rounded'
|
||||
draggable={false}
|
||||
@ -198,7 +206,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
{firstSlide?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
||||
src={resolve(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || 'Carousel slide'}
|
||||
className='w-full h-full object-cover rounded'
|
||||
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'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
|
||||
src={resolve(element.carouselPrevIconUrl)}
|
||||
alt='Previous'
|
||||
className='w-full h-full object-contain'
|
||||
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'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
|
||||
src={resolve(element.carouselNextIconUrl)}
|
||||
alt='Next'
|
||||
className='w-full h-full object-contain'
|
||||
draggable={false}
|
||||
@ -253,7 +261,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Logo'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
@ -262,9 +270,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
}
|
||||
// Text-only logo - no background here, parent element handles styling
|
||||
return (
|
||||
<span className='px-4 py-2 font-bold'>
|
||||
{element.label || 'LOGO'}
|
||||
</span>
|
||||
<span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -279,7 +285,7 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
src={resolve(element.iconUrl)}
|
||||
alt='Hotspot'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
@ -295,17 +301,13 @@ export const ElementContentRenderer: React.FC<ElementContentRendererProps> = ({
|
||||
// Popup - no background here, parent element handles styling
|
||||
if (isPopupElementType(element.type)) {
|
||||
return (
|
||||
<span className='px-4 py-2 text-sm'>
|
||||
{element.label || 'Popup'}
|
||||
</span>
|
||||
<span className='px-4 py-2 text-sm'>{element.label || 'Popup'}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown types - no background here, parent element handles styling
|
||||
return (
|
||||
<span className='px-4 py-2 text-sm'>
|
||||
{element.label || element.type}
|
||||
</span>
|
||||
<span className='px-4 py-2 text-sm'>{element.label || element.type}</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -31,3 +31,14 @@ export type {
|
||||
UsePageDataLoaderOptions,
|
||||
UsePageDataLoaderResult,
|
||||
} 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
|
||||
if (isGalleryElementType(elementType) && Array.isArray(element.galleryCards)) {
|
||||
if (
|
||||
isGalleryElementType(elementType) &&
|
||||
Array.isArray(element.galleryCards)
|
||||
) {
|
||||
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
||||
id: String(card.id || createLocalId()),
|
||||
imageUrl: card.imageUrl || '',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -25,6 +25,7 @@ export interface RuntimePage extends PreloadPage {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
ui_schema_json?: string;
|
||||
environment?: 'dev' | 'stage' | 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user