fixed UI elements rendering logic
This commit is contained in:
parent
41713d1274
commit
b925094555
File diff suppressed because one or more lines are too long
@ -6,6 +6,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import FormField from '../FormField';
|
||||
import { isVideoPlayerElementType } from '../../lib/elementDefaults';
|
||||
import type { MediaSettingsSectionProps } from './types';
|
||||
|
||||
const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({
|
||||
@ -20,7 +21,7 @@ const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({
|
||||
audioAssetOptions = [],
|
||||
}) => {
|
||||
const isConstructor = context === 'constructor';
|
||||
const isVideo = elementType === 'video_player';
|
||||
const isVideo = isVideoPlayerElementType(elementType);
|
||||
const assetOptions = isVideo ? videoAssetOptions : audioAssetOptions;
|
||||
|
||||
return (
|
||||
|
||||
@ -83,7 +83,9 @@ const NavigationSettingsSection: React.FC<NavigationSettingsSectionProps> = ({
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={navDisabled}
|
||||
onChange={(event) => onChange('navDisabled', event.target.checked)}
|
||||
onChange={(event) =>
|
||||
onChange('navDisabled', event.target.checked)
|
||||
}
|
||||
/>
|
||||
Button is disabled (not clickable)
|
||||
</label>
|
||||
|
||||
@ -187,33 +187,6 @@ export interface ElementSettingsTabsProps {
|
||||
tabs: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Element type detection helpers
|
||||
*/
|
||||
export function isNavigationType(type: string): boolean {
|
||||
return type === 'navigation_next' || type === 'navigation_prev';
|
||||
}
|
||||
|
||||
export function isTooltipType(type: string): boolean {
|
||||
return type === 'tooltip';
|
||||
}
|
||||
|
||||
export function isDescriptionType(type: string): boolean {
|
||||
return type === 'description';
|
||||
}
|
||||
|
||||
export function isGalleryType(type: string): boolean {
|
||||
return type === 'gallery';
|
||||
}
|
||||
|
||||
export function isCarouselType(type: string): boolean {
|
||||
return type === 'carousel';
|
||||
}
|
||||
|
||||
export function isMediaType(type: string): boolean {
|
||||
return type === 'video_player' || type === 'audio_player';
|
||||
}
|
||||
|
||||
/**
|
||||
* Value normalization helpers
|
||||
*/
|
||||
@ -257,10 +230,3 @@ export const toUnitValue = (
|
||||
const normalized = normalizeNumberString(value);
|
||||
return normalized ? `${normalized}${unit}` : undefined;
|
||||
};
|
||||
|
||||
export const createLocalId = (): string => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
return `element-default_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
@ -20,8 +20,16 @@ import {
|
||||
parseNullableNumber,
|
||||
toUnitValue,
|
||||
toOptionalTrimmed,
|
||||
createLocalId,
|
||||
} from './types';
|
||||
import {
|
||||
createLocalId,
|
||||
isNavigationElementType,
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
isMediaElementType,
|
||||
} from '../../lib/elementDefaults';
|
||||
|
||||
interface UseElementSettingsFormOptions {
|
||||
elementType: CanvasElementType | string;
|
||||
@ -203,15 +211,13 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
const { elementType } = options;
|
||||
const [state, setState] = useState<FormState>(initialState);
|
||||
|
||||
// Type detection helpers
|
||||
const isNavigationType =
|
||||
elementType === 'navigation_next' || elementType === 'navigation_prev';
|
||||
const isTooltipType = elementType === 'tooltip';
|
||||
const isDescriptionType = elementType === 'description';
|
||||
const isGalleryType = elementType === 'gallery';
|
||||
const isCarouselType = elementType === 'carousel';
|
||||
const isMediaType =
|
||||
elementType === 'video_player' || elementType === 'audio_player';
|
||||
// Type detection using shared helpers from elementDefaults
|
||||
const isNavigationType = isNavigationElementType(elementType);
|
||||
const isTooltipType = isTooltipElementType(elementType);
|
||||
const isDescriptionType = isDescriptionElementType(elementType);
|
||||
const isGalleryType = isGalleryElementType(elementType);
|
||||
const isCarouselType = isCarouselElementType(elementType);
|
||||
const isMediaType = isMediaElementType(elementType);
|
||||
|
||||
/**
|
||||
* Apply settings from JSON to form state
|
||||
|
||||
@ -21,6 +21,7 @@ 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';
|
||||
@ -443,208 +444,10 @@ export default function RuntimePresentation({
|
||||
);
|
||||
|
||||
// Render element content based on type
|
||||
const renderElementContent = (element: any) => {
|
||||
// Navigation buttons
|
||||
if (
|
||||
element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev'
|
||||
) {
|
||||
if (element.iconUrl) {
|
||||
// Use img tag with flexible sizing - auto for dimensions not provided
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width || 'auto',
|
||||
height: element.height || 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Navigation'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='px-4 py-2 bg-white/80 rounded text-black text-sm'>
|
||||
{element.navLabel ||
|
||||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Description element
|
||||
if (element.type === 'description') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Description'
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
if (element.type === 'tooltip') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Tooltip'
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='bg-white/90 p-3 rounded max-w-[200px]'>
|
||||
<p className='font-bold text-black text-sm'>{element.tooltipTitle}</p>
|
||||
<p className='text-gray-600 text-xs'>{element.tooltipText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video player
|
||||
if (element.type === 'video_player' && element.mediaUrl) {
|
||||
return (
|
||||
<video
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio player
|
||||
if (element.type === 'audio_player' && element.mediaUrl) {
|
||||
return (
|
||||
<audio
|
||||
className='w-full'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Gallery
|
||||
if (element.type === 'gallery') {
|
||||
const cards = element.galleryCards || [];
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded'>
|
||||
{cards.map((card: any) => (
|
||||
<div key={card.id} className='relative aspect-square'>
|
||||
{card.imageUrl && (
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
||||
alt={card.title || ''}
|
||||
fill
|
||||
className='object-cover rounded'
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Carousel
|
||||
if (element.type === 'carousel') {
|
||||
const slides = element.carouselSlides || [];
|
||||
const firstSlide = slides[0];
|
||||
if (firstSlide?.imageUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || ''}
|
||||
fill
|
||||
className='object-cover rounded'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: icon or image
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt=''
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.imageUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.imageUrl)}
|
||||
alt=''
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text fallback
|
||||
if (element.text) {
|
||||
return <div className='text-white'>{element.text}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
// Use shared ElementContentRenderer for WYSIWYG consistency with constructor
|
||||
const renderElementContent = (element: any) => (
|
||||
<ElementContentRenderer element={element} />
|
||||
);
|
||||
|
||||
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
|
||||
// Blob URLs render instantly since data is local in memory
|
||||
|
||||
312
frontend/src/components/UiElements/ElementContentRenderer.tsx
Normal file
312
frontend/src/components/UiElements/ElementContentRenderer.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}) => {
|
||||
// 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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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={resolveAssetPlaybackUrl(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;
|
||||
@ -387,7 +387,9 @@ export function usePreloadOrchestrator(
|
||||
// Store in Cache API under storage key for post-refresh lookups
|
||||
if (typeof caches !== 'undefined') {
|
||||
try {
|
||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||
const cache = await caches.open(
|
||||
OFFLINE_CONFIG.cacheNames.assets,
|
||||
);
|
||||
const existingResponse = await cache.match(item.url);
|
||||
if (existingResponse) {
|
||||
await cache.put(item.storageKey, existingResponse.clone());
|
||||
|
||||
634
frontend/src/lib/elementDefaults.ts
Normal file
634
frontend/src/lib/elementDefaults.ts
Normal file
@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Element Defaults
|
||||
*
|
||||
* Single source of truth for UI element default values, creation, and merging.
|
||||
* Used by constructor, runtime, element-type-defaults, and project-element-defaults pages.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CanvasElement,
|
||||
CanvasElementType,
|
||||
GalleryCard,
|
||||
CarouselSlide,
|
||||
NavigationButtonKind,
|
||||
} from '../types/constructor';
|
||||
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
||||
|
||||
/**
|
||||
* Generate a local unique ID for elements
|
||||
*/
|
||||
export const createLocalId = (): string => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
*/
|
||||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
/**
|
||||
* Normalize appearDelaySec value
|
||||
*/
|
||||
export const normalizeAppearDelaySec = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize appearDurationSec value (null means infinite)
|
||||
*/
|
||||
export const normalizeAppearDurationSec = (value: unknown): number | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Labels for each element type
|
||||
*/
|
||||
export const ELEMENT_TYPE_LABELS: Record<CanvasElementType, string> = {
|
||||
navigation_next: 'Navigation: Forward',
|
||||
navigation_prev: 'Navigation: Back',
|
||||
spot: 'Hotspot',
|
||||
description: 'Description',
|
||||
tooltip: 'Tooltip',
|
||||
gallery: 'Gallery',
|
||||
carousel: 'Carousel',
|
||||
logo: 'Logo',
|
||||
video_player: 'Video Player',
|
||||
audio_player: 'Audio Player',
|
||||
popup: 'Popup',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get navigation button label based on type
|
||||
*/
|
||||
export const getNavigationButtonLabel = (
|
||||
type: 'navigation_next' | 'navigation_prev',
|
||||
): string => (type === 'navigation_next' ? 'Forward' : 'Back');
|
||||
|
||||
/**
|
||||
* Get navigation button kind based on type
|
||||
*/
|
||||
export const getNavigationButtonKind = (
|
||||
type: 'navigation_next' | 'navigation_prev',
|
||||
): NavigationButtonKind => (type === 'navigation_prev' ? 'back' : 'forward');
|
||||
|
||||
/**
|
||||
* Get navigation type from kind
|
||||
*/
|
||||
export const getNavigationTypeFromKind = (
|
||||
kind: NavigationButtonKind,
|
||||
): 'navigation_next' | 'navigation_prev' =>
|
||||
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
||||
|
||||
/**
|
||||
* Type-specific default values for elements.
|
||||
* These are the base defaults used when creating new elements.
|
||||
*/
|
||||
export const TYPE_SPECIFIC_DEFAULTS: Partial<
|
||||
Record<CanvasElementType, Partial<CanvasElement>>
|
||||
> = {
|
||||
navigation_next: {
|
||||
navLabel: 'Forward',
|
||||
navType: 'forward',
|
||||
navDisabled: false,
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
},
|
||||
navigation_prev: {
|
||||
navLabel: 'Back',
|
||||
navType: 'back',
|
||||
navDisabled: false,
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
},
|
||||
tooltip: {
|
||||
iconUrl: '',
|
||||
tooltipTitle: 'Tooltip title',
|
||||
tooltipText: 'Tooltip text',
|
||||
},
|
||||
description: {
|
||||
iconUrl: '',
|
||||
descriptionTitle: 'TITLE',
|
||||
descriptionText: '',
|
||||
descriptionTitleFontSize: '24px',
|
||||
descriptionTextFontSize: '16px',
|
||||
descriptionTitleFontFamily: 'inherit',
|
||||
descriptionTextFontFamily: 'inherit',
|
||||
descriptionTitleColor: '#ffffff',
|
||||
descriptionTextColor: '#ffffff',
|
||||
descriptionBackgroundColor: 'transparent',
|
||||
},
|
||||
gallery: {
|
||||
galleryCards: [],
|
||||
},
|
||||
carousel: {
|
||||
carouselSlides: [],
|
||||
carouselPrevIconUrl: '',
|
||||
carouselNextIconUrl: '',
|
||||
},
|
||||
video_player: {
|
||||
mediaUrl: '',
|
||||
mediaAutoplay: true,
|
||||
mediaLoop: true,
|
||||
mediaMuted: true,
|
||||
},
|
||||
audio_player: {
|
||||
mediaUrl: '',
|
||||
mediaAutoplay: true,
|
||||
mediaLoop: true,
|
||||
mediaMuted: false,
|
||||
},
|
||||
logo: {
|
||||
iconUrl: '',
|
||||
},
|
||||
spot: {
|
||||
iconUrl: '',
|
||||
},
|
||||
popup: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a default gallery card
|
||||
*/
|
||||
export const createDefaultGalleryCard = (index: number): GalleryCard => ({
|
||||
id: createLocalId(),
|
||||
imageUrl: '',
|
||||
title: `Card ${index + 1}`,
|
||||
description: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a default carousel slide
|
||||
*/
|
||||
export const createDefaultCarouselSlide = (index: number): CarouselSlide => ({
|
||||
id: createLocalId(),
|
||||
imageUrl: '',
|
||||
caption: `Slide ${index + 1}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new element with default values.
|
||||
* The index is used to offset position for multiple elements.
|
||||
*/
|
||||
export const createDefaultElement = (
|
||||
type: CanvasElementType,
|
||||
index = 0,
|
||||
): CanvasElement => {
|
||||
const base: CanvasElement = {
|
||||
id: createLocalId(),
|
||||
type,
|
||||
label: ELEMENT_TYPE_LABELS[type] || type,
|
||||
xPercent: clamp(12 + index * 4, 5, 80),
|
||||
yPercent: clamp(16 + index * 6, 8, 85),
|
||||
appearDelaySec: 0,
|
||||
appearDurationSec: null,
|
||||
};
|
||||
|
||||
const typeDefaults = TYPE_SPECIFIC_DEFAULTS[type] || {};
|
||||
|
||||
// Handle gallery with initial card
|
||||
if (isGalleryElementType(type)) {
|
||||
return {
|
||||
...base,
|
||||
...typeDefaults,
|
||||
galleryCards: [createDefaultGalleryCard(0)],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle carousel with initial slide
|
||||
if (isCarouselElementType(type)) {
|
||||
return {
|
||||
...base,
|
||||
...typeDefaults,
|
||||
carouselSlides: [createDefaultCarouselSlide(0)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...typeDefaults,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a gallery card from unknown input
|
||||
*/
|
||||
export const normalizeGalleryCard = (
|
||||
card: Record<string, unknown>,
|
||||
index: number,
|
||||
): GalleryCard => ({
|
||||
id: String(card?.id || createLocalId()),
|
||||
imageUrl: String(card?.imageUrl || ''),
|
||||
title: String(card?.title || `Card ${index + 1}`),
|
||||
description: String(card?.description || ''),
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a carousel slide from unknown input
|
||||
*/
|
||||
export const normalizeCarouselSlide = (
|
||||
slide: Record<string, unknown>,
|
||||
index: number,
|
||||
): CarouselSlide => ({
|
||||
id: String(slide?.id || createLocalId()),
|
||||
imageUrl: String(slide?.imageUrl || ''),
|
||||
caption: String(slide?.caption || `Slide ${index + 1}`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Merge an element with project/global defaults.
|
||||
* Used when loading elements from the database or creating new elements.
|
||||
*
|
||||
* @param element - The element to merge
|
||||
* @param defaults - Project or global defaults to apply
|
||||
* @param options.preferElementValues - If true, element values take precedence over defaults
|
||||
*/
|
||||
export const mergeElementWithDefaults = (
|
||||
element: CanvasElement,
|
||||
defaults?: Partial<CanvasElement>,
|
||||
options?: { preferElementValues?: boolean },
|
||||
): CanvasElement => {
|
||||
if (!defaults) return element;
|
||||
|
||||
const preferElementValues = Boolean(options?.preferElementValues);
|
||||
const base = preferElementValues ? defaults : element;
|
||||
const override = preferElementValues ? element : defaults;
|
||||
|
||||
const merged: CanvasElement = {
|
||||
...base,
|
||||
...override,
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
label: element.label || defaults.label || element.type,
|
||||
xPercent: element.xPercent ?? defaults.xPercent ?? 50,
|
||||
yPercent: element.yPercent ?? defaults.yPercent ?? 50,
|
||||
};
|
||||
|
||||
// For style properties, use defaults if element has empty/null/undefined value
|
||||
// This ensures DB defaults are applied when element has no explicit value
|
||||
const elementRecord = element as unknown as Record<string, unknown>;
|
||||
const defaultsRecord = defaults as unknown as Record<string, unknown>;
|
||||
const mergedRecord = merged as unknown as Record<string, unknown>;
|
||||
|
||||
ELEMENT_STYLE_PROPS.forEach((prop) => {
|
||||
const elementValue = elementRecord[prop];
|
||||
const defaultValue = defaultsRecord[prop];
|
||||
const elementIsEmpty =
|
||||
elementValue === '' ||
|
||||
elementValue === undefined ||
|
||||
elementValue === null;
|
||||
const defaultHasValue =
|
||||
defaultValue !== undefined &&
|
||||
defaultValue !== null &&
|
||||
defaultValue !== '';
|
||||
if (elementIsEmpty && defaultHasValue) {
|
||||
mergedRecord[prop] = defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Normalize position values
|
||||
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
|
||||
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
|
||||
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
|
||||
merged.appearDurationSec = normalizeAppearDurationSec(
|
||||
merged.appearDurationSec,
|
||||
);
|
||||
|
||||
// Handle gallery cards array
|
||||
if (isGalleryElementType(merged.type)) {
|
||||
const cards = preferElementValues
|
||||
? Array.isArray(element.galleryCards)
|
||||
? element.galleryCards
|
||||
: defaults.galleryCards || []
|
||||
: Array.isArray(defaults.galleryCards)
|
||||
? defaults.galleryCards
|
||||
: element.galleryCards || [];
|
||||
merged.galleryCards = cards.map((card, i) =>
|
||||
normalizeGalleryCard(card as unknown as Record<string, unknown>, i),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle carousel slides array
|
||||
if (isCarouselElementType(merged.type)) {
|
||||
const slides = preferElementValues
|
||||
? Array.isArray(element.carouselSlides)
|
||||
? element.carouselSlides
|
||||
: defaults.carouselSlides || []
|
||||
: Array.isArray(defaults.carouselSlides)
|
||||
? defaults.carouselSlides
|
||||
: element.carouselSlides || [];
|
||||
merged.carouselSlides = slides.map((slide, i) =>
|
||||
normalizeCarouselSlide(slide as unknown as Record<string, unknown>, i),
|
||||
);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse element settings from JSON string or object.
|
||||
* Used when loading element defaults from the database.
|
||||
*/
|
||||
export const parseElementSettings = (
|
||||
settingsValue?: string | Record<string, unknown>,
|
||||
): Partial<CanvasElement> => {
|
||||
if (!settingsValue) return {};
|
||||
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
if (typeof settingsValue === 'string') {
|
||||
try {
|
||||
settings = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
settings = settingsValue;
|
||||
}
|
||||
|
||||
// Parse gallery cards if present
|
||||
if (Array.isArray(settings.galleryCards)) {
|
||||
settings.galleryCards = settings.galleryCards.map((card, i) =>
|
||||
normalizeGalleryCard(card as Record<string, unknown>, i),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse carousel slides if present
|
||||
if (Array.isArray(settings.carouselSlides)) {
|
||||
settings.carouselSlides = settings.carouselSlides.map((slide, i) =>
|
||||
normalizeCarouselSlide(slide as Record<string, unknown>, i),
|
||||
);
|
||||
}
|
||||
|
||||
return settings as Partial<CanvasElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a value is empty (null, undefined, or empty string)
|
||||
*/
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === null || value === undefined || value === '';
|
||||
|
||||
/**
|
||||
* Add value to settings if not empty
|
||||
*/
|
||||
const addIfNotEmpty = (
|
||||
settings: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (!isEmpty(value)) {
|
||||
settings[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build settings JSON object from element properties.
|
||||
* Used when saving element defaults to the database.
|
||||
*/
|
||||
export const buildElementSettings = (
|
||||
element: Partial<CanvasElement>,
|
||||
elementType: CanvasElementType | string,
|
||||
): Record<string, unknown> => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
|
||||
// Common properties
|
||||
addIfNotEmpty(settings, 'label', element.label);
|
||||
if (element.xPercent !== undefined) settings.xPercent = element.xPercent;
|
||||
if (element.yPercent !== undefined) settings.yPercent = element.yPercent;
|
||||
if (element.appearDelaySec !== undefined) {
|
||||
settings.appearDelaySec = normalizeAppearDelaySec(element.appearDelaySec);
|
||||
}
|
||||
if (element.appearDurationSec !== undefined) {
|
||||
settings.appearDurationSec = normalizeAppearDurationSec(
|
||||
element.appearDurationSec,
|
||||
);
|
||||
}
|
||||
|
||||
// Style properties
|
||||
ELEMENT_STYLE_PROPS.forEach((prop) => {
|
||||
const value = (element as Record<string, unknown>)[prop];
|
||||
addIfNotEmpty(settings, prop, value);
|
||||
});
|
||||
|
||||
// Effect properties
|
||||
const effectProps = [
|
||||
'appearAnimation',
|
||||
'appearAnimationDuration',
|
||||
'appearAnimationEasing',
|
||||
'hoverScale',
|
||||
'hoverOpacity',
|
||||
'hoverBackgroundColor',
|
||||
'hoverColor',
|
||||
'hoverBoxShadow',
|
||||
'hoverTransitionDuration',
|
||||
'focusScale',
|
||||
'focusOpacity',
|
||||
'focusOutline',
|
||||
'focusBoxShadow',
|
||||
'activeScale',
|
||||
'activeOpacity',
|
||||
'activeBackgroundColor',
|
||||
];
|
||||
effectProps.forEach((prop) => {
|
||||
const value = (element as Record<string, unknown>)[prop];
|
||||
addIfNotEmpty(settings, prop, value);
|
||||
});
|
||||
|
||||
// Navigation type settings
|
||||
if (isNavigationElementType(elementType)) {
|
||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||
addIfNotEmpty(settings, 'navLabel', element.navLabel);
|
||||
addIfNotEmpty(settings, 'navType', element.navType);
|
||||
if (element.navDisabled !== undefined) {
|
||||
settings.navDisabled = element.navDisabled;
|
||||
}
|
||||
addIfNotEmpty(settings, 'targetPageId', element.targetPageId);
|
||||
addIfNotEmpty(settings, 'targetPageSlug', element.targetPageSlug);
|
||||
addIfNotEmpty(settings, 'transitionVideoUrl', element.transitionVideoUrl);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'transitionReverseMode',
|
||||
element.transitionReverseMode,
|
||||
);
|
||||
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
|
||||
}
|
||||
|
||||
// Tooltip type settings
|
||||
if (isTooltipElementType(elementType)) {
|
||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
|
||||
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
|
||||
}
|
||||
|
||||
// Description type settings
|
||||
if (isDescriptionElementType(elementType)) {
|
||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||
addIfNotEmpty(settings, 'descriptionTitle', element.descriptionTitle);
|
||||
addIfNotEmpty(settings, 'descriptionText', element.descriptionText);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTitleFontSize',
|
||||
element.descriptionTitleFontSize,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTextFontSize',
|
||||
element.descriptionTextFontSize,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTitleFontFamily',
|
||||
element.descriptionTitleFontFamily,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTextFontFamily',
|
||||
element.descriptionTextFontFamily,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTitleColor',
|
||||
element.descriptionTitleColor,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionTextColor',
|
||||
element.descriptionTextColor,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'descriptionBackgroundColor',
|
||||
element.descriptionBackgroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
// Gallery type settings
|
||||
if (isGalleryElementType(elementType) && Array.isArray(element.galleryCards)) {
|
||||
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
||||
id: String(card.id || createLocalId()),
|
||||
imageUrl: card.imageUrl || '',
|
||||
title: card.title || `Card ${i + 1}`,
|
||||
description: card.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// Carousel type settings
|
||||
if (isCarouselElementType(elementType)) {
|
||||
if (Array.isArray(element.carouselSlides)) {
|
||||
settings.carouselSlides = element.carouselSlides.map((slide, i) => ({
|
||||
id: String(slide.id || createLocalId()),
|
||||
imageUrl: slide.imageUrl || '',
|
||||
caption: slide.caption || `Slide ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
addIfNotEmpty(settings, 'carouselPrevIconUrl', element.carouselPrevIconUrl);
|
||||
addIfNotEmpty(settings, 'carouselNextIconUrl', element.carouselNextIconUrl);
|
||||
}
|
||||
|
||||
// Media type settings
|
||||
if (isMediaElementType(elementType)) {
|
||||
addIfNotEmpty(settings, 'mediaUrl', element.mediaUrl);
|
||||
if (element.mediaAutoplay !== undefined) {
|
||||
settings.mediaAutoplay = element.mediaAutoplay;
|
||||
}
|
||||
if (element.mediaLoop !== undefined) {
|
||||
settings.mediaLoop = element.mediaLoop;
|
||||
}
|
||||
if (element.mediaMuted !== undefined) {
|
||||
settings.mediaMuted = element.mediaMuted;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo/spot type settings
|
||||
if (isLogoElementType(elementType) || isSpotElementType(elementType)) {
|
||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type detection helpers for element types.
|
||||
* Single source of truth used across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a type is a navigation element type
|
||||
*/
|
||||
export const isNavigationElementType = (
|
||||
type: string,
|
||||
): type is 'navigation_next' | 'navigation_prev' =>
|
||||
type === 'navigation_next' || type === 'navigation_prev';
|
||||
|
||||
/**
|
||||
* Check if a type is a tooltip element type
|
||||
*/
|
||||
export const isTooltipElementType = (type: string): type is 'tooltip' =>
|
||||
type === 'tooltip';
|
||||
|
||||
/**
|
||||
* Check if a type is a description element type
|
||||
*/
|
||||
export const isDescriptionElementType = (type: string): type is 'description' =>
|
||||
type === 'description';
|
||||
|
||||
/**
|
||||
* Check if a type is a gallery element type
|
||||
*/
|
||||
export const isGalleryElementType = (type: string): type is 'gallery' =>
|
||||
type === 'gallery';
|
||||
|
||||
/**
|
||||
* Check if a type is a carousel element type
|
||||
*/
|
||||
export const isCarouselElementType = (type: string): type is 'carousel' =>
|
||||
type === 'carousel';
|
||||
|
||||
/**
|
||||
* Check if a type is a media (video/audio player) element type
|
||||
*/
|
||||
export const isMediaElementType = (
|
||||
type: string,
|
||||
): type is 'video_player' | 'audio_player' =>
|
||||
type === 'video_player' || type === 'audio_player';
|
||||
|
||||
/**
|
||||
* Check if a type is a video player element type
|
||||
*/
|
||||
export const isVideoPlayerElementType = (
|
||||
type: string,
|
||||
): type is 'video_player' => type === 'video_player';
|
||||
|
||||
/**
|
||||
* Check if a type is an audio player element type
|
||||
*/
|
||||
export const isAudioPlayerElementType = (
|
||||
type: string,
|
||||
): type is 'audio_player' => type === 'audio_player';
|
||||
|
||||
/**
|
||||
* Check if a type is a logo element type
|
||||
*/
|
||||
export const isLogoElementType = (type: string): type is 'logo' =>
|
||||
type === 'logo';
|
||||
|
||||
/**
|
||||
* Check if a type is a spot (hotspot) element type
|
||||
*/
|
||||
export const isSpotElementType = (type: string): type is 'spot' =>
|
||||
type === 'spot';
|
||||
|
||||
/**
|
||||
* Check if a type is a popup element type
|
||||
*/
|
||||
export const isPopupElementType = (type: string): type is 'popup' =>
|
||||
type === 'popup';
|
||||
@ -30,6 +30,7 @@ import React, {
|
||||
} from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { ElementContentRenderer } from '../components/UiElements/ElementContentRenderer';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
@ -39,7 +40,26 @@ import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { logger } from '../lib/logger';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { parseJsonObject } from '../lib/parseJson';
|
||||
import { buildElementStyle, ELEMENT_STYLE_PROPS } from '../lib/elementStyles';
|
||||
import { buildElementStyle } from '../lib/elementStyles';
|
||||
import {
|
||||
createDefaultElement,
|
||||
mergeElementWithDefaults,
|
||||
createLocalId,
|
||||
clamp,
|
||||
normalizeAppearDelaySec,
|
||||
normalizeAppearDurationSec,
|
||||
ELEMENT_TYPE_LABELS,
|
||||
getNavigationButtonLabel,
|
||||
getNavigationButtonKind,
|
||||
getNavigationTypeFromKind,
|
||||
isNavigationElementType,
|
||||
isTooltipElementType,
|
||||
isDescriptionElementType,
|
||||
isGalleryElementType,
|
||||
isCarouselElementType,
|
||||
isMediaElementType,
|
||||
isVideoPlayerElementType,
|
||||
} from '../lib/elementDefaults';
|
||||
import type { PreloadPageLink, PreloadElement } from '../types/preload';
|
||||
import type {
|
||||
CanvasElementType,
|
||||
@ -107,34 +127,11 @@ type ConstructorPageProps = {
|
||||
|
||||
type ConstructorInteractionMode = 'edit' | 'interact';
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const normalizeAppearDelaySec = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return Number(parsed);
|
||||
};
|
||||
|
||||
const normalizeAppearDurationSec = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return Number(parsed);
|
||||
};
|
||||
|
||||
const getTrimmedCssValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const createLocalId = () => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `constructor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
const getAssetLabel = (asset: ProjectAsset) => {
|
||||
const baseName = asset.name?.trim() || 'Untitled asset';
|
||||
@ -281,222 +278,26 @@ const addFallbackAssetOption = (
|
||||
];
|
||||
};
|
||||
|
||||
const labelByType: Record<CanvasElementType, string> = {
|
||||
navigation_next: 'Navigation: Forward',
|
||||
navigation_prev: 'Navigation: Back',
|
||||
spot: 'Hotspot',
|
||||
description: 'Description',
|
||||
tooltip: 'Tooltip',
|
||||
gallery: 'Gallery',
|
||||
carousel: 'Carousel',
|
||||
logo: 'Logo',
|
||||
video_player: 'Video Player',
|
||||
audio_player: 'Audio Player',
|
||||
popup: 'Popup',
|
||||
};
|
||||
|
||||
const isNavigationElementType = (
|
||||
type: CanvasElementType,
|
||||
): type is NavigationElementType =>
|
||||
type === 'navigation_next' || type === 'navigation_prev';
|
||||
|
||||
const getNavigationButtonLabel = (type: NavigationElementType) =>
|
||||
type === 'navigation_next' ? 'Forward' : 'Back';
|
||||
|
||||
const getNavigationButtonKind = (
|
||||
type: NavigationElementType,
|
||||
): NavigationButtonKind => (type === 'navigation_prev' ? 'back' : 'forward');
|
||||
|
||||
const getNavigationTypeFromKind = (
|
||||
kind: NavigationButtonKind,
|
||||
): NavigationElementType =>
|
||||
kind === 'back' ? 'navigation_prev' : 'navigation_next';
|
||||
|
||||
const createDefaultElement = (
|
||||
type: CanvasElementType,
|
||||
index: number,
|
||||
): CanvasElement => {
|
||||
const base: CanvasElement = {
|
||||
id: createLocalId(),
|
||||
type,
|
||||
label: labelByType[type],
|
||||
xPercent: clamp(12 + index * 4, 5, 80),
|
||||
yPercent: clamp(16 + index * 6, 8, 85),
|
||||
appearDelaySec: 0,
|
||||
appearDurationSec: null,
|
||||
};
|
||||
|
||||
if (type === 'gallery') {
|
||||
return {
|
||||
...base,
|
||||
galleryCards: [
|
||||
{ id: createLocalId(), imageUrl: '', title: 'Card 1', description: '' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'carousel') {
|
||||
return {
|
||||
...base,
|
||||
carouselSlides: [
|
||||
{ id: createLocalId(), imageUrl: '', caption: 'Slide 1' },
|
||||
],
|
||||
carouselPrevIconUrl: '',
|
||||
carouselNextIconUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'tooltip') {
|
||||
return {
|
||||
...base,
|
||||
iconUrl: '',
|
||||
tooltipTitle: 'Tooltip title',
|
||||
tooltipText: 'Tooltip text',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'description') {
|
||||
return {
|
||||
...base,
|
||||
iconUrl: '',
|
||||
descriptionTitle: 'TITLE',
|
||||
descriptionText: '',
|
||||
descriptionTitleFontSize: '48px',
|
||||
descriptionTextFontSize: '36px',
|
||||
descriptionTitleFontFamily: 'inherit',
|
||||
descriptionTextFontFamily: 'inherit',
|
||||
descriptionTitleColor: '#000000',
|
||||
descriptionTextColor: '#4B5563',
|
||||
descriptionBackgroundColor: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'navigation_next' || type === 'navigation_prev') {
|
||||
return {
|
||||
...base,
|
||||
navLabel: getNavigationButtonLabel(type),
|
||||
navType: getNavigationButtonKind(type),
|
||||
navDisabled: false,
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'video_player' || type === 'audio_player') {
|
||||
return {
|
||||
...base,
|
||||
mediaUrl: '',
|
||||
mediaAutoplay: true,
|
||||
mediaLoop: true,
|
||||
mediaMuted: type === 'video_player',
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const mergeElementWithDefaults = (
|
||||
element: CanvasElement,
|
||||
defaults?: Partial<CanvasElement>,
|
||||
options?: { preferElementValues?: boolean },
|
||||
): CanvasElement => {
|
||||
if (!defaults) return element;
|
||||
|
||||
const preferElementValues = Boolean(options?.preferElementValues);
|
||||
const base = preferElementValues ? defaults : element;
|
||||
const override = preferElementValues ? element : defaults;
|
||||
const merged: CanvasElement = {
|
||||
...base,
|
||||
...override,
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
label: element.label || defaults.label || element.type,
|
||||
xPercent: element.xPercent ?? defaults.xPercent ?? 50,
|
||||
yPercent: element.yPercent ?? defaults.yPercent ?? 50,
|
||||
};
|
||||
|
||||
// For style properties, use defaults if element has empty/null/undefined value
|
||||
// This ensures DB defaults are applied when element has no explicit value
|
||||
const elementRecord = element as unknown as Record<string, unknown>;
|
||||
const defaultsRecord = defaults as unknown as Record<string, unknown>;
|
||||
const mergedRecord = merged as unknown as Record<string, unknown>;
|
||||
ELEMENT_STYLE_PROPS.forEach((prop) => {
|
||||
const elementValue = elementRecord[prop];
|
||||
const defaultValue = defaultsRecord[prop];
|
||||
const elementIsEmpty =
|
||||
elementValue === '' ||
|
||||
elementValue === undefined ||
|
||||
elementValue === null;
|
||||
const defaultHasValue =
|
||||
defaultValue !== undefined &&
|
||||
defaultValue !== null &&
|
||||
defaultValue !== '';
|
||||
if (elementIsEmpty && defaultHasValue) {
|
||||
mergedRecord[prop] = defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
|
||||
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
|
||||
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
|
||||
merged.appearDurationSec = normalizeAppearDurationSec(
|
||||
merged.appearDurationSec,
|
||||
);
|
||||
|
||||
if (merged.type === 'gallery') {
|
||||
const cards = preferElementValues
|
||||
? Array.isArray(element.galleryCards)
|
||||
? element.galleryCards
|
||||
: defaults.galleryCards || []
|
||||
: Array.isArray(defaults.galleryCards)
|
||||
? defaults.galleryCards
|
||||
: element.galleryCards || [];
|
||||
merged.galleryCards = cards.map((card, cardIndex) => ({
|
||||
id: String(card?.id || createLocalId()),
|
||||
imageUrl: String(card?.imageUrl || ''),
|
||||
title: String(card?.title || `Card ${cardIndex + 1}`),
|
||||
description: String(card?.description || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
if (merged.type === 'carousel') {
|
||||
const slides = preferElementValues
|
||||
? Array.isArray(element.carouselSlides)
|
||||
? element.carouselSlides
|
||||
: defaults.carouselSlides || []
|
||||
: Array.isArray(defaults.carouselSlides)
|
||||
? defaults.carouselSlides
|
||||
: element.carouselSlides || [];
|
||||
merged.carouselSlides = slides.map((slide, slideIndex) => ({
|
||||
id: String(slide?.id || createLocalId()),
|
||||
imageUrl: String(slide?.imageUrl || ''),
|
||||
caption: String(slide?.caption || `Slide ${slideIndex + 1}`),
|
||||
}));
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
// Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
|
||||
const labelByType = ELEMENT_TYPE_LABELS;
|
||||
|
||||
const getElementButtonTitle = (element: CanvasElement) => {
|
||||
if (element.type === 'gallery') {
|
||||
if (isGalleryElementType(element.type)) {
|
||||
return `${element.label} (${element.galleryCards?.length || 0})`;
|
||||
}
|
||||
|
||||
if (element.type === 'carousel') {
|
||||
if (isCarouselElementType(element.type)) {
|
||||
return `${element.label} (${element.carouselSlides?.length || 0})`;
|
||||
}
|
||||
|
||||
if (element.type === 'tooltip') return element.tooltipTitle ?? '';
|
||||
if (element.type === 'description') return element.descriptionTitle ?? '';
|
||||
if (element.type === 'navigation_next' || element.type === 'navigation_prev')
|
||||
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
|
||||
if (isDescriptionElementType(element.type)) return element.descriptionTitle ?? '';
|
||||
if (isNavigationElementType(element.type))
|
||||
return (
|
||||
element.navLabel?.trim() ||
|
||||
getNavigationButtonLabel(element.type as NavigationElementType)
|
||||
);
|
||||
if (
|
||||
(element.type === 'video_player' || element.type === 'audio_player') &&
|
||||
element.mediaUrl
|
||||
) {
|
||||
if (isMediaElementType(element.type) && element.mediaUrl) {
|
||||
return `${element.label} · configured`;
|
||||
}
|
||||
|
||||
@ -939,13 +740,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
if (
|
||||
selectedElement &&
|
||||
(selectedElement.type === 'video_player' ||
|
||||
selectedElement.type === 'audio_player') &&
|
||||
isMediaElementType(selectedElement.type) &&
|
||||
selectedElement.mediaUrl
|
||||
) {
|
||||
targets.push({
|
||||
source: selectedElement.mediaUrl,
|
||||
mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio',
|
||||
mediaType: isVideoPlayerElementType(selectedElement.type)
|
||||
? 'video'
|
||||
: 'audio',
|
||||
});
|
||||
}
|
||||
|
||||
@ -1029,11 +831,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
[backgroundAudioUrl, getKnownDurationForSource],
|
||||
);
|
||||
const selectedMediaDurationNote = useMemo(() => {
|
||||
if (
|
||||
!selectedElement ||
|
||||
(selectedElement.type !== 'video_player' &&
|
||||
selectedElement.type !== 'audio_player')
|
||||
) {
|
||||
if (!selectedElement || !isMediaElementType(selectedElement.type)) {
|
||||
return 'Duration: unknown';
|
||||
}
|
||||
|
||||
@ -1440,7 +1238,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
mediaMuted:
|
||||
typeof item.mediaMuted === 'boolean'
|
||||
? item.mediaMuted
|
||||
: item.type === 'video_player',
|
||||
: isVideoPlayerElementType(item.type),
|
||||
};
|
||||
|
||||
return mergeElementWithDefaults(
|
||||
@ -1640,10 +1438,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
: allowedNavigationTypes[0]
|
||||
: type;
|
||||
const baseElement = createDefaultElement(nextElementType, elements.length);
|
||||
const nextElement = mergeElementWithDefaults(
|
||||
baseElement,
|
||||
uiElementDefaultsByType[nextElementType],
|
||||
);
|
||||
const defaults = uiElementDefaultsByType[nextElementType];
|
||||
const nextElement = mergeElementWithDefaults(baseElement, defaults);
|
||||
|
||||
setElements((prev) => [...prev, nextElement]);
|
||||
selectElementForEdit(nextElement.id);
|
||||
setSuccessMessage('Element added. Drag it to set position.');
|
||||
@ -2144,261 +1941,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
};
|
||||
};
|
||||
|
||||
const renderCanvasElementContent = (element: CanvasElement) => {
|
||||
if (
|
||||
element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev'
|
||||
) {
|
||||
const fallbackNavLabel = getNavigationButtonLabel(element.type);
|
||||
const navigationLabel = element.navLabel?.trim() || fallbackNavLabel;
|
||||
if (element.iconUrl) {
|
||||
// Use img tag with flexible sizing - auto for dimensions not provided
|
||||
const imgStyle: React.CSSProperties = {
|
||||
width: element.width || 'auto',
|
||||
height: element.height || 'auto',
|
||||
objectFit: 'contain',
|
||||
};
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Navigation icon'
|
||||
style={imgStyle}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Support both targetPageSlug (new) and targetPageId (legacy)
|
||||
const targetPageName = element.targetPageSlug
|
||||
? pageNameBySlug[element.targetPageSlug]
|
||||
: element.targetPageId
|
||||
? pageNameById[element.targetPageId]
|
||||
: '';
|
||||
return (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{navigationLabel}</span>
|
||||
</div>
|
||||
{targetPageName ? (
|
||||
<span className='text-[10px] text-gray-500'>
|
||||
To: {targetPageName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'tooltip') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Tooltip icon'
|
||||
className='max-h-[220px] max-w-[220px] object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-w-[200px] text-left'>
|
||||
<p className='text-[11px] font-bold'>{element.tooltipTitle}</p>
|
||||
<p className='text-[10px] text-gray-600 line-clamp-3'>
|
||||
{element.tooltipText || 'Tooltip text'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'description') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Description icon'
|
||||
className='max-h-[220px] max-w-[220px] object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
||||
return (
|
||||
<div
|
||||
className='w-full text-left p-2 rounded'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<p
|
||||
className='font-bold'
|
||||
style={{
|
||||
fontSize: element.descriptionTitleFontSize || '48px',
|
||||
fontFamily: element.descriptionTitleFontFamily || 'inherit',
|
||||
color: element.descriptionTitleColor || '#000000',
|
||||
}}
|
||||
>
|
||||
{element.descriptionTitle || 'TITLE'}
|
||||
</p>
|
||||
{element.descriptionText && (
|
||||
<p
|
||||
className='line-clamp-4'
|
||||
style={{
|
||||
fontSize: element.descriptionTextFontSize || '36px',
|
||||
fontFamily: element.descriptionTextFontFamily || 'inherit',
|
||||
color: element.descriptionTextColor || '#4B5563',
|
||||
}}
|
||||
>
|
||||
{element.descriptionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'gallery') {
|
||||
const cards = element.galleryCards || [];
|
||||
return (
|
||||
<div className='w-[220px]'>
|
||||
<p className='mb-1 text-left text-[10px] font-semibold text-gray-600'>
|
||||
Gallery ({cards.length})
|
||||
</p>
|
||||
<div className='grid grid-cols-3 gap-1'>
|
||||
{cards.slice(0, 6).map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className='h-12 overflow-hidden rounded bg-gray-100'
|
||||
>
|
||||
{card.imageUrl ? (
|
||||
<div className='relative h-full w-full'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
||||
alt={card.title || 'Gallery card'}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center text-[9px] text-gray-400'>
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'carousel') {
|
||||
const firstSlide = (element.carouselSlides || [])[0];
|
||||
return (
|
||||
<div className='w-[220px] text-left'>
|
||||
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
||||
Carousel ({element.carouselSlides?.length || 0})
|
||||
</p>
|
||||
<div className='h-20 overflow-hidden rounded bg-gray-100'>
|
||||
{firstSlide?.imageUrl ? (
|
||||
<div className='relative h-full w-full'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || 'Carousel slide'}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center text-[10px] text-gray-400'>
|
||||
No slide image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className='mt-1 text-[10px] text-gray-600 line-clamp-1'>
|
||||
{firstSlide?.caption || 'No caption'}
|
||||
</p>
|
||||
{(element.carouselPrevIconUrl || element.carouselNextIconUrl) && (
|
||||
<div className='mt-1 flex items-center justify-between text-[9px] text-gray-500'>
|
||||
<span className='flex items-center gap-1'>
|
||||
{element.carouselPrevIconUrl ? (
|
||||
<div className='relative h-3 w-3'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
|
||||
alt='Previous icon'
|
||||
className='w-3 h-3 object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
Prev
|
||||
</span>
|
||||
<span className='flex items-center gap-1'>
|
||||
Next
|
||||
{element.carouselNextIconUrl ? (
|
||||
<div className='relative h-3 w-3'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
|
||||
alt='Next icon'
|
||||
className='w-3 h-3 object-contain'
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'video_player') {
|
||||
return (
|
||||
<div className='w-[220px] text-left'>
|
||||
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
||||
Video player
|
||||
</p>
|
||||
<video
|
||||
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`}
|
||||
className='h-24 w-full rounded bg-black object-cover'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'audio_player') {
|
||||
return (
|
||||
<div className='w-[240px] text-left'>
|
||||
<p className='mb-1 text-[10px] font-semibold text-gray-600'>
|
||||
Audio player
|
||||
</p>
|
||||
<audio
|
||||
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
|
||||
className='w-full'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return getElementButtonTitle(element);
|
||||
};
|
||||
// Use shared ElementContentRenderer for WYSIWYG consistency with runtime
|
||||
const renderCanvasElementContent = (element: CanvasElement) => (
|
||||
<ElementContentRenderer element={element} />
|
||||
);
|
||||
|
||||
const isElementVisibleOnCanvas = (element: CanvasElement) => {
|
||||
const delay = Number(element.appearDelaySec || 0);
|
||||
@ -2419,10 +1965,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
const isElementReadyForCanvasRender = (element: CanvasElement) => {
|
||||
const isPreloadableIconElement =
|
||||
(element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev' ||
|
||||
element.type === 'tooltip' ||
|
||||
element.type === 'description') &&
|
||||
(isNavigationElementType(element.type) ||
|
||||
isTooltipElementType(element.type) ||
|
||||
isDescriptionElementType(element.type)) &&
|
||||
Boolean(element.iconUrl);
|
||||
|
||||
if (!isPreloadableIconElement) return true;
|
||||
@ -2695,26 +2240,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
const hasIconDrivenSize =
|
||||
Boolean(element.iconUrl) &&
|
||||
(element.type === 'tooltip' ||
|
||||
element.type === 'description' ||
|
||||
element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev');
|
||||
(isTooltipElementType(element.type) ||
|
||||
isDescriptionElementType(element.type) ||
|
||||
isNavigationElementType(element.type));
|
||||
const isNavigationIconElement =
|
||||
Boolean(element.iconUrl) &&
|
||||
(element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev');
|
||||
isNavigationElementType(element.type);
|
||||
const hasTransparentBackground =
|
||||
(element.type === 'description' &&
|
||||
(isDescriptionElementType(element.type) &&
|
||||
!element.iconUrl &&
|
||||
(!element.descriptionBackgroundColor ||
|
||||
element.descriptionBackgroundColor === 'transparent')) ||
|
||||
// Navigation buttons with icons should be transparent (icon is visible)
|
||||
((element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev') &&
|
||||
(isNavigationElementType(element.type) &&
|
||||
Boolean(element.iconUrl)) ||
|
||||
element.type === 'tooltip' ||
|
||||
element.type === 'gallery' ||
|
||||
element.type === 'carousel';
|
||||
isTooltipElementType(element.type) ||
|
||||
isGalleryElementType(element.type) ||
|
||||
isCarouselElementType(element.type);
|
||||
const isNavDisabled =
|
||||
isNavigationElementType(element.type) &&
|
||||
(element.navDisabled ||
|
||||
@ -3014,8 +2556,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedElement.type === 'navigation_next' ||
|
||||
selectedElement.type === 'navigation_prev') && (
|
||||
{isNavigationElementType(selectedElement.type) && (
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
@ -3274,7 +2815,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
)}
|
||||
|
||||
{selectedElement &&
|
||||
selectedElement.type === 'tooltip' && (
|
||||
isTooltipElementType(selectedElement.type) && (
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
@ -3337,7 +2878,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
)}
|
||||
|
||||
{selectedElement &&
|
||||
selectedElement.type === 'description' && (
|
||||
isDescriptionElementType(selectedElement.type) && (
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
@ -3531,12 +3072,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
)}
|
||||
|
||||
{selectedElement &&
|
||||
(selectedElement.type === 'video_player' ||
|
||||
selectedElement.type === 'audio_player') && (
|
||||
isMediaElementType(selectedElement.type) && (
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
{selectedElement.type === 'video_player'
|
||||
{isVideoPlayerElementType(selectedElement.type)
|
||||
? 'Video asset'
|
||||
: 'Audio asset'}
|
||||
</label>
|
||||
@ -3551,7 +3091,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
>
|
||||
<option value=''>Not selected</option>
|
||||
{addFallbackAssetOption(
|
||||
selectedElement.type === 'video_player'
|
||||
isVideoPlayerElementType(selectedElement.type)
|
||||
? videoAssetOptions
|
||||
: audioAssetOptions,
|
||||
selectedElement.mediaUrl,
|
||||
@ -3592,7 +3132,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
{selectedElement.type === 'video_player' && (
|
||||
{isVideoPlayerElementType(selectedElement.type) && (
|
||||
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
@ -3612,7 +3152,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
)}
|
||||
|
||||
{selectedElement &&
|
||||
selectedElement.type === 'gallery' && (
|
||||
isGalleryElementType(selectedElement.type) && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-[11px] font-semibold text-gray-600'>
|
||||
@ -3697,7 +3237,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
)}
|
||||
|
||||
{selectedElement &&
|
||||
selectedElement.type === 'carousel' && (
|
||||
isCarouselElementType(selectedElement.type) && (
|
||||
<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'>
|
||||
|
||||
@ -112,7 +112,9 @@ const buildThemeConfigJson = (values: {
|
||||
/**
|
||||
* Build custom_css_json from individual fields
|
||||
*/
|
||||
const buildCustomCssJson = (values: { customFontFamily: string }): string | null => {
|
||||
const buildCustomCssJson = (values: {
|
||||
customFontFamily: string;
|
||||
}): string | null => {
|
||||
const config: Record<string, string> = {};
|
||||
|
||||
if (values.customFontFamily.trim()) {
|
||||
@ -410,10 +412,7 @@ const EditProjectsPage = () => {
|
||||
)}
|
||||
|
||||
<FormField label='Theme Primary Color'>
|
||||
<Field
|
||||
name='themePrimaryColor'
|
||||
placeholder='e.g. #E7DDB5'
|
||||
/>
|
||||
<Field name='themePrimaryColor' placeholder='e.g. #E7DDB5' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Theme Background Color'>
|
||||
@ -424,10 +423,7 @@ const EditProjectsPage = () => {
|
||||
</FormField>
|
||||
|
||||
<FormField label='Theme Text Color'>
|
||||
<Field
|
||||
name='themeTextColor'
|
||||
placeholder='e.g. #FFFFFF'
|
||||
/>
|
||||
<Field name='themeTextColor' placeholder='e.g. #FFFFFF' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Custom Font Family'>
|
||||
|
||||
@ -22,12 +22,15 @@ const ProjectsListPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const projectsRaw = useAppSelector((state) => state.projects.projects);
|
||||
const projectsRaw = useAppSelector((state) => state.projects.projects) as
|
||||
| Project[]
|
||||
| Project
|
||||
| undefined;
|
||||
// Handle both array (from list fetch) and single object (after edit fetch)
|
||||
const projects: Project[] = Array.isArray(projectsRaw)
|
||||
? projectsRaw
|
||||
: projectsRaw
|
||||
? [projectsRaw as Project]
|
||||
? [projectsRaw]
|
||||
: [];
|
||||
const isLoading = useAppSelector((state) => state.projects.loading);
|
||||
|
||||
|
||||
@ -38,6 +38,12 @@ export interface Project extends BaseEntity {
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
favicon_url?: string;
|
||||
og_image_url?: string;
|
||||
theme_config_json?: string;
|
||||
custom_css_json?: string;
|
||||
cdn_base_url?: string;
|
||||
}
|
||||
|
||||
// Asset entity
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user