fixed UI elements rendering logic

This commit is contained in:
Dmitri 2026-03-28 08:51:47 +04:00
parent 41713d1274
commit b925094555
13 changed files with 1055 additions and 784 deletions

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@
import React from 'react'; import React from 'react';
import FormField from '../FormField'; import FormField from '../FormField';
import { isVideoPlayerElementType } from '../../lib/elementDefaults';
import type { MediaSettingsSectionProps } from './types'; import type { MediaSettingsSectionProps } from './types';
const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({ const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({
@ -20,7 +21,7 @@ const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({
audioAssetOptions = [], audioAssetOptions = [],
}) => { }) => {
const isConstructor = context === 'constructor'; const isConstructor = context === 'constructor';
const isVideo = elementType === 'video_player'; const isVideo = isVideoPlayerElementType(elementType);
const assetOptions = isVideo ? videoAssetOptions : audioAssetOptions; const assetOptions = isVideo ? videoAssetOptions : audioAssetOptions;
return ( return (

View File

@ -83,7 +83,9 @@ const NavigationSettingsSection: React.FC<NavigationSettingsSectionProps> = ({
<input <input
type='checkbox' type='checkbox'
checked={navDisabled} checked={navDisabled}
onChange={(event) => onChange('navDisabled', event.target.checked)} onChange={(event) =>
onChange('navDisabled', event.target.checked)
}
/> />
Button is disabled (not clickable) Button is disabled (not clickable)
</label> </label>

View File

@ -187,33 +187,6 @@ export interface ElementSettingsTabsProps {
tabs: { id: string; label: string }[]; 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 * Value normalization helpers
*/ */
@ -257,10 +230,3 @@ export const toUnitValue = (
const normalized = normalizeNumberString(value); const normalized = normalizeNumberString(value);
return normalized ? `${normalized}${unit}` : undefined; 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)}`;
};

View File

@ -20,8 +20,16 @@ import {
parseNullableNumber, parseNullableNumber,
toUnitValue, toUnitValue,
toOptionalTrimmed, toOptionalTrimmed,
createLocalId,
} from './types'; } from './types';
import {
createLocalId,
isNavigationElementType,
isTooltipElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isMediaElementType,
} from '../../lib/elementDefaults';
interface UseElementSettingsFormOptions { interface UseElementSettingsFormOptions {
elementType: CanvasElementType | string; elementType: CanvasElementType | string;
@ -203,15 +211,13 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
const { elementType } = options; const { elementType } = options;
const [state, setState] = useState<FormState>(initialState); const [state, setState] = useState<FormState>(initialState);
// Type detection helpers // Type detection using shared helpers from elementDefaults
const isNavigationType = const isNavigationType = isNavigationElementType(elementType);
elementType === 'navigation_next' || elementType === 'navigation_prev'; const isTooltipType = isTooltipElementType(elementType);
const isTooltipType = elementType === 'tooltip'; const isDescriptionType = isDescriptionElementType(elementType);
const isDescriptionType = elementType === 'description'; const isGalleryType = isGalleryElementType(elementType);
const isGalleryType = elementType === 'gallery'; const isCarouselType = isCarouselElementType(elementType);
const isCarouselType = elementType === 'carousel'; const isMediaType = isMediaElementType(elementType);
const isMediaType =
elementType === 'video_player' || elementType === 'audio_player';
/** /**
* Apply settings from JSON to form state * Apply settings from JSON to form state

View File

@ -21,6 +21,7 @@ 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';
@ -443,208 +444,10 @@ export default function RuntimePresentation({
); );
// Render element content based on type // Render element content based on type
const renderElementContent = (element: any) => { // Use shared ElementContentRenderer for WYSIWYG consistency with constructor
// Navigation buttons const renderElementContent = (element: any) => (
if ( <ElementContentRenderer element={element} />
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 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

View 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;

View File

@ -387,7 +387,9 @@ export function usePreloadOrchestrator(
// Store in Cache API under storage key for post-refresh lookups // Store in Cache API under storage key for post-refresh lookups
if (typeof caches !== 'undefined') { if (typeof caches !== 'undefined') {
try { 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); const existingResponse = await cache.match(item.url);
if (existingResponse) { if (existingResponse) {
await cache.put(item.storageKey, existingResponse.clone()); await cache.put(item.storageKey, existingResponse.clone());

View 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';

View File

@ -30,6 +30,7 @@ import React, {
} from 'react'; } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon'; import BaseIcon from '../components/BaseIcon';
import { ElementContentRenderer } from '../components/UiElements/ElementContentRenderer';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
@ -39,7 +40,26 @@ import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonObject } from '../lib/parseJson'; 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 { PreloadPageLink, PreloadElement } from '../types/preload';
import type { import type {
CanvasElementType, CanvasElementType,
@ -107,34 +127,11 @@ type ConstructorPageProps = {
type ConstructorInteractionMode = 'edit' | 'interact'; 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) => { const getTrimmedCssValue = (value: unknown) => {
if (value === null || value === undefined) return ''; if (value === null || value === undefined) return '';
return String(value).trim(); 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 getAssetLabel = (asset: ProjectAsset) => {
const baseName = asset.name?.trim() || 'Untitled asset'; const baseName = asset.name?.trim() || 'Untitled asset';
@ -281,222 +278,26 @@ const addFallbackAssetOption = (
]; ];
}; };
const labelByType: Record<CanvasElementType, string> = { // Use ELEMENT_TYPE_LABELS from elementDefaults for label lookup
navigation_next: 'Navigation: Forward', const labelByType = ELEMENT_TYPE_LABELS;
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;
};
const getElementButtonTitle = (element: CanvasElement) => { const getElementButtonTitle = (element: CanvasElement) => {
if (element.type === 'gallery') { if (isGalleryElementType(element.type)) {
return `${element.label} (${element.galleryCards?.length || 0})`; return `${element.label} (${element.galleryCards?.length || 0})`;
} }
if (element.type === 'carousel') { if (isCarouselElementType(element.type)) {
return `${element.label} (${element.carouselSlides?.length || 0})`; return `${element.label} (${element.carouselSlides?.length || 0})`;
} }
if (element.type === 'tooltip') return element.tooltipTitle ?? ''; if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
if (element.type === 'description') return element.descriptionTitle ?? ''; if (isDescriptionElementType(element.type)) return element.descriptionTitle ?? '';
if (element.type === 'navigation_next' || element.type === 'navigation_prev') if (isNavigationElementType(element.type))
return ( return (
element.navLabel?.trim() || element.navLabel?.trim() ||
getNavigationButtonLabel(element.type as NavigationElementType) getNavigationButtonLabel(element.type as NavigationElementType)
); );
if ( if (isMediaElementType(element.type) && element.mediaUrl) {
(element.type === 'video_player' || element.type === 'audio_player') &&
element.mediaUrl
) {
return `${element.label} · configured`; return `${element.label} · configured`;
} }
@ -939,13 +740,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if ( if (
selectedElement && selectedElement &&
(selectedElement.type === 'video_player' || isMediaElementType(selectedElement.type) &&
selectedElement.type === 'audio_player') &&
selectedElement.mediaUrl selectedElement.mediaUrl
) { ) {
targets.push({ targets.push({
source: selectedElement.mediaUrl, 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], [backgroundAudioUrl, getKnownDurationForSource],
); );
const selectedMediaDurationNote = useMemo(() => { const selectedMediaDurationNote = useMemo(() => {
if ( if (!selectedElement || !isMediaElementType(selectedElement.type)) {
!selectedElement ||
(selectedElement.type !== 'video_player' &&
selectedElement.type !== 'audio_player')
) {
return 'Duration: unknown'; return 'Duration: unknown';
} }
@ -1440,7 +1238,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
mediaMuted: mediaMuted:
typeof item.mediaMuted === 'boolean' typeof item.mediaMuted === 'boolean'
? item.mediaMuted ? item.mediaMuted
: item.type === 'video_player', : isVideoPlayerElementType(item.type),
}; };
return mergeElementWithDefaults( return mergeElementWithDefaults(
@ -1640,10 +1438,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: allowedNavigationTypes[0] : allowedNavigationTypes[0]
: type; : type;
const baseElement = createDefaultElement(nextElementType, elements.length); const baseElement = createDefaultElement(nextElementType, elements.length);
const nextElement = mergeElementWithDefaults( const defaults = uiElementDefaultsByType[nextElementType];
baseElement, const nextElement = mergeElementWithDefaults(baseElement, defaults);
uiElementDefaultsByType[nextElementType],
);
setElements((prev) => [...prev, nextElement]); setElements((prev) => [...prev, nextElement]);
selectElementForEdit(nextElement.id); selectElementForEdit(nextElement.id);
setSuccessMessage('Element added. Drag it to set position.'); setSuccessMessage('Element added. Drag it to set position.');
@ -2144,261 +1941,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}; };
}; };
const renderCanvasElementContent = (element: CanvasElement) => { // Use shared ElementContentRenderer for WYSIWYG consistency with runtime
if ( const renderCanvasElementContent = (element: CanvasElement) => (
element.type === 'navigation_next' || <ElementContentRenderer element={element} />
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);
};
const isElementVisibleOnCanvas = (element: CanvasElement) => { const isElementVisibleOnCanvas = (element: CanvasElement) => {
const delay = Number(element.appearDelaySec || 0); const delay = Number(element.appearDelaySec || 0);
@ -2419,10 +1965,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const isElementReadyForCanvasRender = (element: CanvasElement) => { const isElementReadyForCanvasRender = (element: CanvasElement) => {
const isPreloadableIconElement = const isPreloadableIconElement =
(element.type === 'navigation_next' || (isNavigationElementType(element.type) ||
element.type === 'navigation_prev' || isTooltipElementType(element.type) ||
element.type === 'tooltip' || isDescriptionElementType(element.type)) &&
element.type === 'description') &&
Boolean(element.iconUrl); Boolean(element.iconUrl);
if (!isPreloadableIconElement) return true; if (!isPreloadableIconElement) return true;
@ -2695,26 +2240,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const hasIconDrivenSize = const hasIconDrivenSize =
Boolean(element.iconUrl) && Boolean(element.iconUrl) &&
(element.type === 'tooltip' || (isTooltipElementType(element.type) ||
element.type === 'description' || isDescriptionElementType(element.type) ||
element.type === 'navigation_next' || isNavigationElementType(element.type));
element.type === 'navigation_prev');
const isNavigationIconElement = const isNavigationIconElement =
Boolean(element.iconUrl) && Boolean(element.iconUrl) &&
(element.type === 'navigation_next' || isNavigationElementType(element.type);
element.type === 'navigation_prev');
const hasTransparentBackground = const hasTransparentBackground =
(element.type === 'description' && (isDescriptionElementType(element.type) &&
!element.iconUrl && !element.iconUrl &&
(!element.descriptionBackgroundColor || (!element.descriptionBackgroundColor ||
element.descriptionBackgroundColor === 'transparent')) || element.descriptionBackgroundColor === 'transparent')) ||
// Navigation buttons with icons should be transparent (icon is visible) // Navigation buttons with icons should be transparent (icon is visible)
((element.type === 'navigation_next' || (isNavigationElementType(element.type) &&
element.type === 'navigation_prev') &&
Boolean(element.iconUrl)) || Boolean(element.iconUrl)) ||
element.type === 'tooltip' || isTooltipElementType(element.type) ||
element.type === 'gallery' || isGalleryElementType(element.type) ||
element.type === 'carousel'; isCarouselElementType(element.type);
const isNavDisabled = const isNavDisabled =
isNavigationElementType(element.type) && isNavigationElementType(element.type) &&
(element.navDisabled || (element.navDisabled ||
@ -3014,8 +2556,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
</div> </div>
</div> </div>
{(selectedElement.type === 'navigation_next' || {isNavigationElementType(selectedElement.type) && (
selectedElement.type === 'navigation_prev') && (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
@ -3274,7 +2815,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)} )}
{selectedElement && {selectedElement &&
selectedElement.type === 'tooltip' && ( isTooltipElementType(selectedElement.type) && (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
@ -3337,7 +2878,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)} )}
{selectedElement && {selectedElement &&
selectedElement.type === 'description' && ( isDescriptionElementType(selectedElement.type) && (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
@ -3531,12 +3072,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)} )}
{selectedElement && {selectedElement &&
(selectedElement.type === 'video_player' || isMediaElementType(selectedElement.type) && (
selectedElement.type === 'audio_player') && (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
{selectedElement.type === 'video_player' {isVideoPlayerElementType(selectedElement.type)
? 'Video asset' ? 'Video asset'
: 'Audio asset'} : 'Audio asset'}
</label> </label>
@ -3551,7 +3091,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
> >
<option value=''>Not selected</option> <option value=''>Not selected</option>
{addFallbackAssetOption( {addFallbackAssetOption(
selectedElement.type === 'video_player' isVideoPlayerElementType(selectedElement.type)
? videoAssetOptions ? videoAssetOptions
: audioAssetOptions, : audioAssetOptions,
selectedElement.mediaUrl, selectedElement.mediaUrl,
@ -3592,7 +3132,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
Loop Loop
</label> </label>
{selectedElement.type === 'video_player' && ( {isVideoPlayerElementType(selectedElement.type) && (
<label className='flex items-center gap-2 text-[11px] text-gray-700'> <label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input <input
type='checkbox' type='checkbox'
@ -3612,7 +3152,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)} )}
{selectedElement && {selectedElement &&
selectedElement.type === 'gallery' && ( isGalleryElementType(selectedElement.type) && (
<div className='space-y-2'> <div className='space-y-2'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-600'> <p className='text-[11px] font-semibold text-gray-600'>
@ -3697,7 +3237,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)} )}
{selectedElement && {selectedElement &&
selectedElement.type === 'carousel' && ( isCarouselElementType(selectedElement.type) && (
<div className='space-y-2'> <div className='space-y-2'>
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-gray-700'>

View File

@ -112,7 +112,9 @@ const buildThemeConfigJson = (values: {
/** /**
* Build custom_css_json from individual fields * 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> = {}; const config: Record<string, string> = {};
if (values.customFontFamily.trim()) { if (values.customFontFamily.trim()) {
@ -410,10 +412,7 @@ const EditProjectsPage = () => {
)} )}
<FormField label='Theme Primary Color'> <FormField label='Theme Primary Color'>
<Field <Field name='themePrimaryColor' placeholder='e.g. #E7DDB5' />
name='themePrimaryColor'
placeholder='e.g. #E7DDB5'
/>
</FormField> </FormField>
<FormField label='Theme Background Color'> <FormField label='Theme Background Color'>
@ -424,10 +423,7 @@ const EditProjectsPage = () => {
</FormField> </FormField>
<FormField label='Theme Text Color'> <FormField label='Theme Text Color'>
<Field <Field name='themeTextColor' placeholder='e.g. #FFFFFF' />
name='themeTextColor'
placeholder='e.g. #FFFFFF'
/>
</FormField> </FormField>
<FormField label='Custom Font Family'> <FormField label='Custom Font Family'>

View File

@ -22,12 +22,15 @@ const ProjectsListPage = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); 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) // Handle both array (from list fetch) and single object (after edit fetch)
const projects: Project[] = Array.isArray(projectsRaw) const projects: Project[] = Array.isArray(projectsRaw)
? projectsRaw ? projectsRaw
: projectsRaw : projectsRaw
? [projectsRaw as Project] ? [projectsRaw]
: []; : [];
const isLoading = useAppSelector((state) => state.projects.loading); const isLoading = useAppSelector((state) => state.projects.loading);

View File

@ -38,6 +38,12 @@ export interface Project extends BaseEntity {
name: string; name: string;
slug?: string; slug?: string;
description?: 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 // Asset entity