added additional settings for gallery, made structure more flexible

This commit is contained in:
Dmitri 2026-04-02 11:12:14 +04:00
parent 73f524dab3
commit 8ef30576b1
12 changed files with 995 additions and 168 deletions

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ import {
GallerySettingsSectionCompact,
CarouselSettingsSectionCompact,
GalleryCarouselSettingsSectionCompact,
GallerySectionStyleInputs,
extractNumericValue,
} from '../ElementSettings';
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
@ -132,7 +133,7 @@ interface ElementEditorPanelProps {
};
galleryInfoSpans: {
add: () => void;
update: (spanId: string, text: string) => void;
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
remove: (spanId: string) => void;
};
carouselSlides: {
@ -502,15 +503,9 @@ export function ElementEditorPanel({
galleryInfoSpans={
selectedElement.galleryInfoSpans || []
}
galleryColumns={selectedElement.galleryColumns || 3}
galleryTitleFontFamily={
selectedElement.galleryTitleFontFamily || ''
}
galleryTextFontFamily={
selectedElement.galleryTextFontFamily || ''
}
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={imageAssetOptions}
iconAssetOptions={iconAssetOptions}
onUpdateHeader={(patch) => onUpdateElement(patch)}
onAddInfoSpan={galleryInfoSpans.add}
onUpdateInfoSpan={galleryInfoSpans.update}
@ -581,6 +576,141 @@ export function ElementEditorPanel({
)}
{/* 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-gray-700'>
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 || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
}
showFont
/>
<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 || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
}
showFont
/>
<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,
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
}
showFont
showGap
showColumns
/>
<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 || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
}
showGap
showColumns
showTitleStyles
/>
<p className='text-[11px] font-semibold text-gray-700 pt-2'>
General Element Styles
</p>
</div>
)}
</>
)}
{activeTab === 'css' && (
<StyleSettingsSectionCompact
values={{

View File

@ -0,0 +1,244 @@
/**
* GallerySectionStyleInputs
*
* Reusable component for gallery section style inputs.
* Used in the CSS tab to configure wrapper, header, title, span, and card styles.
*/
import React from 'react';
import { FONT_OPTIONS } from '../../lib/fonts';
interface GallerySectionStyleInputsProps {
sectionLabel: string;
prefix: string;
values: Record<string, string | number>;
onChange: (prop: string, value: string | number) => void;
showFont?: boolean;
showColumns?: boolean;
showGap?: boolean;
showBlur?: boolean;
showTitleStyles?: boolean;
}
/**
* Reusable inputs for gallery section styling
*/
const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
sectionLabel,
prefix,
values,
onChange,
showFont = false,
showColumns = false,
showGap = false,
showBlur = false,
showTitleStyles = false,
}) => {
return (
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'>{sectionLabel}</p>
<div className='grid grid-cols-2 gap-2'>
{/* Background Color */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>BG color</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}BackgroundColor`] || ''}
onChange={(e) => onChange(`${prefix}BackgroundColor`, e.target.value)}
placeholder='rgba(0,0,0,0.6)'
/>
</div>
{/* Text Color (not for wrapper) */}
{prefix !== 'galleryWrapper' && !showTitleStyles && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Text color</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Color`] || ''}
onChange={(e) => onChange(`${prefix}Color`, e.target.value)}
placeholder='#ffffff'
/>
</div>
)}
{/* Padding */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Padding</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Padding`] || ''}
onChange={(e) => onChange(`${prefix}Padding`, e.target.value)}
placeholder='0.75rem'
/>
</div>
{/* Border Radius */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Radius</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}BorderRadius`] || ''}
onChange={(e) => onChange(`${prefix}BorderRadius`, e.target.value)}
placeholder='0.75rem'
/>
</div>
{/* Border */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Border</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Border`] || ''}
onChange={(e) => onChange(`${prefix}Border`, e.target.value)}
placeholder='1px solid #ccc'
/>
</div>
{/* Gap (optional) */}
{showGap && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Gap</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Gap`] || ''}
onChange={(e) => onChange(`${prefix}Gap`, e.target.value)}
placeholder='0.5rem'
/>
</div>
)}
{/* Backdrop Blur (wrapper only) */}
{showBlur && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Blur</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}BackdropBlur`] || ''}
onChange={(e) => onChange(`${prefix}BackdropBlur`, e.target.value)}
placeholder='4px'
/>
</div>
)}
{/* Grid Columns (optional) */}
{showColumns && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Columns</label>
<input
type='number'
min='1'
max='6'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Columns`] || ''}
onChange={(e) => onChange(`${prefix}Columns`, parseInt(e.target.value) || 3)}
placeholder='3'
/>
</div>
)}
{/* Font Size (optional) */}
{showFont && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Font size</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}FontSize`] || ''}
onChange={(e) => onChange(`${prefix}FontSize`, e.target.value)}
placeholder='0.875rem'
/>
</div>
)}
{/* Font Weight (optional) */}
{showFont && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Font weight</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}FontWeight`] || ''}
onChange={(e) => onChange(`${prefix}FontWeight`, e.target.value)}
placeholder='500'
/>
</div>
)}
</div>
{/* Font Family (optional - full width) */}
{showFont && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Font</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}FontFamily`] || ''}
onChange={(e) => onChange(`${prefix}FontFamily`, e.target.value)}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
)}
{/* Card Title Styles (cards only) */}
{showTitleStyles && (
<>
<p className='text-[10px] text-gray-500 pt-1'>Card title overlay:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title color</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.galleryCardTitleColor || ''}
onChange={(e) => onChange('galleryCardTitleColor', e.target.value)}
placeholder='#ffffff'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title BG</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.galleryCardTitleBackgroundColor || ''}
onChange={(e) => onChange('galleryCardTitleBackgroundColor', e.target.value)}
placeholder='transparent'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title size</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.galleryCardTitleFontSize || ''}
onChange={(e) => onChange('galleryCardTitleFontSize', e.target.value)}
placeholder='0.75rem'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title weight</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.galleryCardTitleFontWeight || ''}
onChange={(e) => onChange('galleryCardTitleFontWeight', e.target.value)}
placeholder='700'
/>
</div>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title shadow</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.galleryCardTitleShadow || ''}
onChange={(e) => onChange('galleryCardTitleShadow', e.target.value)}
placeholder='0 1px 3px rgba(0,0,0,0.5)'
/>
</div>
</>
)}
</div>
);
};
export default GallerySectionStyleInputs;

View File

@ -3,6 +3,7 @@
*
* Compact gallery element settings for constructor sidebar.
* Header image, title, info spans, and card management.
* Note: Fonts, columns, and styling are now in the CSS tab.
*/
import React from 'react';
@ -12,7 +13,6 @@ import type {
AssetOption,
} from '../../types/constructor';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import { FONT_OPTIONS } from '../../lib/fonts';
interface GallerySettingsSectionCompactProps {
// Header settings
@ -20,24 +20,18 @@ interface GallerySettingsSectionCompactProps {
galleryHeaderText: string;
galleryTitle: string;
galleryInfoSpans: GalleryInfoSpan[];
galleryColumns: number;
// Font settings
galleryTitleFontFamily: string;
galleryTextFontFamily: string;
// Cards
galleryCards: GalleryCard[];
imageAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
// Header handlers
onUpdateHeader: (patch: {
galleryHeaderImageUrl?: string;
galleryHeaderText?: string;
galleryTitle?: string;
galleryColumns?: number;
galleryTitleFontFamily?: string;
galleryTextFontFamily?: string;
}) => void;
onAddInfoSpan: () => void;
onUpdateInfoSpan: (spanId: string, text: string) => void;
onUpdateInfoSpan: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
onRemoveInfoSpan: (spanId: string) => void;
// Card handlers
onAddCard: () => void;
@ -52,11 +46,9 @@ const GallerySettingsSectionCompact: React.FC<
galleryHeaderText,
galleryTitle,
galleryInfoSpans,
galleryColumns,
galleryTitleFontFamily,
galleryTextFontFamily,
galleryCards,
imageAssetOptions,
iconAssetOptions,
onUpdateHeader,
onAddInfoSpan,
onUpdateInfoSpan,
@ -107,58 +99,6 @@ const GallerySettingsSectionCompact: React.FC<
onUpdateHeader({ galleryTitle: event.target.value })
}
/>
<div className='flex items-center gap-2'>
<label className='text-[10px] text-gray-600'>Grid columns:</label>
<input
type='number'
min='1'
max='6'
className='w-16 rounded border border-gray-300 px-2 py-1 text-xs'
value={galleryColumns}
onChange={(event) =>
onUpdateHeader({
galleryColumns: Math.max(1, Math.min(6, parseInt(event.target.value) || 3)),
})
}
/>
</div>
<div>
<label className='text-[10px] text-gray-600'>Title font:</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={galleryTitleFontFamily}
onChange={(event) =>
onUpdateHeader({ galleryTitleFontFamily: event.target.value })
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
<div>
<label className='text-[10px] text-gray-600'>Text font:</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={galleryTextFontFamily}
onChange={(event) =>
onUpdateHeader({ galleryTextFontFamily: event.target.value })
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
</div>
</div>
{/* Info Spans */}
@ -175,26 +115,48 @@ const GallerySettingsSectionCompact: React.FC<
</div>
{galleryInfoSpans.map((span, index) => (
<div key={span.id} className='flex items-center gap-1'>
<input
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder={`Span ${index + 1}`}
value={span.text}
onChange={(event) => onUpdateInfoSpan(span.id, event.target.value)}
/>
<button
type='button'
className='text-xs text-red-600 hover:underline px-1'
onClick={() => onRemoveInfoSpan(span.id)}
<div key={span.id} className='space-y-1'>
<div className='flex items-center gap-1'>
<input
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
placeholder={`Span ${index + 1} text`}
value={span.text}
onChange={(event) =>
onUpdateInfoSpan(span.id, { text: event.target.value })
}
/>
<button
type='button'
className='text-xs text-red-600 hover:underline px-1'
onClick={() => onRemoveInfoSpan(span.id)}
>
×
</button>
</div>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={span.iconUrl || ''}
onChange={(event) =>
onUpdateInfoSpan(span.id, { iconUrl: event.target.value })
}
>
×
</button>
<option value=''>No icon (use text)</option>
{addFallbackAssetOption(
iconAssetOptions,
span.iconUrl,
`Current icon`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
))}
{galleryInfoSpans.length === 0 && (
<p className='text-[10px] text-gray-500'>
Add spans for brief notes (capacity, price, etc.)
Add spans for brief notes (capacity, price, icons, etc.)
</p>
)}
</div>

View File

@ -25,6 +25,7 @@ export { default as MediaSettingsSection } from './MediaSettingsSection';
export { default as MediaSettingsSectionCompact } from './MediaSettingsSectionCompact';
export { default as GallerySettingsSection } from './GallerySettingsSection';
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
export { default as GallerySectionStyleInputs } from './GallerySectionStyleInputs';
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
export { default as GalleryCarouselSettingsSectionCompact } from './GalleryCarouselSettingsSectionCompact';

View File

@ -65,33 +65,41 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
// Without icon: render styled text description
// Background color is controlled via CSS Styles tab (backgroundColor property)
// fontWeight from CSS Styles tab is applied to both title and text
const fontWeight = element.fontWeight || 'bold';
// Inheritable styles (color, fontSize, fontWeight) cascade from General Element Styles
// when section-specific values are not set
// Build title style - only set properties if explicitly configured
// fontWeight cascades from General Element Styles via CSS inheritance
const titleStyle: CSSProperties = {
...titleFontStyle,
};
if (element.descriptionTitleFontSize) {
titleStyle.fontSize = normalizePixelValue(element.descriptionTitleFontSize);
}
if (element.descriptionTitleColor) {
titleStyle.color = element.descriptionTitleColor;
}
// Build text style - only set properties if explicitly configured
// fontWeight cascades from General Element Styles via CSS inheritance
const textStyle: CSSProperties = {
...textFontStyle,
};
if (element.descriptionTextFontSize) {
textStyle.fontSize = normalizePixelValue(element.descriptionTextFontSize);
}
if (element.descriptionTextColor) {
textStyle.color = element.descriptionTextColor;
}
return (
<div className={className} style={style}>
<div className='p-4'>
<p
style={{
fontSize:
normalizePixelValue(element.descriptionTitleFontSize) || '24px',
color: element.descriptionTitleColor || '#ffffff',
fontWeight,
...titleFontStyle,
}}
>
<p style={titleStyle}>
{element.descriptionTitle || ''}
</p>
{element.descriptionText && (
<p
style={{
fontSize:
normalizePixelValue(element.descriptionTextFontSize) || '16px',
color: element.descriptionTextColor || '#ffffff',
fontWeight,
...textFontStyle,
}}
>
<p style={textStyle}>
{element.descriptionText}
</p>
)}

View File

@ -2,7 +2,7 @@
* GalleryElement Component
*
* Gallery element with header image, title, info spans, and grid of image cards.
* Renders with unified wrapper styling + content.
* Renders with unified wrapper styling + content using section-specific styles.
*/
import React, { useMemo } from 'react';
@ -13,7 +13,16 @@ import type {
GalleryInfoSpan,
} from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import {
buildGalleryHeaderStyle,
buildGalleryTitleStyle,
buildGallerySpanStyle,
buildGallerySpanGridStyle,
buildGalleryCardStyle,
buildGalleryCardTitleStyle,
buildGalleryCardGridStyle,
GALLERY_SECTION_DEFAULTS,
} from '../../../lib/gallerySectionStyles';
interface GalleryElementProps {
element: CanvasElement;
@ -36,31 +45,59 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
const headerImageUrl = element.galleryHeaderImageUrl;
const headerText = element.galleryHeaderText;
const title = element.galleryTitle;
const columns = element.galleryColumns || 3;
// Resolve font keys to full CSS styles (including fontStretch for condensed variants)
const titleFontStyle = useMemo(() => {
const fontKey = element.galleryTitleFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.galleryTitleFontFamily]);
// Build section styles from element properties
const headerStyle = useMemo(() => buildGalleryHeaderStyle(element), [element]);
const titleStyle = useMemo(() => buildGalleryTitleStyle(element), [element]);
const spanStyle = useMemo(() => buildGallerySpanStyle(element), [element]);
const spanGridStyle = useMemo(() => buildGallerySpanGridStyle(element), [element]);
const cardStyle = useMemo(() => buildGalleryCardStyle(element), [element]);
const cardTitleStyle = useMemo(() => buildGalleryCardTitleStyle(element), [element]);
const cardGridStyle = useMemo(() => buildGalleryCardGridStyle(element), [element]);
const textFontStyle = useMemo(() => {
const fontKey = element.galleryTextFontFamily;
if (!fontKey) return {};
const font = getFontByKey(fontKey);
return font ? getFontStyle(font) : { fontFamily: fontKey };
}, [element.galleryTextFontFamily]);
// Build wrapper style from general element styles with gallery defaults
const wrapperDefaults = GALLERY_SECTION_DEFAULTS.wrapper;
const wrapperStyle: CSSProperties = useMemo(() => ({
display: 'flex',
flexDirection: 'column',
backgroundColor: style.backgroundColor || wrapperDefaults.backgroundColor,
padding: style.padding || wrapperDefaults.padding,
borderRadius: style.borderRadius || wrapperDefaults.borderRadius,
border: style.border,
gap: style.gap || wrapperDefaults.gap,
backdropFilter: wrapperDefaults.backdropFilter,
WebkitBackdropFilter: wrapperDefaults.backdropFilter,
// Visual properties that should apply to the wrapper, not outer positioning div
boxShadow: style.boxShadow,
opacity: style.opacity,
// Inheritable text styles - cascade to child sections
color: style.color,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
}), [style.backgroundColor, style.padding, style.borderRadius, style.border, style.gap, style.boxShadow, style.opacity, style.color, style.fontSize, style.fontWeight, style.lineHeight, wrapperDefaults]);
// Extract backgroundColor from style to apply to inner content wrapper
const { backgroundColor, ...outerStyle } = style;
// Extract wrapper-related styles from outer style (they go to wrapper, not outer div)
const {
backgroundColor: _bg,
padding: _pad,
borderRadius: _radius,
border: _border,
gap: _gap,
boxShadow: _shadow,
opacity: _opacity,
color: _color,
fontSize: _fontSize,
fontWeight: _fontWeight,
lineHeight: _lineHeight,
...outerStyle
} = style;
return (
<div className={className} style={outerStyle}>
<div
className='flex flex-col gap-2 p-3 rounded-xl min-w-[200px] backdrop-blur-sm'
style={{ backgroundColor: backgroundColor || 'rgba(0, 0, 0, 0.6)' }}
className='min-w-[200px]'
style={wrapperStyle}
>
{/* Header: image takes priority, otherwise render text */}
{headerImageUrl ? (
@ -72,56 +109,60 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
draggable={false}
/>
) : headerText ? (
<div
className='text-2xl font-bold px-1 py-2'
style={titleFontStyle}
>
{headerText}
</div>
<div style={headerStyle}>{headerText}</div>
) : null}
{/* Title */}
{title && (
<div
className='bg-amber-50 text-slate-800 text-center py-2 px-3 rounded-lg font-semibold text-sm'
style={titleFontStyle}
>
<div className='text-center' style={titleStyle}>
{title}
</div>
)}
{/* Info spans */}
{infoSpans.length > 0 && (
<div
className='grid gap-2'
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{infoSpans.map((span) => (
<div
key={span.id}
className='bg-slate-700 text-amber-50 text-center py-2 px-2 rounded-lg text-xs font-medium'
style={textFontStyle}
>
{span.text}
</div>
))}
<div style={spanGridStyle}>
{infoSpans.map((span) => {
// For icon spans, remove padding so image fills container
const spanItemStyle = span.iconUrl
? { ...spanStyle, padding: 0, overflow: 'hidden' }
: spanStyle;
return (
<div
key={span.id}
className='text-center flex items-center justify-center'
style={spanItemStyle}
>
{span.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(span.iconUrl)}
alt=''
className='w-full h-full object-cover'
draggable={false}
/>
) : (
span.text
)}
</div>
);
})}
</div>
)}
{/* Gallery cards */}
{cards.length > 0 && (
<div
className='grid gap-2 w-full'
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
>
<div style={cardGridStyle}>
{cards.map((card, index) => (
<div
key={card.id}
className={`relative aspect-[4/3] min-w-[50px] min-h-[40px] ${
className={`relative aspect-[4/3] min-w-[50px] min-h-[40px] overflow-hidden ${
onCardClick
? 'cursor-pointer hover:ring-2 hover:ring-white hover:ring-offset-1 hover:ring-offset-black/50 transition-all'
: ''
}`}
style={cardStyle}
onClick={(e) => {
if (onCardClick) {
e.stopPropagation();
@ -134,18 +175,14 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
<img
src={resolve(card.imageUrl)}
alt={card.title || ''}
className='absolute inset-0 w-full h-full object-cover rounded-lg'
className='absolute inset-0 w-full h-full object-cover'
style={{ borderRadius: cardStyle.borderRadius }}
draggable={false}
/>
)}
{card.title && (
<div className='absolute inset-0 flex items-center justify-center'>
<span
className='text-white text-xs font-bold drop-shadow-lg'
style={textFontStyle}
>
{card.title}
</span>
<span style={cardTitleStyle}>{card.title}</span>
</div>
)}
</div>

View File

@ -63,13 +63,16 @@ const TooltipElement: React.FC<TooltipElementProps> = ({
}
// Without icon: render text tooltip
// Inheritable styles (color, fontSize, fontWeight) cascade from General Element Styles
// Font family can be set per-section via tooltipTitleFontFamily/tooltipTextFontFamily
return (
<div className={className} style={style}>
<div className='p-3 max-w-[200px]'>
<p className='font-bold text-sm' style={titleFontStyle}>
<p style={titleFontStyle}>
{element.tooltipTitle}
</p>
<p className='text-xs opacity-70' style={textFontStyle}>
<p style={{ opacity: 0.7, ...textFontStyle }}>
{element.tooltipText}
</p>
</div>

View File

@ -87,7 +87,7 @@ interface UseConstructorElementsResult {
/** Gallery info span operations */
galleryInfoSpans: {
add: () => void;
update: (spanId: string, text: string) => void;
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
remove: (spanId: string) => void;
};
/** Carousel slide operations */
@ -360,11 +360,11 @@ export function useConstructorElements({
];
updateSelectedElement({ galleryInfoSpans: nextSpans });
},
update: (spanId: string, text: string) => {
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => {
if (!selectedElement || !isGalleryElementType(selectedElement.type))
return;
const nextSpans = (selectedElement.galleryInfoSpans || []).map((span) =>
span.id === spanId ? { ...span, text } : span,
span.id === spanId ? { ...span, ...patch } : span,
);
updateSelectedElement({ galleryInfoSpans: nextSpans });
},

View File

@ -9,10 +9,12 @@ import type {
CanvasElement,
CanvasElementType,
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
NavigationButtonKind,
} from '../types/constructor';
import { ELEMENT_STYLE_PROPS } from './elementStyles';
import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles';
/**
* Generate a local unique ID for elements
@ -230,6 +232,17 @@ export const normalizeGalleryCard = (
description: String(card?.description || ''),
});
/**
* Normalize a gallery info span from unknown input
*/
export const normalizeGalleryInfoSpan = (
span: Record<string, unknown>,
): GalleryInfoSpan => ({
id: String(span?.id || createLocalId()),
text: String(span?.text || ''),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
});
/**
* Normalize a carousel slide from unknown input
*/
@ -313,6 +326,18 @@ export const mergeElementWithDefaults = (
merged.galleryCards = cards.map((card, i) =>
normalizeGalleryCard(card as unknown as Record<string, unknown>, i),
);
// Handle gallery info spans array
const spans = preferElementValues
? Array.isArray(element.galleryInfoSpans)
? element.galleryInfoSpans
: defaults.galleryInfoSpans || []
: Array.isArray(defaults.galleryInfoSpans)
? defaults.galleryInfoSpans
: element.galleryInfoSpans || [];
merged.galleryInfoSpans = spans.map((span) =>
normalizeGalleryInfoSpan(span as unknown as Record<string, unknown>),
);
}
// Handle carousel slides array
@ -360,6 +385,13 @@ export const parseElementSettings = (
);
}
// Parse gallery info spans if present
if (Array.isArray(settings.galleryInfoSpans)) {
settings.galleryInfoSpans = settings.galleryInfoSpans.map((span) =>
normalizeGalleryInfoSpan(span as Record<string, unknown>),
);
}
// Parse carousel slides if present
if (Array.isArray(settings.carouselSlides)) {
settings.carouselSlides = settings.carouselSlides.map((slide, i) =>
@ -525,6 +557,13 @@ export const buildElementSettings = (
description: card.description || '',
}));
}
if (Array.isArray(element.galleryInfoSpans)) {
settings.galleryInfoSpans = element.galleryInfoSpans.map((span) => ({
id: String(span.id || createLocalId()),
text: span.text || '',
iconUrl: span.iconUrl || undefined,
}));
}
addIfNotEmpty(
settings,
'galleryTitleFontFamily',
@ -535,6 +574,19 @@ export const buildElementSettings = (
'galleryTextFontFamily',
element.galleryTextFontFamily,
);
addIfNotEmpty(settings, 'galleryColumns', element.galleryColumns);
addIfNotEmpty(
settings,
'galleryHeaderImageUrl',
element.galleryHeaderImageUrl,
);
addIfNotEmpty(settings, 'galleryHeaderText', element.galleryHeaderText);
addIfNotEmpty(settings, 'galleryTitle', element.galleryTitle);
// Gallery section style properties
GALLERY_SECTION_STYLE_PROPS.forEach((prop) => {
const value = (element as Record<string, unknown>)[prop];
addIfNotEmpty(settings, prop, value);
});
}
// Carousel type settings

View File

@ -0,0 +1,343 @@
/**
* Gallery Section Styles
*
* Unified types and utilities for gallery element section styling.
* Follows the same pattern as elementStyles.ts for consistency.
*/
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../types/constructor';
import { getFontByKey, getFontStyle } from './fonts';
/**
* Gallery section names for styling
*/
export type GallerySectionName = 'header' | 'title' | 'span' | 'card' | 'wrapper';
/**
* Default values for gallery sections to preserve current Tailwind appearance
*/
export const GALLERY_SECTION_DEFAULTS: Record<GallerySectionName, CSSProperties> = {
header: {
fontSize: '1.5rem', // text-2xl
fontWeight: '700', // font-bold
padding: '0.25rem 0.5rem', // px-1 py-2
},
title: {
backgroundColor: '#fefce8', // bg-amber-50
color: '#1e293b', // text-slate-800
fontSize: '0.875rem', // text-sm
fontWeight: '600', // font-semibold
padding: '0.5rem 0.75rem', // py-2 px-3
borderRadius: '0.5rem', // rounded-lg
},
span: {
backgroundColor: '#334155', // bg-slate-700
color: '#fef3c7', // text-amber-50
fontSize: '0.75rem', // text-xs
fontWeight: '500', // font-medium
padding: '0.5rem', // py-2 px-2
borderRadius: '0.5rem', // rounded-lg
},
card: {
borderRadius: '0.5rem', // rounded-lg
},
wrapper: {
backgroundColor: 'rgba(0, 0, 0, 0.6)', // bg-black/60
padding: '0.75rem', // p-3
borderRadius: '0.75rem', // rounded-xl
gap: '0.5rem', // gap-2
backdropFilter: 'blur(4px)', // backdrop-blur-sm
},
};
/**
* Get trimmed value from unknown input
*/
const getTrimmedValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (value === 0) return '0';
return String(value).trim();
};
/**
* Apply value with default fallback
*/
const applyWithDefault = (
style: CSSProperties,
prop: keyof CSSProperties,
value: unknown,
defaultValue: unknown,
): void => {
const trimmed = getTrimmedValue(value);
if (trimmed) {
(style as Record<string, unknown>)[prop] = trimmed;
} else if (defaultValue !== undefined) {
(style as Record<string, unknown>)[prop] = defaultValue;
}
};
/**
* Apply value only if explicitly set (no default - allows CSS inheritance)
*/
const applyIfSet = (
style: CSSProperties,
prop: keyof CSSProperties,
value: unknown,
): void => {
const trimmed = getTrimmedValue(value);
if (trimmed) {
(style as Record<string, unknown>)[prop] = trimmed;
}
};
/**
* Build CSS style object for gallery header section
*/
export function buildGalleryHeaderStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = GALLERY_SECTION_DEFAULTS.header;
const style: CSSProperties = {};
applyIfSet(style, 'backgroundColor', element.galleryHeaderBackgroundColor);
// Inheritable properties: only apply if explicitly set (allows CSS inheritance from wrapper)
applyIfSet(style, 'color', element.galleryHeaderColor);
applyIfSet(style, 'fontSize', element.galleryHeaderFontSize);
applyIfSet(style, 'fontWeight', element.galleryHeaderFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.galleryHeaderPadding, defaults.padding);
applyIfSet(style, 'borderRadius', element.galleryHeaderBorderRadius);
applyIfSet(style, 'border', element.galleryHeaderBorder);
// Apply font family with font library resolution
const fontKey = element.galleryHeaderFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for gallery title section
*/
export function buildGalleryTitleStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = GALLERY_SECTION_DEFAULTS.title;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.galleryTitleBackgroundColor, defaults.backgroundColor);
// Inheritable properties: only apply if explicitly set (allows CSS inheritance from wrapper)
applyIfSet(style, 'color', element.galleryTitleColor);
applyIfSet(style, 'fontSize', element.galleryTitleFontSize);
applyIfSet(style, 'fontWeight', element.galleryTitleFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.galleryTitlePadding, defaults.padding);
applyWithDefault(style, 'borderRadius', element.galleryTitleBorderRadius, defaults.borderRadius);
applyIfSet(style, 'border', element.galleryTitleBorder);
// Apply font family with font library resolution
const fontKey = element.galleryTitleFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for gallery span items
*/
export function buildGallerySpanStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = GALLERY_SECTION_DEFAULTS.span;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.gallerySpanBackgroundColor, defaults.backgroundColor);
// Inheritable properties: only apply if explicitly set (allows CSS inheritance from wrapper)
applyIfSet(style, 'color', element.gallerySpanColor);
applyIfSet(style, 'fontSize', element.gallerySpanFontSize);
applyIfSet(style, 'fontWeight', element.gallerySpanFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.gallerySpanPadding, defaults.padding);
applyWithDefault(style, 'borderRadius', element.gallerySpanBorderRadius, defaults.borderRadius);
applyIfSet(style, 'border', element.gallerySpanBorder);
// Apply font family with font library resolution (fallback to galleryTextFontFamily for legacy support)
const fontKey = element.gallerySpanFontFamily || element.galleryTextFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for gallery span grid container
*/
export function buildGallerySpanGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getGalleryGridColumns(element, 'span');
const gap = getTrimmedValue(element.gallerySpanGap) || '0.5rem';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap,
};
}
/**
* Build CSS style object for gallery card items
*/
export function buildGalleryCardStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const defaults = GALLERY_SECTION_DEFAULTS.card;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.galleryCardBackgroundColor, undefined);
applyWithDefault(style, 'borderRadius', element.galleryCardBorderRadius, defaults.borderRadius);
applyWithDefault(style, 'border', element.galleryCardBorder, undefined);
return style;
}
/**
* Build CSS style object for gallery card title overlay
*/
export function buildGalleryCardTitleStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const style: CSSProperties = {};
// Inheritable properties: only apply if explicitly set (allows CSS inheritance from wrapper)
// Note: card titles typically need white text for visibility over images
applyIfSet(style, 'color', element.galleryCardTitleColor);
applyIfSet(style, 'fontSize', element.galleryCardTitleFontSize);
applyIfSet(style, 'fontWeight', element.galleryCardTitleFontWeight);
if (element.galleryCardTitleBackgroundColor) {
style.backgroundColor = element.galleryCardTitleBackgroundColor;
}
// Handle text shadow
const shadow = element.galleryCardTitleShadow;
if (shadow) {
style.textShadow = shadow;
} else {
// Default drop-shadow-lg equivalent for visibility over images
style.filter = 'drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))';
}
// Apply font family from galleryTextFontFamily for legacy support
const fontKey = element.galleryTextFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
Object.assign(style, getFontStyle(font));
} else {
style.fontFamily = fontKey;
}
}
return style;
}
/**
* Build CSS style object for gallery cards grid container
*/
export function buildGalleryCardGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getGalleryGridColumns(element, 'card');
const gap = getTrimmedValue(element.galleryCardGap) || '0.5rem';
return {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
width: '100%',
};
}
/**
* Get grid columns with fallback to legacy galleryColumns
*/
export function getGalleryGridColumns(
element: Partial<CanvasElement>,
section: 'span' | 'card',
): number {
if (section === 'span') {
return element.gallerySpanColumns ?? element.galleryColumns ?? 3;
}
// For cards, use galleryCardColumns with fallback to legacy galleryColumns
return element.galleryCardColumns ?? element.galleryColumns ?? 3;
}
/**
* All gallery section style property names for iteration
*/
export const GALLERY_SECTION_STYLE_PROPS = [
// Header
'galleryHeaderBackgroundColor',
'galleryHeaderColor',
'galleryHeaderFontFamily',
'galleryHeaderFontSize',
'galleryHeaderFontWeight',
'galleryHeaderPadding',
'galleryHeaderBorderRadius',
'galleryHeaderBorder',
// Title
'galleryTitleBackgroundColor',
'galleryTitleColor',
'galleryTitleFontFamily',
'galleryTitleFontSize',
'galleryTitleFontWeight',
'galleryTitlePadding',
'galleryTitleBorderRadius',
'galleryTitleBorder',
// Spans
'gallerySpanBackgroundColor',
'gallerySpanColor',
'gallerySpanFontFamily',
'gallerySpanFontSize',
'gallerySpanFontWeight',
'gallerySpanPadding',
'gallerySpanBorderRadius',
'gallerySpanBorder',
'gallerySpanGap',
'gallerySpanColumns',
// Cards
'galleryCardBackgroundColor',
'galleryCardBorderRadius',
'galleryCardBorder',
'galleryCardGap',
'galleryCardColumns',
'galleryCardTitleColor',
'galleryCardTitleBackgroundColor',
'galleryCardTitleFontSize',
'galleryCardTitleFontWeight',
'galleryCardTitleShadow',
] as const;
export type GallerySectionStyleProp = (typeof GALLERY_SECTION_STYLE_PROPS)[number];

View File

@ -47,6 +47,7 @@ export interface GalleryCard {
export interface GalleryInfoSpan {
id: string;
text: string;
iconUrl?: string; // Renders icon instead of text when set
}
/**
@ -100,6 +101,52 @@ export interface CanvasElement extends BaseCanvasElement {
galleryColumns?: number;
galleryTitleFontFamily?: string;
galleryTextFontFamily?: string;
// Gallery Section Styles - Header
galleryHeaderBackgroundColor?: string;
galleryHeaderColor?: string;
galleryHeaderFontFamily?: string;
galleryHeaderFontSize?: string;
galleryHeaderFontWeight?: string;
galleryHeaderPadding?: string;
galleryHeaderBorderRadius?: string;
galleryHeaderBorder?: string;
// Gallery Section Styles - Title
galleryTitleBackgroundColor?: string;
galleryTitleColor?: string;
galleryTitleFontSize?: string;
galleryTitleFontWeight?: string;
galleryTitlePadding?: string;
galleryTitleBorderRadius?: string;
galleryTitleBorder?: string;
// Gallery Section Styles - Spans
gallerySpanBackgroundColor?: string;
gallerySpanColor?: string;
gallerySpanFontFamily?: string;
gallerySpanFontSize?: string;
gallerySpanFontWeight?: string;
gallerySpanPadding?: string;
gallerySpanBorderRadius?: string;
gallerySpanBorder?: string;
gallerySpanGap?: string;
gallerySpanColumns?: number;
// Gallery Section Styles - Cards
galleryCardBackgroundColor?: string;
galleryCardBorderRadius?: string;
galleryCardBorder?: string;
galleryCardGap?: string;
galleryCardColumns?: number;
galleryCardTitleColor?: string;
galleryCardTitleBackgroundColor?: string;
galleryCardTitleFontSize?: string;
galleryCardTitleFontWeight?: string;
galleryCardTitleShadow?: string;
// Gallery Section Styles - Wrapper
galleryWrapperBackgroundColor?: string;
galleryWrapperPadding?: string;
galleryWrapperBorderRadius?: string;
galleryWrapperBorder?: string;
galleryWrapperGap?: string;
galleryWrapperBackdropBlur?: string;
carouselSlides?: CarouselSlide[];
carouselCaptionFontFamily?: string;
carouselPrevIconUrl?: string;
@ -401,7 +448,7 @@ export interface EditorCollectionOpsProps {
};
galleryInfoSpans: {
add: () => void;
update: (spanId: string, text: string) => void;
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
remove: (spanId: string) => void;
};
carouselSlides: {