39948-vm/frontend/src/components/Constructor/ElementEditorPanel.tsx

2180 lines
101 KiB
TypeScript

/**
* ElementEditorPanel Component
*
* Renders the element editor sidebar in the constructor.
* Handles element settings, background settings, and transition creation.
*
* Uses ConstructorContext for all state - only receives local UI props.
*/
import React from 'react';
import {
useConstructorContext,
useConstructorElements,
useConstructorBackground,
useConstructorAssets,
useConstructorCollectionOps,
useConstructorDuration,
useConstructorNavigation,
useConstructorEditorTab,
useConstructorMenu,
} from '../../context/ConstructorContext';
import {
ElementSettingsTabsCompact,
StyleSettingsSectionCompact,
EffectsSettingsSectionCompact,
CommonSettingsSectionCompact,
DescriptionSettingsSectionCompact,
MediaSettingsSectionCompact,
GallerySettingsSectionCompact,
CarouselSettingsSectionCompact,
GalleryCarouselSettingsSectionCompact,
GallerySectionStyleInputs,
InfoPanelSettingsSectionCompact,
InfoPanelStyleInputs,
extractNumericValue,
} from '../ElementSettings';
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
import ElementEditorHeader from './ElementEditorHeader';
import NavigationSettingsSectionCompact from '../ElementSettings/NavigationSettingsSectionCompact';
import {
normalizeAppearDelaySec,
normalizeAppearDurationSec,
isNavigationElementType,
isDescriptionElementType,
isGalleryElementType,
isCarouselElementType,
isMediaElementType,
isVideoPlayerElementType,
isInfoPanelElementType,
} from '../../lib/elementDefaults';
import type { CanvasElement } from '../../types/constructor';
import {
getSystemControlAnchorBounds,
type SystemUiControlSettings,
} from '../../types/uiControls';
import { FONT_OPTIONS } from '../../lib/fonts';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import {
opacityToPercentInput,
percentInputToOpacityNumber,
} from '../../lib/opacityPercent';
type NavigationElementType = 'navigation_next' | 'navigation_prev';
// ============================================================================
// Props Interface (Local UI props only)
// ============================================================================
interface ElementEditorPanelProps {
/** Ref for outside click detection */
elementEditorRef: React.RefObject<HTMLDivElement | null>;
/** Draggable position */
position: { x: number; y: number };
/** Whether panel is collapsed */
isCollapsed: boolean;
/** Toggle collapse state */
onToggleCollapse: () => void;
/** Start dragging the panel */
onDragStart: (event: React.MouseEvent) => void;
/** Panel title */
title: string;
}
// ============================================================================
// CSS Property Handler
// ============================================================================
/**
* Handle CSS property changes with unit conversion
*/
const handleCssPropertyChange = (
prop: string,
value: string | number | boolean,
onUpdateElement: (patch: Partial<CanvasElement>) => void,
) => {
const numericProps = [
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'border',
'borderRadius',
'gap',
];
const getUnit = (p: string) => {
if (['width', 'minWidth', 'maxWidth'].includes(p)) return 'vw';
if (['height', 'minHeight', 'maxHeight'].includes(p)) return 'vh';
if (['border', 'borderRadius'].includes(p)) return 'px';
if (p === 'gap') return 'rem';
return '';
};
if (numericProps.includes(prop)) {
const trimmed = String(value || '').trim();
if (prop === 'border') {
onUpdateElement({
[prop]: trimmed ? `${trimmed}px solid currentColor` : 'none',
});
} else if (prop === 'borderRadius') {
onUpdateElement({
[prop]: trimmed ? `${trimmed}px` : undefined,
});
} else {
const unit = getUnit(prop);
onUpdateElement({
[prop]: trimmed ? `${trimmed}${unit}` : undefined,
});
}
} else {
onUpdateElement({
[prop]: value === '' ? undefined : value,
});
}
};
// ============================================================================
// Component
// ============================================================================
export function ElementEditorPanel({
elementEditorRef,
position,
isCollapsed,
onToggleCollapse,
onDragStart,
title,
}: ElementEditorPanelProps) {
// Get state from context
const {
selectedElement,
selectedElementId,
updateSelectedElement,
removeSelectedElement,
selectedSystemControl,
resolvedUiControlsSettings,
uiControlsCanvasAspectRatio,
updateSystemControl,
} = useConstructorElements();
const { selectedMenuItem } = useConstructorMenu();
const {
pageBackground,
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundEmbedUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
setBackgroundAudioSettings,
} = useConstructorBackground();
const { assetOptions } = useConstructorAssets();
const {
galleryCards,
galleryInfoSpans,
carouselSlides,
infoPanelSectionOps,
} = useConstructorCollectionOps();
const { getDuration, durationNotes } = useConstructorDuration();
const {
pages,
activePageId,
allowedNavigationTypes,
normalizeNavigationType,
} = useConstructorNavigation();
const { activeTab, setActiveTab } = useConstructorEditorTab();
// ============================================================================
// Render
// ============================================================================
const selectedSystemControlSettings = selectedSystemControl
? resolvedUiControlsSettings[selectedSystemControl]
: null;
const selectedSystemControlBounds = selectedSystemControlSettings
? getSystemControlAnchorBounds(
selectedSystemControlSettings.anchor,
selectedSystemControlSettings.buttonSizePercent,
uiControlsCanvasAspectRatio,
)
: null;
const updateSelectedSystemControl = (
patch: Partial<SystemUiControlSettings>,
) => {
if (!selectedSystemControl) return;
updateSystemControl(selectedSystemControl, patch);
};
return (
<div
ref={elementEditorRef}
className={`fixed z-[1000] ${isCollapsed ? 'w-[220px]' : 'w-[300px]'} max-h-[calc(100vh-1rem)] overflow-auto rounded-lg border border-white/30 bg-white/10 backdrop-blur-xl p-2 shadow-xl text-sm`}
style={{ left: position.x, top: position.y }}
>
<ElementEditorHeader
title={title}
isCollapsed={isCollapsed}
showRemoveButton={Boolean(selectedElement) && !selectedSystemControl}
onToggleCollapse={onToggleCollapse}
onRemove={removeSelectedElement}
onDragStart={onDragStart}
/>
{!isCollapsed && (
<>
{selectedSystemControl && selectedSystemControlSettings && (
<div className='space-y-3'>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
X (%)
</label>
<input
type='number'
min={selectedSystemControlBounds?.minX ?? 0}
max={selectedSystemControlBounds?.maxX ?? 100}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedSystemControlSettings.xPercent}
onChange={(event) =>
updateSelectedSystemControl({
xPercent: Math.max(
selectedSystemControlBounds?.minX ?? 0,
Math.min(
selectedSystemControlBounds?.maxX ?? 100,
Number(event.target.value) || 0,
),
),
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Y (%)
</label>
<input
type='number'
min={selectedSystemControlBounds?.minY ?? 0}
max={selectedSystemControlBounds?.maxY ?? 100}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedSystemControlSettings.yPercent}
onChange={(event) =>
updateSelectedSystemControl({
yPercent: Math.max(
selectedSystemControlBounds?.minY ?? 0,
Math.min(
selectedSystemControlBounds?.maxY ?? 100,
Number(event.target.value) || 0,
),
),
})
}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Anchor
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedSystemControlSettings.anchor}
onChange={(event) => {
const anchor = event.target
.value as SystemUiControlSettings['anchor'];
const bounds = anchor
? getSystemControlAnchorBounds(
anchor,
selectedSystemControlSettings.buttonSizePercent,
uiControlsCanvasAspectRatio,
)
: selectedSystemControlBounds;
updateSelectedSystemControl({
anchor,
...(bounds
? {
xPercent: Math.max(
bounds.minX,
Math.min(
bounds.maxX,
selectedSystemControlSettings.xPercent,
),
),
yPercent: Math.max(
bounds.minY,
Math.min(
bounds.maxY,
selectedSystemControlSettings.yPercent,
),
),
}
: {}),
});
}}
>
<option value='center'>center</option>
<option value='top-left'>top-left</option>
<option value='top-right'>top-right</option>
<option value='bottom-left'>bottom-left</option>
<option value='bottom-right'>bottom-right</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Order
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={selectedSystemControlSettings.order}
onChange={(event) =>
updateSelectedSystemControl({
order: Number(event.target.value) || 0,
})
}
/>
</div>
</div>
<div className='space-y-1'>
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
<input
type='checkbox'
checked={selectedSystemControlSettings.hidden}
onChange={(event) =>
updateSelectedSystemControl({
hidden: event.target.checked,
})
}
/>
Hidden
</label>
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
<input
type='checkbox'
checked={!selectedSystemControlSettings.enabled}
onChange={(event) =>
updateSelectedSystemControl({
enabled: !event.target.checked,
})
}
/>
Disabled
</label>
</div>
<div className='grid grid-cols-2 gap-2'>
{[
['Default icon', 'defaultIconUrl'],
['Active icon', 'activeIconUrl'],
].map(([label, key]) => {
const value = selectedSystemControlSettings[
key as keyof typeof selectedSystemControlSettings
] as string;
return (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={value}
onChange={(event) =>
updateSelectedSystemControl({
[key]: event.target.value,
} as Partial<SystemUiControlSettings>)
}
>
<option value=''>Use default icon</option>
{addFallbackAssetOption(
assetOptions.icon,
value,
`Current icon · ${value}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
})}
</div>
<div className='grid grid-cols-2 gap-2'>
{[
['Button size (%)', 'buttonSizePercent'],
['Icon size (%)', 'iconSizePercent'],
['Radius (%)', 'borderRadiusPercent'],
['Z-index', 'zIndex'],
].map(([label, key]) => (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedSystemControlSettings[
key as keyof typeof selectedSystemControlSettings
] as number
}
onChange={(event) => {
const value = Number(event.target.value) || 0;
if (key === 'buttonSizePercent') {
const bounds = getSystemControlAnchorBounds(
selectedSystemControlSettings.anchor,
value,
uiControlsCanvasAspectRatio,
);
updateSelectedSystemControl({
buttonSizePercent: value,
xPercent: Math.max(
bounds.minX,
Math.min(
bounds.maxX,
selectedSystemControlSettings.xPercent,
),
),
yPercent: Math.max(
bounds.minY,
Math.min(
bounds.maxY,
selectedSystemControlSettings.yPercent,
),
),
});
return;
}
updateSelectedSystemControl({
[key]: value,
} as Partial<SystemUiControlSettings>);
}}
/>
</div>
))}
</div>
<div className='grid grid-cols-2 gap-2'>
{[
['Default BG', 'defaultBackgroundColor'],
['Active BG', 'activeBackgroundColor'],
['Hover BG', 'hoverBackgroundColor'],
['Icon color', 'color'],
['Default border', 'defaultBorderColor'],
['Active border', 'activeBorderColor'],
['Opacity (%)', 'opacity'],
['Shadow', 'boxShadow'],
].map(([label, key]) => (
<div key={key}>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label}
</label>
<input
type={key === 'opacity' ? 'number' : 'text'}
step={key === 'opacity' ? '0.1' : undefined}
min={key === 'opacity' ? '0' : undefined}
max={key === 'opacity' ? '100' : undefined}
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
key === 'opacity'
? opacityToPercentInput(
selectedSystemControlSettings.opacity,
)
: String(
selectedSystemControlSettings[
key as keyof typeof selectedSystemControlSettings
] ?? '',
)
}
onChange={(event) =>
updateSelectedSystemControl({
[key]:
key === 'opacity'
? percentInputToOpacityNumber(event.target.value)
: event.target.value,
} as Partial<SystemUiControlSettings>)
}
/>
</div>
))}
</div>
</div>
)}
{/* Background Image Settings */}
{!selectedSystemControl &&
selectedMenuItem === 'background_image' && (
<BackgroundSettingsEditor
type='image'
value={pageBackground.imageUrl}
options={assetOptions.backgroundImage}
onChange={(value) => {
setBackgroundImageUrl(value);
if (value) {
setBackgroundVideoUrl('');
setBackgroundEmbedUrl('');
}
}}
/>
)}
{/* Background Video Settings */}
{!selectedSystemControl &&
selectedMenuItem === 'background_video' && (
<BackgroundSettingsEditor
type='video'
value={pageBackground.videoUrl}
options={assetOptions.video}
durationNote={durationNotes.backgroundVideo}
onChange={(value) => {
setBackgroundVideoUrl(value);
if (value) {
setBackgroundImageUrl('');
setBackgroundEmbedUrl('');
}
}}
videoAutoplay={pageBackground.videoSettings.autoplay}
videoLoop={pageBackground.videoSettings.loop}
videoMuted={pageBackground.videoSettings.muted}
videoStartTime={pageBackground.videoSettings.startTime}
videoEndTime={pageBackground.videoSettings.endTime}
onVideoSettingsChange={setBackgroundVideoSettings}
/>
)}
{/* Background 360 Settings */}
{selectedMenuItem === 'background_embed' && (
<BackgroundSettingsEditor
type='embed'
value={pageBackground.embedUrl}
options={assetOptions.embed}
onChange={(value) => {
setBackgroundEmbedUrl(value);
if (value) {
setBackgroundImageUrl('');
setBackgroundVideoUrl('');
}
}}
/>
)}
{/* Background Audio Settings */}
{selectedMenuItem === 'background_audio' && (
<BackgroundSettingsEditor
type='audio'
value={pageBackground.audioUrl}
options={assetOptions.audio}
durationNote={durationNotes.backgroundAudio}
onChange={setBackgroundAudioUrl}
audioLoop={pageBackground.audioSettings.loop}
audioStartTime={pageBackground.audioSettings.startTime}
audioEndTime={pageBackground.audioSettings.endTime}
onAudioSettingsChange={setBackgroundAudioSettings}
/>
)}
{/* Element Settings */}
{selectedElement && (
<>
<ElementSettingsTabsCompact
activeTab={activeTab}
onTabChange={(tab) =>
setActiveTab(tab as 'general' | 'css' | 'effects')
}
tabs={[
{ id: 'general', label: 'General' },
{ id: 'css', label: 'CSS' },
{ id: 'effects', label: 'Effects' },
]}
/>
{/* General Tab */}
{activeTab === 'general' && (
<>
{/* Common settings for all elements except info_panel */}
{!isInfoPanelElementType(selectedElement.type) && (
<CommonSettingsSectionCompact
label={selectedElement.label}
xPercent={String(selectedElement.xPercent ?? 50)}
yPercent={String(selectedElement.yPercent ?? 50)}
appearDelaySec={String(
selectedElement.appearDelaySec ?? 0,
)}
appearDurationSec={
selectedElement.appearDurationSec != null
? String(selectedElement.appearDurationSec)
: ''
}
showPosition={false}
onChange={(prop, value) => {
if (prop === 'label') {
updateSelectedElement({ label: value });
} else if (prop === 'appearDelaySec') {
updateSelectedElement({
appearDelaySec: normalizeAppearDelaySec(value),
});
} else if (prop === 'appearDurationSec') {
updateSelectedElement({
appearDurationSec:
normalizeAppearDurationSec(value),
});
}
}}
/>
)}
{/* Navigation Settings */}
{isNavigationElementType(selectedElement.type) && (
<NavigationSettingsSectionCompact
type={
selectedElement.type as
| 'navigation_next'
| 'navigation_prev'
}
navType={selectedElement.navType}
navLabel={selectedElement.navLabel || ''}
navLabelFontFamily={
selectedElement.navLabelFontFamily || ''
}
navDisabled={selectedElement.navDisabled || false}
iconUrl={selectedElement.iconUrl || ''}
navigationTargetMode={
selectedElement.navigationTargetMode || 'target_page'
}
targetPageSlug={selectedElement.targetPageSlug || ''}
externalUrl={selectedElement.externalUrl || ''}
transitionVideoUrl={
selectedElement.transitionVideoUrl || ''
}
transitionReverseMode={
selectedElement.transitionReverseMode || 'auto_reverse'
}
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
transitionType={selectedElement.transitionType || ''}
transitionDurationMs={
selectedElement.transitionDurationMs ?? ''
}
transitionEasing={selectedElement.transitionEasing || ''}
transitionOverlayColor={
selectedElement.transitionOverlayColor || ''
}
allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo}
pages={pages}
activePageId={activePageId || ''}
selectedMediaDurationNote={durationNotes.selectedMedia}
selectedTransitionDurationNote={
durationNotes.selectedTransition
}
onChange={(prop, value) => {
if (prop === 'type') {
if (typeof value === 'object') {
const nextType = (value.type ||
selectedElement.type) as NavigationElementType;
updateSelectedElement({
...normalizeNavigationType(
selectedElement,
nextType,
),
...value,
});
} else {
const nextType = value as NavigationElementType;
updateSelectedElement(
normalizeNavigationType(
selectedElement,
nextType,
),
);
}
} else if (prop === 'transitionVideoUrl') {
const nextVideoUrl = value as string;
const resolvedDuration = getDuration(nextVideoUrl);
updateSelectedElement({
transitionVideoUrl: nextVideoUrl,
transitionDurationSec:
resolvedDuration || undefined,
});
} else if (prop === 'targetPageSlug') {
updateSelectedElement({
targetPageSlug: value as string,
targetPageId: '',
});
} else if (prop === 'navigationTargetMode') {
if (typeof value === 'object') {
updateSelectedElement(value);
}
} else {
updateSelectedElement({
[prop]: value,
});
}
}}
/>
)}
{/* Description Settings */}
{isDescriptionElementType(selectedElement.type) && (
<DescriptionSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
descriptionTitle={selectedElement.descriptionTitle || ''}
descriptionText={selectedElement.descriptionText || ''}
descriptionTitleFontSize={
selectedElement.descriptionTitleFontSize || '48px'
}
descriptionTextFontSize={
selectedElement.descriptionTextFontSize || '36px'
}
descriptionTitleFontFamily={
selectedElement.descriptionTitleFontFamily || 'inherit'
}
descriptionTextFontFamily={
selectedElement.descriptionTextFontFamily || 'inherit'
}
descriptionTitleColor={
selectedElement.descriptionTitleColor || '#000000'
}
descriptionTextColor={
selectedElement.descriptionTextColor || '#4B5563'
}
iconAssetOptions={assetOptions.icon}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
/>
)}
{/* Media Settings */}
{isMediaElementType(selectedElement.type) && (
<MediaSettingsSectionCompact
mediaType={
isVideoPlayerElementType(selectedElement.type)
? 'video'
: 'audio'
}
mediaUrl={selectedElement.mediaUrl || ''}
mediaAutoplay={Boolean(selectedElement.mediaAutoplay)}
mediaLoop={Boolean(selectedElement.mediaLoop)}
mediaMuted={Boolean(selectedElement.mediaMuted)}
videoAssetOptions={assetOptions.video}
audioAssetOptions={assetOptions.audio}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
/>
)}
{/* Gallery Settings */}
{isGalleryElementType(selectedElement.type) && (
<>
<GallerySettingsSectionCompact
galleryHeaderImageUrl={
selectedElement.galleryHeaderImageUrl || ''
}
galleryHeaderText={
selectedElement.galleryHeaderText || ''
}
galleryTitle={selectedElement.galleryTitle || ''}
galleryInfoSpans={
selectedElement.galleryInfoSpans || []
}
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={assetOptions.image}
iconAssetOptions={assetOptions.icon}
onUpdateHeader={(patch) => updateSelectedElement(patch)}
onAddInfoSpan={galleryInfoSpans.add}
onUpdateInfoSpan={galleryInfoSpans.update}
onRemoveInfoSpan={galleryInfoSpans.remove}
onAddCard={galleryCards.add}
onUpdateCard={galleryCards.update}
onRemoveCard={galleryCards.remove}
/>
<GalleryCarouselSettingsSectionCompact
prevIconUrl={
selectedElement.galleryCarouselPrevIconUrl || ''
}
nextIconUrl={
selectedElement.galleryCarouselNextIconUrl || ''
}
backIconUrl={
selectedElement.galleryCarouselBackIconUrl || ''
}
backLabel={
selectedElement.galleryCarouselBackLabel || ''
}
prevWidth={
selectedElement.galleryCarouselPrevWidth || ''
}
prevHeight={
selectedElement.galleryCarouselPrevHeight || ''
}
nextWidth={
selectedElement.galleryCarouselNextWidth || ''
}
nextHeight={
selectedElement.galleryCarouselNextHeight || ''
}
backWidth={
selectedElement.galleryCarouselBackWidth || ''
}
backHeight={
selectedElement.galleryCarouselBackHeight || ''
}
iconAssetOptions={assetOptions.icon}
onUpdateElement={updateSelectedElement}
/>
</>
)}
{/* Carousel Settings */}
{isCarouselElementType(selectedElement.type) && (
<CarouselSettingsSectionCompact
carouselSlides={selectedElement.carouselSlides || []}
carouselPrevIconUrl={
selectedElement.carouselPrevIconUrl || ''
}
carouselNextIconUrl={
selectedElement.carouselNextIconUrl || ''
}
carouselCaptionFontFamily={
selectedElement.carouselCaptionFontFamily || ''
}
carouselFullWidth={
selectedElement.carouselFullWidth || false
}
carouselPrevWidth={
selectedElement.carouselPrevWidth || ''
}
carouselPrevHeight={
selectedElement.carouselPrevHeight || ''
}
carouselNextWidth={
selectedElement.carouselNextWidth || ''
}
carouselNextHeight={
selectedElement.carouselNextHeight || ''
}
iconAssetOptions={assetOptions.icon}
imageAssetOptions={assetOptions.image}
onUpdateElement={updateSelectedElement}
onAddSlide={carouselSlides.add}
onUpdateSlide={carouselSlides.update}
onRemoveSlide={carouselSlides.remove}
/>
)}
{/* Info Panel Settings */}
{isInfoPanelElementType(selectedElement.type) && (
<InfoPanelSettingsSectionCompact
element={selectedElement}
imageAssetOptions={assetOptions.image}
videoAssetOptions={assetOptions.video}
iconAssetOptions={assetOptions.icon}
embedAssetOptions={assetOptions.embed}
pages={pages}
activePageId={activePageId}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
// Section operations (ID-based)
onMoveSection={infoPanelSectionOps.move}
onRemoveSection={infoPanelSectionOps.remove}
onAddSection={infoPanelSectionOps.add}
onUpdateSection={infoPanelSectionOps.update}
// Per-section item operations
onAddSpan={infoPanelSectionOps.addSpan}
onUpdateSpan={infoPanelSectionOps.updateSpan}
onRemoveSpan={infoPanelSectionOps.removeSpan}
onAddImage={infoPanelSectionOps.addImage}
onUpdateImage={infoPanelSectionOps.updateImage}
onRemoveImage={infoPanelSectionOps.removeImage}
/>
)}
</>
)}
{/* CSS Styles Tab */}
{activeTab === 'css' && (
<>
{/* Gallery Section Styles (shown first for gallery elements) */}
{isGalleryElementType(selectedElement.type) && (
<div className='space-y-2 mb-4'>
<p className='text-[11px] font-semibold text-white/90'>
Gallery Section Styles
</p>
<GallerySectionStyleInputs
sectionLabel='Header'
prefix='galleryHeader'
values={{
galleryHeaderBackgroundColor:
selectedElement.galleryHeaderBackgroundColor || '',
galleryHeaderColor:
selectedElement.galleryHeaderColor || '',
galleryHeaderFontFamily:
selectedElement.galleryHeaderFontFamily || '',
galleryHeaderFontSize:
selectedElement.galleryHeaderFontSize || '',
galleryHeaderFontWeight:
selectedElement.galleryHeaderFontWeight || '',
galleryHeaderPadding:
selectedElement.galleryHeaderPadding || '',
galleryHeaderBorderRadius:
selectedElement.galleryHeaderBorderRadius || '',
galleryHeaderBorder:
selectedElement.galleryHeaderBorder || '',
galleryHeaderWidth:
selectedElement.galleryHeaderWidth || '',
galleryHeaderHeight:
selectedElement.galleryHeaderHeight || '',
galleryHeaderMinHeight:
selectedElement.galleryHeaderMinHeight || '',
galleryHeaderMaxHeight:
selectedElement.galleryHeaderMaxHeight || '',
galleryHeaderTextAlign:
selectedElement.galleryHeaderTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showDimensions
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Title'
prefix='galleryTitle'
values={{
galleryTitleBackgroundColor:
selectedElement.galleryTitleBackgroundColor || '',
galleryTitleColor:
selectedElement.galleryTitleColor || '',
galleryTitleFontFamily:
selectedElement.galleryTitleFontFamily || '',
galleryTitleFontSize:
selectedElement.galleryTitleFontSize || '',
galleryTitleFontWeight:
selectedElement.galleryTitleFontWeight || '',
galleryTitlePadding:
selectedElement.galleryTitlePadding || '',
galleryTitleBorderRadius:
selectedElement.galleryTitleBorderRadius || '',
galleryTitleBorder:
selectedElement.galleryTitleBorder || '',
galleryTitleTextAlign:
selectedElement.galleryTitleTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Info Spans'
prefix='gallerySpan'
values={{
gallerySpanBackgroundColor:
selectedElement.gallerySpanBackgroundColor || '',
gallerySpanColor:
selectedElement.gallerySpanColor || '',
gallerySpanFontFamily:
selectedElement.gallerySpanFontFamily || '',
gallerySpanFontSize:
selectedElement.gallerySpanFontSize || '',
gallerySpanFontWeight:
selectedElement.gallerySpanFontWeight || '',
gallerySpanPadding:
selectedElement.gallerySpanPadding || '',
gallerySpanBorderRadius:
selectedElement.gallerySpanBorderRadius || '',
gallerySpanBorder:
selectedElement.gallerySpanBorder || '',
gallerySpanGap: selectedElement.gallerySpanGap || '',
gallerySpanColumns:
selectedElement.gallerySpanColumns ||
selectedElement.galleryColumns ||
3,
gallerySpanTextAlign:
selectedElement.gallerySpanTextAlign || 'center',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showGap
showColumns
showTextAlign
/>
<GallerySectionStyleInputs
sectionLabel='Image Cards'
prefix='galleryCard'
values={{
galleryCardBackgroundColor:
selectedElement.galleryCardBackgroundColor || '',
galleryCardBorderRadius:
selectedElement.galleryCardBorderRadius || '',
galleryCardBorder:
selectedElement.galleryCardBorder || '',
galleryCardGap: selectedElement.galleryCardGap || '',
galleryCardColumns:
selectedElement.galleryCardColumns ||
selectedElement.galleryColumns ||
3,
galleryCardTitleColor:
selectedElement.galleryCardTitleColor || '',
galleryCardTitleBackgroundColor:
selectedElement.galleryCardTitleBackgroundColor ||
'',
galleryCardTitleFontSize:
selectedElement.galleryCardTitleFontSize || '',
galleryCardTitleFontWeight:
selectedElement.galleryCardTitleFontWeight || '',
galleryCardTitleShadow:
selectedElement.galleryCardTitleShadow || '',
galleryCardAspectRatio:
selectedElement.galleryCardAspectRatio || '',
galleryCardMinHeight:
selectedElement.galleryCardMinHeight || '',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
showGap
showColumns
showTitleStyles
showAspectRatio
/>
<p className='text-[11px] font-semibold text-white/90 pt-2'>
General Element Styles
</p>
</div>
)}
{/* Info Panel Section Styles - order: Trigger → Header → Info Panel → Image Detail */}
{isInfoPanelElementType(selectedElement.type) && (
<div className='space-y-2 mb-4'>
{/* Trigger Button Styles (uses general StyleSettingsSectionCompact) */}
<p className='text-[11px] font-semibold text-white/90'>
Trigger Button Styles
</p>
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(
selectedElement.minWidth,
),
maxWidth: extractNumericValue(
selectedElement.maxWidth,
),
minHeight: extractNumericValue(
selectedElement.minHeight,
),
maxHeight: extractNumericValue(
selectedElement.maxHeight,
),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: extractNumericValue(selectedElement.gap),
fontFamily: selectedElement.fontFamily || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity ?? '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor:
selectedElement.backgroundColor || '',
color: selectedElement.color || '',
}}
onChange={(prop, value) =>
handleCssPropertyChange(
prop,
value,
updateSelectedElement,
)
}
/>
{/* Info Panel Container - contains all panel and section styles */}
<div className='rounded border border-white/20 p-2 space-y-3'>
<p className='text-[11px] font-semibold text-white/90'>
Info Panel
</p>
{/* Panel Position/Size/Style */}
<InfoPanelStyleInputs
sectionLabel='Panel Container'
prefix='panel'
values={{
xPercent: selectedElement.panelXPercent ?? 30,
yPercent: selectedElement.panelYPercent ?? 50,
width: selectedElement.panelWidth || '',
height: selectedElement.panelHeight || '',
backgroundColor:
selectedElement.panelBackgroundColor || '',
borderRadius:
selectedElement.panelBorderRadius || '',
padding: selectedElement.panelPadding || '',
backdropBlur:
selectedElement.panelBackdropBlur || '',
borderWidth: selectedElement.panelBorderWidth || '',
borderColor: selectedElement.panelBorderColor || '',
borderStyle:
selectedElement.panelBorderStyle || 'solid',
}}
onChange={(prop, value) =>
updateSelectedElement({
[prop]: value || undefined,
})
}
hideOverlay
/>
{/* Header Section Styles */}
<GallerySectionStyleInputs
sectionLabel='Header Section'
prefix='infoPanelHeader'
values={{
infoPanelHeaderBackgroundColor:
selectedElement.infoPanelHeaderBackgroundColor ||
'',
infoPanelHeaderColor:
selectedElement.infoPanelHeaderColor || '',
infoPanelHeaderFontFamily:
selectedElement.infoPanelHeaderFontFamily || '',
infoPanelHeaderFontSize:
selectedElement.infoPanelHeaderFontSize || '',
infoPanelHeaderFontWeight:
selectedElement.infoPanelHeaderFontWeight || '',
infoPanelHeaderPadding:
selectedElement.infoPanelHeaderPadding || '',
infoPanelHeaderBorderRadius:
selectedElement.infoPanelHeaderBorderRadius || '',
infoPanelHeaderWidth:
selectedElement.infoPanelHeaderWidth || '',
infoPanelHeaderHeight:
selectedElement.infoPanelHeaderHeight || '',
infoPanelHeaderMinHeight:
selectedElement.infoPanelHeaderMinHeight || '',
infoPanelHeaderMaxHeight:
selectedElement.infoPanelHeaderMaxHeight || '',
infoPanelHeaderTextAlign:
selectedElement.infoPanelHeaderTextAlign ||
'center',
}}
onChange={(prop, value) =>
updateSelectedElement({
[prop]: value || undefined,
})
}
showFont
showDimensions
showTextAlign
/>
{/* Title Section Styles */}
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>
Title Section
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitleBackgroundColor ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitleBackgroundColor:
e.target.value || undefined,
})
}
placeholder='transparent'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={
selectedElement.infoPanelTitleColor ||
'#ffffff'
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitleColor: e.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitleFontSize || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitleFontSize:
e.target.value || undefined,
})
}
placeholder='16'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitlePadding || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitlePadding:
e.target.value || undefined,
})
}
placeholder='4 8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitleFontWeight || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitleFontWeight:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
<option value='400'>Normal (400)</option>
<option value='500'>Medium (500)</option>
<option value='600'>Semibold (600)</option>
<option value='700'>Bold (700)</option>
</select>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitleTextAlign || ''
}
onChange={(e) => {
const val = e.target.value as
| 'left'
| 'center'
| 'right'
| '';
updateSelectedElement({
infoPanelTitleTextAlign: val || undefined,
});
}}
>
<option value=''>Default (left)</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>
Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTitleFontFamily || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTitleFontFamily:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Text Section Styles */}
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>
Text Section
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextBackgroundColor ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextBackgroundColor:
e.target.value || undefined,
})
}
placeholder='transparent'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={
selectedElement.infoPanelTextColor ||
'#ffffff'
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextColor: e.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextFontSize || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextFontSize:
e.target.value || undefined,
})
}
placeholder='14'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextPadding || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextPadding:
e.target.value || undefined,
})
}
placeholder='4 8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextFontWeight || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextFontWeight:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
<option value='400'>Normal (400)</option>
<option value='500'>Medium (500)</option>
<option value='600'>Semibold (600)</option>
<option value='700'>Bold (700)</option>
</select>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextTextAlign || ''
}
onChange={(e) => {
const val = e.target.value as
| 'left'
| 'center'
| 'right'
| '';
updateSelectedElement({
infoPanelTextTextAlign: val || undefined,
});
}}
>
<option value=''>Default (left)</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>
Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelTextFontFamily || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelTextFontFamily:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Spans Section Styles */}
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>
Spans Section
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanBackgroundColor ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanBackgroundColor:
e.target.value || undefined,
})
}
placeholder='rgba(255,255,255,0.1)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={
selectedElement.infoPanelSpanColor ||
'#ffffff'
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanColor: e.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanFontSize || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanFontSize:
e.target.value || undefined,
})
}
placeholder='12'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanPadding || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanPadding:
e.target.value || undefined,
})
}
placeholder='4 8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Border Radius
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanBorderRadius ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanBorderRadius:
e.target.value || undefined,
})
}
placeholder='6'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Font Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanFontWeight || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanFontWeight:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
<option value='400'>Normal (400)</option>
<option value='500'>Medium (500)</option>
<option value='600'>Semibold (600)</option>
<option value='700'>Bold (700)</option>
</select>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Text Align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanTextAlign || ''
}
onChange={(e) => {
const val = e.target.value as
| 'left'
| 'center'
| 'right'
| '';
updateSelectedElement({
infoPanelSpanTextAlign: val || undefined,
});
}}
>
<option value=''>Default (center)</option>
<option value='left'>Left</option>
<option value='center'>Center</option>
<option value='right'>Right</option>
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>
Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelSpanFontFamily || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelSpanFontFamily:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Media Section Styles */}
<div className='rounded border border-white/10 p-2 space-y-2'>
<p className='text-[10px] font-semibold text-white/80'>
Media Section
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Preview height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelImagesPreviewHeight ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelImagesPreviewHeight:
e.target.value || undefined,
})
}
placeholder='300'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Thumbnail size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelImagesThumbnailSize ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelImagesThumbnailSize:
e.target.value || undefined,
})
}
placeholder='80'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Background
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardBackgroundColor ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardBackgroundColor:
e.target.value || undefined,
})
}
placeholder='rgba(0,0,0,0.3)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Border Radius
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardBorderRadius ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardBorderRadius:
e.target.value || undefined,
})
}
placeholder='8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Aspect Ratio
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardAspectRatio || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardAspectRatio:
e.target.value || undefined,
})
}
placeholder='16/9'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Min Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardMinHeight || ''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardMinHeight:
e.target.value || undefined,
})
}
placeholder='auto'
/>
</div>
</div>
<p className='text-[9px] text-white/60 pt-1'>
Caption Overlay
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption BG
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardTitleBackgroundColor ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitleBackgroundColor:
e.target.value || undefined,
})
}
placeholder='rgba(0,0,0,0.6)'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Color
</label>
<input
type='color'
className='w-full h-6 rounded border border-gray-300'
value={
selectedElement.infoPanelCardTitleColor ||
'#ffffff'
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitleColor: e.target.value,
})
}
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardTitleFontSize ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitleFontSize:
e.target.value || undefined,
})
}
placeholder='12'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardTitlePadding ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitlePadding:
e.target.value || undefined,
})
}
placeholder='4 8'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Weight
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardTitleFontWeight ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitleFontWeight:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
<option value='400'>Normal (400)</option>
<option value='500'>Medium (500)</option>
<option value='600'>Semibold (600)</option>
<option value='700'>Bold (700)</option>
</select>
</div>
<div className='col-span-2'>
<label className='mb-1 block text-[10px] text-white/70'>
Caption Font
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={
selectedElement.infoPanelCardTitleFontFamily ||
''
}
onChange={(e) =>
updateSelectedElement({
infoPanelCardTitleFontFamily:
e.target.value || undefined,
})
}
>
<option value=''>Default</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Image Detail Panel - no overlay (InfoPanelOverlay provides backdrop) */}
<InfoPanelStyleInputs
sectionLabel='Image Detail Panel'
prefix='detail'
values={{
xPercent: selectedElement.detailXPercent ?? 70,
yPercent: selectedElement.detailYPercent ?? 50,
width: selectedElement.detailWidth || '',
height: selectedElement.detailHeight || '',
backgroundColor:
selectedElement.detailBackgroundColor || '',
borderRadius:
selectedElement.detailBorderRadius || '',
padding: selectedElement.detailPadding || '',
captionFontFamily:
selectedElement.detailCaptionFontFamily || '',
borderWidth: selectedElement.detailBorderWidth || '',
borderColor: selectedElement.detailBorderColor || '',
borderStyle:
selectedElement.detailBorderStyle || 'solid',
}}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value || undefined })
}
hideOverlay
showCaptionFont
/>
</div>
)}
{/* General element styles (skip for info_panel - already shown above) */}
{!isInfoPanelElementType(selectedElement.type) && (
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(selectedElement.minWidth),
maxWidth: extractNumericValue(selectedElement.maxWidth),
minHeight: extractNumericValue(
selectedElement.minHeight,
),
maxHeight: extractNumericValue(
selectedElement.maxHeight,
),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: extractNumericValue(selectedElement.gap),
fontFamily: selectedElement.fontFamily || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity ?? '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor: selectedElement.backgroundColor || '',
color: selectedElement.color || '',
}}
onChange={(prop, value) =>
handleCssPropertyChange(
prop,
value,
updateSelectedElement,
)
}
/>
)}
</>
)}
{/* Effects Tab */}
{activeTab === 'effects' && (
<EffectsSettingsSectionCompact
elementType={selectedElement.type}
values={{
appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration:
selectedElement.appearAnimationDuration || '',
appearAnimationEasing:
selectedElement.appearAnimationEasing || '',
hoverScale: selectedElement.hoverScale || '',
hoverOpacity: selectedElement.hoverOpacity || '',
hoverBackgroundColor:
selectedElement.hoverBackgroundColor || '',
hoverColor: selectedElement.hoverColor || '',
hoverBoxShadow: selectedElement.hoverBoxShadow || '',
hoverTransitionDuration:
selectedElement.hoverTransitionDuration || '',
focusScale: selectedElement.focusScale || '',
focusOpacity: selectedElement.focusOpacity || '',
focusOutline: selectedElement.focusOutline || '',
focusBoxShadow: selectedElement.focusBoxShadow || '',
activeScale: selectedElement.activeScale || '',
activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor:
selectedElement.activeBackgroundColor || '',
// Hover reveal values
hoverReveal: selectedElement.hoverReveal ? 'true' : '',
hoverRevealInitialOpacity:
selectedElement.hoverRevealInitialOpacity || '',
hoverRevealTargetOpacity:
selectedElement.hoverRevealTargetOpacity || '',
hoverRevealDuration:
selectedElement.hoverRevealDuration || '',
hoverRevealDelay: selectedElement.hoverRevealDelay || '',
hoverRevealPersist: selectedElement.hoverRevealPersist
? 'true'
: '',
hoverPersistOnClick: selectedElement.hoverPersistOnClick
? 'true'
: '',
// Audio effects
hoverAudioUrl: selectedElement.hoverAudioUrl || '',
clickAudioUrl: selectedElement.clickAudioUrl || '',
audioVolume: selectedElement.audioVolume || '1',
// Slide transition values (gallery/carousel)
slideTransitionType:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionType || ''
: selectedElement.carouselSlideTransitionType || '',
slideTransitionDurationMs:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionDurationMs !==
undefined &&
selectedElement.gallerySlideTransitionDurationMs !==
''
? String(
selectedElement.gallerySlideTransitionDurationMs,
)
: ''
: selectedElement.carouselSlideTransitionDurationMs !==
undefined &&
selectedElement.carouselSlideTransitionDurationMs !==
''
? String(
selectedElement.carouselSlideTransitionDurationMs,
)
: '',
slideTransitionEasing:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionEasing || ''
: selectedElement.carouselSlideTransitionEasing || '',
slideTransitionOverlayColor:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionOverlayColor ||
''
: selectedElement.carouselSlideTransitionOverlayColor ||
'',
}}
onChange={(prop, value) => {
// Handle slide transition properties with proper prefixes
if (prop === 'slideTransitionType') {
const typedValue = (value || undefined) as
| 'fade'
| 'none'
| ''
| undefined;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionType: typedValue,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionType: typedValue,
});
}
} else if (prop === 'slideTransitionDurationMs') {
const ms = value ? parseInt(value, 10) : undefined;
const typedMs = ms !== undefined && ms > 0 ? ms : '';
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionDurationMs: typedMs,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionDurationMs: typedMs,
});
}
} else if (prop === 'slideTransitionEasing') {
// Cast to proper type - form values are validated by select options
type EasingValue =
| 'ease-in-out'
| 'ease-in'
| 'ease-out'
| 'linear'
| ''
| undefined;
const typedEasing = (value || undefined) as EasingValue;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionEasing: typedEasing,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionEasing: typedEasing,
});
}
} else if (prop === 'slideTransitionOverlayColor') {
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionOverlayColor:
value || undefined,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionOverlayColor:
value || undefined,
});
}
} else if (
prop === 'hoverReveal' ||
prop === 'hoverRevealPersist' ||
prop === 'hoverPersistOnClick'
) {
// Boolean properties - convert 'true' string to boolean
updateSelectedElement({
[prop]: value === 'true',
});
} else {
// Standard effect properties
updateSelectedElement({
[prop]: value || undefined,
});
}
}}
audioAssetOptions={assetOptions.audio}
/>
)}
</>
)}
</>
)}
</div>
);
}
export default ElementEditorPanel;