created shared components for UI elements for constructor and presentations

This commit is contained in:
Dmitri 2026-03-30 13:45:00 +04:00
parent b66cf94fb4
commit 7b21006086
18 changed files with 837 additions and 394 deletions

View File

@ -70,9 +70,13 @@ frontend/src/
├── components/ # React components (PascalCase)
│ ├── Assets/ # Asset management components
│ ├── Constructor/ # Tour builder components
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
│ ├── ElementSettings/ # Shared element settings form components
│ ├── UiElements/ # Element type components
│ ├── UiElements/ # Unified element rendering (WYSIWYG consistency)
│ │ ├── UiElementRenderer.tsx # Main entry point (used by both constructor & runtime)
│ │ ├── shared/ # Shared hooks (useElementWrapperStyle)
│ │ └── elements/ # Per-type components (NavigationElement, GalleryElement, etc.)
│ ├── RuntimeElement.tsx # Runtime element wrapper (position, effects)
│ ├── Generic/ # Generic CRUD components
│ ├── CardBox.tsx # Card container
│ ├── NavBar.tsx # Top navigation

File diff suppressed because one or more lines are too long

View File

@ -2,19 +2,11 @@
* CanvasElement Component
*
* Individual element rendered on the constructor canvas.
* Handles positioning, styling, and interaction modes.
* Handles positioning and interaction, delegates styling to UiElementRenderer.
*/
import React from 'react';
import ElementContentRenderer from '../UiElements/ElementContentRenderer';
import { buildElementStyle } from '../../lib/elementStyles';
import {
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isGalleryElementType,
isCarouselElementType,
} from '../../lib/elementDefaults';
import UiElementRenderer from '../UiElements/UiElementRenderer';
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
interface CanvasElementProps {
@ -37,58 +29,31 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
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'
}`}
className='absolute'
style={{
...elementStyle,
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
}}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
>
<ElementContentRenderer element={element} resolveUrl={resolveUrl} />
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
isDisabled={isDisabled}
/>
</button>
);
};

View File

@ -2,12 +2,13 @@
* RuntimeElement Component
*
* Renders a single UI element with interactive effects at runtime.
* Handles hover, focus, and active states for element effects.
* Handles hover, focus, active states and positioning.
* Delegates element styling and content to UiElementRenderer.
*/
import React from 'react';
import UiElementRenderer from './UiElements/UiElementRenderer';
import { useElementEffects } from '../hooks/useElementEffects';
import { buildElementStyle } from '../lib/elementStyles';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
@ -18,13 +19,14 @@ import {
interface RuntimeElementProps {
element: any;
onClick: () => void;
children: React.ReactNode;
/** Optional URL resolver for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
}
const RuntimeElement: React.FC<RuntimeElementProps> = ({
element,
onClick,
children,
resolveUrl,
}) => {
const xPercent = element.xPercent ?? 0;
const yPercent = element.yPercent ?? 0;
@ -53,49 +55,45 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
// Use effects hook for interactive states
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
// Build base element style
const baseStyle: React.CSSProperties = {
// Build base position style
let positionStyle: React.CSSProperties = {
left: `${xPercent}%`,
top: `${yPercent}%`,
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
...buildElementStyle(element),
};
// Merge transform if effect style has transform
let mergedStyle: React.CSSProperties = { ...baseStyle };
// Handle transform merging - effect transform overrides base (except for position)
if (effectStyle.transform) {
// Preserve the translate and rotation, add effect transform
mergedStyle.transform = `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''} ${effectStyle.transform}`;
positionStyle.transform = `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''} ${effectStyle.transform}`;
// Remove transform from effectStyle to avoid double application
const { transform, ...restEffectStyle } = effectStyle;
mergedStyle = { ...mergedStyle, ...restEffectStyle };
positionStyle = { ...positionStyle, ...restEffectStyle };
} else {
mergedStyle = { ...mergedStyle, ...effectStyle };
positionStyle = { ...positionStyle, ...effectStyle };
}
// Add transition if element has any effects
if (hasAnyEffects(effectProperties)) {
const transitionStyle = buildTransitionStyle(effectProperties);
mergedStyle = { ...mergedStyle, ...transitionStyle };
positionStyle = { ...positionStyle, ...transitionStyle };
}
// Add appear animation if configured
if (effectProperties.appearAnimation) {
const animationStyle = buildAppearAnimationStyle(effectProperties);
mergedStyle = { ...mergedStyle, ...animationStyle };
positionStyle = { ...positionStyle, ...animationStyle };
}
return (
<div
className='absolute cursor-pointer'
style={mergedStyle}
style={positionStyle}
onClick={onClick}
tabIndex={0}
{...eventHandlers}
>
{children}
<UiElementRenderer element={element} resolveUrl={resolveUrl} />
</div>
);
};

View File

@ -20,7 +20,6 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeElement from './RuntimeElement';
import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
@ -347,12 +346,6 @@ export default function RuntimePresentation({
[preloadOrchestrator],
);
// Render element content based on type
// Use shared ElementContentRenderer for WYSIWYG consistency with constructor
const renderElementContent = (element: any) => (
<ElementContentRenderer element={element} resolveUrl={resolveUrlWithBlob} />
);
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
// Blob URLs render instantly since data is local in memory
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
@ -470,9 +463,8 @@ export default function RuntimePresentation({
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
>
{renderElementContent(element)}
</RuntimeElement>
resolveUrl={resolveUrlWithBlob}
/>
))}
</div>

View File

@ -1,314 +0,0 @@
/**
* ElementContentRenderer
*
* Single source of truth for rendering UI element content.
* Used by both constructor (editor) and runtime (presentation) to ensure
* WYSIWYG consistency - what you see in the editor is exactly what appears in production.
*/
import React from 'react';
import type {
CanvasElement,
GalleryCard,
CarouselSlide,
} from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isVideoPlayerElementType,
isAudioPlayerElementType,
isGalleryElementType,
isCarouselElementType,
isLogoElementType,
isSpotElementType,
isPopupElementType,
} from '../../lib/elementDefaults';
export interface ElementContentRendererProps {
element: CanvasElement;
/** Optional URL resolver - use for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
}
/**
* Renders the inner content of a UI element.
* This component handles all element types and renders them consistently
* across constructor and runtime contexts.
*/
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) {
// Icon fills button dimensions from element, flexible for content when not set
const imgStyle: React.CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.iconUrl)}
alt='Navigation'
style={imgStyle}
draggable={false}
/>
);
}
// Text-only navigation button - no background here, parent element handles styling
return (
<span className='px-4 py-2 text-sm'>
{element.navLabel ||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
</span>
);
}
// Tooltip element
if (isTooltipElementType(element.type)) {
if (element.iconUrl) {
const imgStyle: React.CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.iconUrl)}
alt='Tooltip'
style={imgStyle}
draggable={false}
/>
);
}
// Text-only tooltip - no background here, parent element handles styling
return (
<div className='p-3 max-w-[200px]'>
<p className='font-bold text-sm'>{element.tooltipTitle}</p>
<p className='text-xs opacity-70'>{element.tooltipText}</p>
</div>
);
}
// Description element
if (isDescriptionElementType(element.type)) {
if (element.iconUrl) {
const imgStyle: React.CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.iconUrl)}
alt='Description'
style={imgStyle}
draggable={false}
/>
);
}
const bgColor = element.descriptionBackgroundColor || 'transparent';
return (
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
<p
className='font-bold'
style={{
fontSize: element.descriptionTitleFontSize || '24px',
fontFamily: element.descriptionTitleFontFamily || 'inherit',
color: element.descriptionTitleColor || '#ffffff',
}}
>
{element.descriptionTitle || ''}
</p>
{element.descriptionText && (
<p
style={{
fontSize: element.descriptionTextFontSize || '16px',
fontFamily: element.descriptionTextFontFamily || 'inherit',
color: element.descriptionTextColor || '#ffffff',
}}
>
{element.descriptionText}
</p>
)}
</div>
);
}
// Video player
if (isVideoPlayerElementType(element.type) && element.mediaUrl) {
return (
<video
className='w-full h-full object-cover rounded'
src={resolve(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
/>
);
}
// Audio player
if (isAudioPlayerElementType(element.type) && element.mediaUrl) {
return (
<audio
className='w-full'
src={resolve(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
/>
);
}
// Gallery
if (isGalleryElementType(element.type)) {
const cards: GalleryCard[] = element.galleryCards || [];
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]'
>
{card.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(card.imageUrl)}
alt={card.title || ''}
className='absolute inset-0 w-full h-full object-cover rounded'
draggable={false}
/>
)}
</div>
))}
</div>
);
}
// Carousel
if (isCarouselElementType(element.type)) {
const slides: CarouselSlide[] = element.carouselSlides || [];
const firstSlide = slides[0];
return (
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
{firstSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded'
draggable={false}
/>
)}
{/* Carousel navigation overlay */}
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
{slides.map((slide, index) => (
<div
key={slide.id}
className={`w-2 h-2 rounded-full ${
index === 0 ? 'bg-white' : 'bg-white/50'
}`}
/>
))}
</div>
{/* Prev/Next icons if configured */}
{element.carouselPrevIconUrl && (
<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={resolve(element.carouselPrevIconUrl)}
alt='Previous'
className='w-full h-full object-contain'
draggable={false}
/>
</div>
)}
{element.carouselNextIconUrl && (
<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={resolve(element.carouselNextIconUrl)}
alt='Next'
className='w-full h-full object-contain'
draggable={false}
/>
</div>
)}
</div>
);
}
// Logo
if (isLogoElementType(element.type)) {
if (element.iconUrl) {
const imgStyle: React.CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.iconUrl)}
alt='Logo'
style={imgStyle}
draggable={false}
/>
);
}
// Text-only logo - no background here, parent element handles styling
return (
<span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
);
}
// Spot (hotspot)
if (isSpotElementType(element.type)) {
if (element.iconUrl) {
const imgStyle: React.CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(element.iconUrl)}
alt='Hotspot'
style={imgStyle}
draggable={false}
/>
);
}
// Default spot indicator
return (
<div className='w-8 h-8 rounded-full bg-blue-500/70 border-2 border-white animate-pulse' />
);
}
// 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>
);
}
// 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>
);
};
export default ElementContentRenderer;

View File

@ -0,0 +1,111 @@
/**
* UiElementRenderer Component
*
* Unified UI Element Renderer - single source of truth for element rendering.
* Used by both CanvasElement (constructor) and RuntimeElement (presentation)
* to ensure WYSIWYG consistency.
*
* Renders any UI element with consistent styling by delegating to per-type components.
*/
import React from 'react';
import type { CanvasElement } from '../../types/constructor';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import {
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isVideoPlayerElementType,
isAudioPlayerElementType,
isLogoElementType,
isSpotElementType,
isPopupElementType,
} from '../../lib/elementDefaults';
// Import per-type components
import NavigationElement from './elements/NavigationElement';
import GalleryElement from './elements/GalleryElement';
import TooltipElement from './elements/TooltipElement';
import DescriptionElement from './elements/DescriptionElement';
import CarouselElement from './elements/CarouselElement';
import LogoElement from './elements/LogoElement';
import SpotElement from './elements/SpotElement';
import VideoPlayerElement from './elements/VideoPlayerElement';
import AudioPlayerElement from './elements/AudioPlayerElement';
import PopupElement from './elements/PopupElement';
export interface UiElementRendererProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
// Constructor-specific props (optional)
isSelected?: boolean;
isEditMode?: boolean;
isDisabled?: boolean;
}
/**
* Unified UI Element Renderer
*
* Renders any UI element with consistent styling.
* Used by both CanvasElement (constructor) and RuntimeElement (presentation).
*/
export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
element,
resolveUrl,
isSelected = false,
isEditMode = false,
isDisabled = false,
}) => {
const { className, style } = useElementWrapperStyle({
element,
isSelected,
isEditMode,
isDisabled,
});
// Common props for all element types
const commonProps = { element, resolveUrl, className, style };
// Delegate to type-specific component
if (isNavigationElementType(element.type)) {
return <NavigationElement {...commonProps} />;
}
if (isGalleryElementType(element.type)) {
return <GalleryElement {...commonProps} />;
}
if (isTooltipElementType(element.type)) {
return <TooltipElement {...commonProps} />;
}
if (isDescriptionElementType(element.type)) {
return <DescriptionElement {...commonProps} />;
}
if (isCarouselElementType(element.type)) {
return <CarouselElement {...commonProps} />;
}
if (isVideoPlayerElementType(element.type)) {
return <VideoPlayerElement {...commonProps} />;
}
if (isAudioPlayerElementType(element.type)) {
return <AudioPlayerElement {...commonProps} />;
}
if (isLogoElementType(element.type)) {
return <LogoElement {...commonProps} />;
}
if (isSpotElementType(element.type)) {
return <SpotElement {...commonProps} />;
}
if (isPopupElementType(element.type)) {
return <PopupElement {...commonProps} />;
}
// Fallback for unknown types
return (
<div className={className} style={style}>
<span className='px-4 py-2 text-sm'>{element.label || element.type}</span>
</div>
);
};
export default UiElementRenderer;

View File

@ -0,0 +1,49 @@
/**
* AudioPlayerElement Component
*
* Audio player element with controls.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface AudioPlayerElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
if (!element.mediaUrl) {
return (
<div className={className} style={style}>
<span className='px-4 py-2 text-sm'>Audio Player</span>
</div>
);
}
return (
<div className={className} style={style}>
<audio
className='w-full'
src={resolve(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
/>
</div>
);
};
export default AudioPlayerElement;

View File

@ -0,0 +1,81 @@
/**
* CarouselElement Component
*
* Carousel element - slideshow of images with navigation.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface CarouselElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const CarouselElement: React.FC<CarouselElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || [];
const firstSlide = slides[0];
return (
<div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'>
{firstSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded'
draggable={false}
/>
)}
{/* Carousel navigation overlay */}
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-1'>
{slides.map((slide, index) => (
<div
key={slide.id}
className={`w-2 h-2 rounded-full ${
index === 0 ? 'bg-white' : 'bg-white/50'
}`}
/>
))}
</div>
{/* Prev/Next icons if configured */}
{element.carouselPrevIconUrl && (
<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={resolve(element.carouselPrevIconUrl)}
alt='Previous'
className='w-full h-full object-contain'
draggable={false}
/>
</div>
)}
{element.carouselNextIconUrl && (
<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={resolve(element.carouselNextIconUrl)}
alt='Next'
className='w-full h-full object-contain'
draggable={false}
/>
</div>
)}
</div>
</div>
);
};
export default CarouselElement;

View File

@ -0,0 +1,81 @@
/**
* DescriptionElement Component
*
* Description element with icon or styled text content.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface DescriptionElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const DescriptionElement: React.FC<DescriptionElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
<div className={className} style={style}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt='Description'
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render styled text description
const bgColor = element.descriptionBackgroundColor || 'transparent';
return (
<div className={className} style={style}>
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
<p
className='font-bold'
style={{
fontSize: element.descriptionTitleFontSize || '24px',
fontFamily: element.descriptionTitleFontFamily || 'inherit',
color: element.descriptionTitleColor || '#ffffff',
}}
>
{element.descriptionTitle || ''}
</p>
{element.descriptionText && (
<p
style={{
fontSize: element.descriptionTextFontSize || '16px',
fontFamily: element.descriptionTextFontFamily || 'inherit',
color: element.descriptionTextColor || '#ffffff',
}}
>
{element.descriptionText}
</p>
)}
</div>
</div>
);
};
export default DescriptionElement;

View File

@ -0,0 +1,53 @@
/**
* GalleryElement Component
*
* Gallery element - grid of image cards.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement, GalleryCard } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface GalleryElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const GalleryElement: React.FC<GalleryElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const cards: GalleryCard[] = element.galleryCards || [];
return (
<div className={className} style={style}>
<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]'
>
{card.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(card.imageUrl)}
alt={card.title || ''}
className='absolute inset-0 w-full h-full object-cover rounded'
draggable={false}
/>
)}
</div>
))}
</div>
</div>
);
};
export default GalleryElement;

View File

@ -0,0 +1,57 @@
/**
* LogoElement Component
*
* Logo element with icon or text fallback.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface LogoElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const LogoElement: React.FC<LogoElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
<div className={className} style={style}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt='Logo'
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render text logo
return (
<div className={className} style={style}>
<span className='px-4 py-2 font-bold'>{element.label || 'LOGO'}</span>
</div>
);
};
export default LogoElement;

View File

@ -0,0 +1,60 @@
/**
* NavigationElement Component
*
* Navigation button element (navigation_next, navigation_prev).
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface NavigationElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const NavigationElement: React.FC<NavigationElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
<div className={className} style={style}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt='Navigation'
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render text label
return (
<div className={className} style={style}>
<span className='px-4 py-2 text-sm'>
{element.navLabel ||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
</span>
</div>
);
};
export default NavigationElement;

View File

@ -0,0 +1,31 @@
/**
* PopupElement Component
*
* Popup trigger element.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
interface PopupElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const PopupElement: React.FC<PopupElementProps> = ({
element,
className,
style,
}) => {
return (
<div className={className} style={style}>
<span className='px-4 py-2 text-sm'>{element.label || 'Popup'}</span>
</div>
);
};
export default PopupElement;

View File

@ -0,0 +1,57 @@
/**
* SpotElement Component
*
* Hotspot element with icon or default indicator.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface SpotElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const SpotElement: React.FC<SpotElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
<div className={className} style={style}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt='Hotspot'
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render default spot indicator
return (
<div className={className} style={style}>
<div className='w-8 h-8 rounded-full bg-blue-500/70 border-2 border-white animate-pulse' />
</div>
);
};
export default SpotElement;

View File

@ -0,0 +1,60 @@
/**
* TooltipElement Component
*
* Tooltip element with icon or text content.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface TooltipElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const TooltipElement: React.FC<TooltipElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
// With icon: render image
if (element.iconUrl) {
const imgStyle: CSSProperties = {
width: element.width ? '100%' : 'auto',
height: element.height ? '100%' : 'auto',
objectFit: 'contain',
};
return (
<div className={className} style={style}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(element.iconUrl)}
alt='Tooltip'
style={imgStyle}
draggable={false}
/>
</div>
);
}
// Without icon: render text tooltip
return (
<div className={className} style={style}>
<div className='p-3 max-w-[200px]'>
<p className='font-bold text-sm'>{element.tooltipTitle}</p>
<p className='text-xs opacity-70'>{element.tooltipText}</p>
</div>
</div>
);
};
export default TooltipElement;

View File

@ -0,0 +1,51 @@
/**
* VideoPlayerElement Component
*
* Video player element with controls.
* Renders with unified wrapper styling + content.
*/
import React from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
interface VideoPlayerElementProps {
element: CanvasElement;
resolveUrl?: (url: string | undefined) => string;
className: string;
style: CSSProperties;
}
const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
element,
resolveUrl,
className,
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
if (!element.mediaUrl) {
return (
<div className={className} style={style}>
<span className='px-4 py-2 text-sm'>Video Player</span>
</div>
);
}
return (
<div className={className} style={style}>
<video
className='w-full h-full object-cover rounded'
src={resolve(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
/>
</div>
);
};
export default VideoPlayerElement;

View File

@ -0,0 +1,107 @@
/**
* useElementWrapperStyle Hook
*
* Single source of truth for UI element wrapper styling.
* Used by both CanvasElement (constructor) and RuntimeElement (presentation)
* to ensure WYSIWYG consistency.
*/
import { useMemo } from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { buildElementStyle } from '../../../lib/elementStyles';
import {
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isGalleryElementType,
isCarouselElementType,
isLogoElementType,
isSpotElementType,
} from '../../../lib/elementDefaults';
interface UseElementWrapperStyleOptions {
element: CanvasElement;
/** Constructor-specific: show selection styling */
isSelected?: boolean;
/** Constructor-specific: show edit mode styling */
isEditMode?: boolean;
/** Constructor-specific: show disabled styling */
isDisabled?: boolean;
}
interface ElementWrapperStyle {
className: string;
style: CSSProperties;
}
/**
* Hook that returns consistent wrapper styling for any UI element.
* Ensures constructor and runtime render elements identically.
*
* @param options - Element and optional constructor state flags
* @returns Object with className and style for the element wrapper
*/
export function useElementWrapperStyle({
element,
isSelected = false,
isEditMode = false,
isDisabled = false,
}: UseElementWrapperStyleOptions): ElementWrapperStyle {
return useMemo(() => {
// Determine element characteristics
const hasIconDrivenSize =
Boolean(element.iconUrl) &&
(isTooltipElementType(element.type) ||
isDescriptionElementType(element.type) ||
isNavigationElementType(element.type) ||
isLogoElementType(element.type) ||
isSpotElementType(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);
// Navigation elements (with or without icon) should be centered
const isNavigationElement = isNavigationElementType(element.type);
// Build className - same logic for both constructor and runtime
const classNames = [
'rounded text-xs font-semibold',
// Text alignment: center for navigation buttons, left for others
isNavigationElement ? 'text-center' : 'text-left',
// Background/border - selected state overrides transparent
isSelected
? 'border shadow border-blue-500 bg-blue-50'
: hasTransparentBackground
? 'bg-transparent'
: 'border shadow border-blue-200 bg-white/95',
// Padding
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2',
// Flex centering for navigation elements (both icons and text)
isNavigationElement ? 'flex items-center justify-center' : '',
// Constructor-specific states (only applied when in constructor)
isEditMode
? 'cursor-move'
: isDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
]
.filter(Boolean)
.join(' ');
// Build inline style from element properties
const inlineStyle = buildElementStyle(element);
return {
className: classNames,
style: inlineStyle,
};
}, [element, isSelected, isEditMode, isDisabled]);
}