created shared components for UI elements for constructor and presentations
This commit is contained in:
parent
b66cf94fb4
commit
7b21006086
@ -70,9 +70,13 @@ frontend/src/
|
|||||||
│
|
│
|
||||||
├── components/ # React components (PascalCase)
|
├── components/ # React components (PascalCase)
|
||||||
│ ├── Assets/ # Asset management components
|
│ ├── Assets/ # Asset management components
|
||||||
│ ├── Constructor/ # Tour builder components
|
│ ├── Constructor/ # Tour builder components (CanvasElement, etc.)
|
||||||
│ ├── ElementSettings/ # Shared element settings form components
|
│ ├── 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
|
│ ├── Generic/ # Generic CRUD components
|
||||||
│ ├── CardBox.tsx # Card container
|
│ ├── CardBox.tsx # Card container
|
||||||
│ ├── NavBar.tsx # Top navigation
|
│ ├── NavBar.tsx # Top navigation
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -2,19 +2,11 @@
|
|||||||
* CanvasElement Component
|
* CanvasElement Component
|
||||||
*
|
*
|
||||||
* Individual element rendered on the constructor canvas.
|
* 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 React from 'react';
|
||||||
import ElementContentRenderer from '../UiElements/ElementContentRenderer';
|
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
||||||
import { buildElementStyle } from '../../lib/elementStyles';
|
|
||||||
import {
|
|
||||||
isTooltipElementType,
|
|
||||||
isDescriptionElementType,
|
|
||||||
isNavigationElementType,
|
|
||||||
isGalleryElementType,
|
|
||||||
isCarouselElementType,
|
|
||||||
} from '../../lib/elementDefaults';
|
|
||||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
@ -37,58 +29,31 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
onMouseDown,
|
onMouseDown,
|
||||||
resolveUrl,
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
data-constructor-element-id={element.id}
|
data-constructor-element-id={element.id}
|
||||||
className={`absolute rounded text-xs font-semibold text-left ${
|
className='absolute'
|
||||||
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={{
|
style={{
|
||||||
...elementStyle,
|
|
||||||
left: `${element.xPercent}%`,
|
left: `${element.xPercent}%`,
|
||||||
top: `${element.yPercent}%`,
|
top: `${element.yPercent}%`,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
|
// Reset button defaults to let UiElementRenderer control styling
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
onMouseDown={isEditMode ? onMouseDown : undefined}
|
onMouseDown={isEditMode ? onMouseDown : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<ElementContentRenderer element={element} resolveUrl={resolveUrl} />
|
<UiElementRenderer
|
||||||
|
element={element}
|
||||||
|
resolveUrl={resolveUrl}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
* RuntimeElement Component
|
* RuntimeElement Component
|
||||||
*
|
*
|
||||||
* Renders a single UI element with interactive effects at runtime.
|
* 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 React from 'react';
|
||||||
|
import UiElementRenderer from './UiElements/UiElementRenderer';
|
||||||
import { useElementEffects } from '../hooks/useElementEffects';
|
import { useElementEffects } from '../hooks/useElementEffects';
|
||||||
import { buildElementStyle } from '../lib/elementStyles';
|
|
||||||
import {
|
import {
|
||||||
buildTransitionStyle,
|
buildTransitionStyle,
|
||||||
buildAppearAnimationStyle,
|
buildAppearAnimationStyle,
|
||||||
@ -18,13 +19,14 @@ import {
|
|||||||
interface RuntimeElementProps {
|
interface RuntimeElementProps {
|
||||||
element: any;
|
element: any;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children: React.ReactNode;
|
/** Optional URL resolver for preloaded blob URLs */
|
||||||
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||||
element,
|
element,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
resolveUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const xPercent = element.xPercent ?? 0;
|
const xPercent = element.xPercent ?? 0;
|
||||||
const yPercent = element.yPercent ?? 0;
|
const yPercent = element.yPercent ?? 0;
|
||||||
@ -53,49 +55,45 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
// Use effects hook for interactive states
|
// Use effects hook for interactive states
|
||||||
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
|
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
|
||||||
|
|
||||||
// Build base element style
|
// Build base position style
|
||||||
const baseStyle: React.CSSProperties = {
|
let positionStyle: React.CSSProperties = {
|
||||||
left: `${xPercent}%`,
|
left: `${xPercent}%`,
|
||||||
top: `${yPercent}%`,
|
top: `${yPercent}%`,
|
||||||
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
||||||
...buildElementStyle(element),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge transform if effect style has transform
|
// 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) {
|
if (effectStyle.transform) {
|
||||||
// Preserve the translate and rotation, add effect 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
|
// Remove transform from effectStyle to avoid double application
|
||||||
const { transform, ...restEffectStyle } = effectStyle;
|
const { transform, ...restEffectStyle } = effectStyle;
|
||||||
mergedStyle = { ...mergedStyle, ...restEffectStyle };
|
positionStyle = { ...positionStyle, ...restEffectStyle };
|
||||||
} else {
|
} else {
|
||||||
mergedStyle = { ...mergedStyle, ...effectStyle };
|
positionStyle = { ...positionStyle, ...effectStyle };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add transition if element has any effects
|
// Add transition if element has any effects
|
||||||
if (hasAnyEffects(effectProperties)) {
|
if (hasAnyEffects(effectProperties)) {
|
||||||
const transitionStyle = buildTransitionStyle(effectProperties);
|
const transitionStyle = buildTransitionStyle(effectProperties);
|
||||||
mergedStyle = { ...mergedStyle, ...transitionStyle };
|
positionStyle = { ...positionStyle, ...transitionStyle };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add appear animation if configured
|
// Add appear animation if configured
|
||||||
if (effectProperties.appearAnimation) {
|
if (effectProperties.appearAnimation) {
|
||||||
const animationStyle = buildAppearAnimationStyle(effectProperties);
|
const animationStyle = buildAppearAnimationStyle(effectProperties);
|
||||||
mergedStyle = { ...mergedStyle, ...animationStyle };
|
positionStyle = { ...positionStyle, ...animationStyle };
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='absolute cursor-pointer'
|
className='absolute cursor-pointer'
|
||||||
style={mergedStyle}
|
style={positionStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
{...eventHandlers}
|
{...eventHandlers}
|
||||||
>
|
>
|
||||||
{children}
|
<UiElementRenderer element={element} resolveUrl={resolveUrl} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import BaseButton from './BaseButton';
|
|||||||
import CardBox from './CardBox';
|
import CardBox from './CardBox';
|
||||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
@ -347,12 +346,6 @@ export default function RuntimePresentation({
|
|||||||
[preloadOrchestrator],
|
[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)
|
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
|
||||||
// Blob URLs render instantly since data is local in memory
|
// Blob URLs render instantly since data is local in memory
|
||||||
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
|
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
|
||||||
@ -470,9 +463,8 @@ export default function RuntimePresentation({
|
|||||||
key={element.id}
|
key={element.id}
|
||||||
element={element}
|
element={element}
|
||||||
onClick={() => handleElementClick(element)}
|
onClick={() => handleElementClick(element)}
|
||||||
>
|
resolveUrl={resolveUrlWithBlob}
|
||||||
{renderElementContent(element)}
|
/>
|
||||||
</RuntimeElement>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
111
frontend/src/components/UiElements/UiElementRenderer.tsx
Normal file
111
frontend/src/components/UiElements/UiElementRenderer.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
57
frontend/src/components/UiElements/elements/LogoElement.tsx
Normal file
57
frontend/src/components/UiElements/elements/LogoElement.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
31
frontend/src/components/UiElements/elements/PopupElement.tsx
Normal file
31
frontend/src/components/UiElements/elements/PopupElement.tsx
Normal 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;
|
||||||
57
frontend/src/components/UiElements/elements/SpotElement.tsx
Normal file
57
frontend/src/components/UiElements/elements/SpotElement.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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]);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user