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 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 (

View File

@ -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>

View File

@ -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)}`;
};

View File

@ -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

View File

@ -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

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
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());

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

View File

@ -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'>

View File

@ -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);

View File

@ -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