added dimentions settings for gallery header image and cards previews

This commit is contained in:
Dmitri 2026-04-03 10:37:13 +04:00
parent ca3b3725f9
commit c235909c54
28 changed files with 574 additions and 179 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
/// <reference path="./build/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

File diff suppressed because one or more lines are too long

View File

@ -48,7 +48,9 @@ export function useProjectSelector({
const response = await axios.get(
'/projects?limit=100&page=0&sort=desc&field=updatedAt',
);
const rows = Array.isArray(response?.data?.rows) ? response.data.rows : [];
const rows = Array.isArray(response?.data?.rows)
? response.data.rows
: [];
setProjects(rows);
} catch (error: unknown) {
const errorMessage =

View File

@ -91,7 +91,10 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
// Add transition for interactive effects (preview mode only)
if (!isEditMode && hasAnyEffects(effectProperties)) {
positionStyle = { ...positionStyle, ...buildTransitionStyle(effectProperties) };
positionStyle = {
...positionStyle,
...buildTransitionStyle(effectProperties),
};
}
// Add appear animation (ALWAYS - for WYSIWYG)

View File

@ -367,7 +367,9 @@ export function ElementEditorPanel({
}
navType={selectedElement.navType}
navLabel={selectedElement.navLabel || ''}
navLabelFontFamily={selectedElement.navLabelFontFamily || ''}
navLabelFontFamily={
selectedElement.navLabelFontFamily || ''
}
navDisabled={selectedElement.navDisabled || false}
iconUrl={selectedElement.iconUrl || ''}
targetPageSlug={selectedElement.targetPageSlug || ''}
@ -604,11 +606,20 @@ export function ElementEditorPanel({
selectedElement.galleryHeaderBorderRadius || '',
galleryHeaderBorder:
selectedElement.galleryHeaderBorder || '',
galleryHeaderWidth:
selectedElement.galleryHeaderWidth || '',
galleryHeaderHeight:
selectedElement.galleryHeaderHeight || '',
galleryHeaderMinHeight:
selectedElement.galleryHeaderMinHeight || '',
galleryHeaderMaxHeight:
selectedElement.galleryHeaderMaxHeight || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
}
showFont
showDimensions
/>
<GallerySectionStyleInputs
sectionLabel='Title'
@ -695,6 +706,10 @@ export function ElementEditorPanel({
selectedElement.galleryCardTitleFontWeight || '',
galleryCardTitleShadow:
selectedElement.galleryCardTitleShadow || '',
galleryCardAspectRatio:
selectedElement.galleryCardAspectRatio || '',
galleryCardMinHeight:
selectedElement.galleryCardMinHeight || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
@ -702,6 +717,7 @@ export function ElementEditorPanel({
showGap
showColumns
showTitleStyles
showAspectRatio
/>
<p className='text-[11px] font-semibold text-gray-700 pt-2'>
General Element Styles

View File

@ -144,7 +144,11 @@ const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
<select
value={slide.imageUrl}
onChange={(event) =>
onUpdateSlide(slide.id, 'imageUrl', event.target.value)
onUpdateSlide(
slide.id,
'imageUrl',
event.target.value,
)
}
>
<option value=''>Not selected</option>

View File

@ -89,7 +89,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='W (rem)'
value={prevWidth}
onChange={(event) =>
onUpdateElement({ galleryCarouselPrevWidth: event.target.value })
onUpdateElement({
galleryCarouselPrevWidth: event.target.value,
})
}
/>
<input
@ -100,7 +102,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='H (rem)'
value={prevHeight}
onChange={(event) =>
onUpdateElement({ galleryCarouselPrevHeight: event.target.value })
onUpdateElement({
galleryCarouselPrevHeight: event.target.value,
})
}
/>
</div>
@ -136,7 +140,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='W (rem)'
value={nextWidth}
onChange={(event) =>
onUpdateElement({ galleryCarouselNextWidth: event.target.value })
onUpdateElement({
galleryCarouselNextWidth: event.target.value,
})
}
/>
<input
@ -147,7 +153,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='H (rem)'
value={nextHeight}
onChange={(event) =>
onUpdateElement({ galleryCarouselNextHeight: event.target.value })
onUpdateElement({
galleryCarouselNextHeight: event.target.value,
})
}
/>
</div>
@ -183,7 +191,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='W (rem)'
value={backWidth}
onChange={(event) =>
onUpdateElement({ galleryCarouselBackWidth: event.target.value })
onUpdateElement({
galleryCarouselBackWidth: event.target.value,
})
}
/>
<input
@ -194,7 +204,9 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
placeholder='H (rem)'
value={backHeight}
onChange={(event) =>
onUpdateElement({ galleryCarouselBackHeight: event.target.value })
onUpdateElement({
galleryCarouselBackHeight: event.target.value,
})
}
/>
</div>
@ -210,7 +222,8 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
)}
<p className='text-[10px] text-gray-500 mt-1'>
Set icon + dimensions for navigation-style buttons. Drag to reposition.
Set icon + dimensions for navigation-style buttons. Drag to
reposition.
</p>
</div>
</div>

View File

@ -18,6 +18,8 @@ interface GallerySectionStyleInputsProps {
showGap?: boolean;
showBlur?: boolean;
showTitleStyles?: boolean;
showDimensions?: boolean;
showAspectRatio?: boolean;
}
/**
@ -33,6 +35,8 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
showGap = false,
showBlur = false,
showTitleStyles = false,
showDimensions = false,
showAspectRatio = false,
}) => {
return (
<div className='rounded border border-gray-200 p-2 space-y-2'>
@ -41,11 +45,15 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
<div className='grid grid-cols-2 gap-2'>
{/* Background Color */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>BG color</label>
<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)}
onChange={(e) =>
onChange(`${prefix}BackgroundColor`, e.target.value)
}
placeholder='rgba(0,0,0,0.6)'
/>
</div>
@ -53,7 +61,9 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Text Color (not for wrapper) */}
{prefix !== 'galleryWrapper' && !showTitleStyles && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Text color</label>
<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`] || ''}
@ -65,7 +75,9 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Padding */}
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Padding</label>
<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`] || ''}
@ -116,7 +128,9 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
<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)}
onChange={(e) =>
onChange(`${prefix}BackdropBlur`, e.target.value)
}
placeholder='4px'
/>
</div>
@ -125,14 +139,18 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Grid Columns (optional) */}
{showColumns && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Columns</label>
<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)}
onChange={(e) =>
onChange(`${prefix}Columns`, parseInt(e.target.value) || 3)
}
placeholder='3'
/>
</div>
@ -141,7 +159,9 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Font Size (optional) */}
{showFont && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Font size</label>
<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`] || ''}
@ -154,7 +174,9 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Font Weight (optional) */}
{showFont && (
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Font weight</label>
<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`] || ''}
@ -184,54 +206,165 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
</div>
)}
{/* Header Dimensions (header section only) */}
{showDimensions && (
<>
<p className='text-[10px] text-gray-500 pt-1'>Dimensions:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Width
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Width`] || ''}
onChange={(e) => onChange(`${prefix}Width`, e.target.value)}
placeholder='100%, 300px'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Height`] || ''}
onChange={(e) => onChange(`${prefix}Height`, e.target.value)}
placeholder='200px, auto'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Min Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}MinHeight`] || ''}
onChange={(e) => onChange(`${prefix}MinHeight`, e.target.value)}
placeholder='150px'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Max Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}MaxHeight`] || ''}
onChange={(e) => onChange(`${prefix}MaxHeight`, e.target.value)}
placeholder='400px, 50vh'
/>
</div>
</div>
</>
)}
{/* Card Aspect Ratio and Min Height (cards only) */}
{showAspectRatio && (
<>
<p className='text-[10px] text-gray-500 pt-1'>Card dimensions:</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Aspect Ratio
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}AspectRatio`] || ''}
onChange={(e) =>
onChange(`${prefix}AspectRatio`, e.target.value)
}
>
<option value=''>4:3 (Default)</option>
<option value='16/9'>16:9 (Widescreen)</option>
<option value='1/1'>1:1 (Square)</option>
<option value='3/2'>3:2</option>
<option value='auto'>Auto</option>
</select>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>
Min Height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}MinHeight`] || ''}
onChange={(e) => onChange(`${prefix}MinHeight`, e.target.value)}
placeholder='150px'
/>
</div>
</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>
<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)}
onChange={(e) =>
onChange('galleryCardTitleColor', e.target.value)
}
placeholder='#ffffff'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title BG</label>
<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)}
onChange={(e) =>
onChange('galleryCardTitleBackgroundColor', e.target.value)
}
placeholder='transparent'
/>
</div>
<div>
<label className='mb-1 block text-[10px] text-gray-600'>Title size</label>
<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)}
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>
<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)}
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>
<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)}
onChange={(e) =>
onChange('galleryCardTitleShadow', e.target.value)
}
placeholder='0 1px 3px rgba(0,0,0,0.5)'
/>
</div>

View File

@ -61,7 +61,9 @@ const GallerySettingsSectionCompact: React.FC<
<div className='space-y-3'>
{/* Header Settings */}
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'>Gallery header</p>
<p className='text-[11px] font-semibold text-gray-700'>
Gallery header
</p>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'

View File

@ -133,7 +133,9 @@ const NavigationSettingsSectionCompact: React.FC<
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={navLabelFontFamily}
onChange={(event) => onChange('navLabelFontFamily', event.target.value)}
onChange={(event) =>
onChange('navLabelFontFamily', event.target.value)
}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (

View File

@ -327,7 +327,9 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
mediaMuted: Boolean(settings.mediaMuted),
carouselPrevIconUrl: String(settings.carouselPrevIconUrl || ''),
carouselNextIconUrl: String(settings.carouselNextIconUrl || ''),
carouselCaptionFontFamily: String(settings.carouselCaptionFontFamily || ''),
carouselCaptionFontFamily: String(
settings.carouselCaptionFontFamily || '',
),
galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''),
galleryTextFontFamily: String(settings.galleryTextFontFamily || ''),
galleryCards: Array.isArray(settings.galleryCards)
@ -658,16 +660,19 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
settings.descriptionText = state.descriptionText;
// Only include if explicitly set - allows CSS inheritance from wrapper
if (state.descriptionTitleFontSize.trim()) {
settings.descriptionTitleFontSize = state.descriptionTitleFontSize.trim();
settings.descriptionTitleFontSize =
state.descriptionTitleFontSize.trim();
}
if (state.descriptionTextFontSize.trim()) {
settings.descriptionTextFontSize = state.descriptionTextFontSize.trim();
}
if (state.descriptionTitleFontFamily.trim()) {
settings.descriptionTitleFontFamily = state.descriptionTitleFontFamily.trim();
settings.descriptionTitleFontFamily =
state.descriptionTitleFontFamily.trim();
}
if (state.descriptionTextFontFamily.trim()) {
settings.descriptionTextFontFamily = state.descriptionTextFontFamily.trim();
settings.descriptionTextFontFamily =
state.descriptionTextFontFamily.trim();
}
if (state.descriptionTitleColor.trim()) {
settings.descriptionTitleColor = state.descriptionTitleColor.trim();
@ -698,7 +703,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
}));
settings.carouselPrevIconUrl = state.carouselPrevIconUrl.trim();
settings.carouselNextIconUrl = state.carouselNextIconUrl.trim();
settings.carouselCaptionFontFamily = state.carouselCaptionFontFamily.trim();
settings.carouselCaptionFontFamily =
state.carouselCaptionFontFamily.trim();
}
// Media type settings

View File

@ -392,23 +392,23 @@ export default function RuntimePresentation({
<>
<Head>
<title>{project?.name || 'Presentation'}</title>
{faviconUrl && <link key="favicon" rel="icon" href={faviconUrl} />}
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
{ogImageUrl && (
<>
<meta key="og:image" property="og:image" content={ogImageUrl} />
<meta key='og:image' property='og:image' content={ogImageUrl} />
<meta
key="twitter:image:src"
property="twitter:image:src"
key='twitter:image:src'
property='twitter:image:src'
content={ogImageUrl}
/>
</>
)}
{project?.name && (
<>
<meta key="og:title" property="og:title" content={project.name} />
<meta key='og:title' property='og:title' content={project.name} />
<meta
key="twitter:title"
property="twitter:title"
key='twitter:title'
property='twitter:title'
content={project.name}
/>
</>
@ -416,13 +416,13 @@ export default function RuntimePresentation({
{project?.description && (
<>
<meta
key="og:description"
property="og:description"
key='og:description'
property='og:description'
content={project.description}
/>
<meta
key="twitter:description"
property="twitter:description"
key='twitter:description'
property='twitter:description'
content={project.description}
/>
</>

View File

@ -205,9 +205,17 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
);
// For prev/next, also update the other button's Y position
if (draggingButton === 'prev') {
onButtonPositionChange('next', currentPositions.nextX, currentPositions.prevY);
onButtonPositionChange(
'next',
currentPositions.nextX,
currentPositions.prevY,
);
} else if (draggingButton === 'next') {
onButtonPositionChange('prev', currentPositions.prevX, currentPositions.nextY);
onButtonPositionChange(
'prev',
currentPositions.prevX,
currentPositions.nextY,
);
}
}
setDraggingButton(null);
@ -377,7 +385,6 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
backWidth,
backHeight,
)}
</div>
);
};

View File

@ -95,13 +95,9 @@ const DescriptionElement: React.FC<DescriptionElementProps> = ({
return (
<div className={className} style={style}>
<div className='p-4'>
<p style={titleStyle}>
{element.descriptionTitle || ''}
</p>
<p style={titleStyle}>{element.descriptionTitle || ''}</p>
{element.descriptionText && (
<p style={textStyle}>
{element.descriptionText}
</p>
<p style={textStyle}>{element.descriptionText}</p>
)}
</div>
</div>

View File

@ -47,35 +47,63 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
const title = element.galleryTitle;
// Build section styles from element properties
const headerStyle = useMemo(() => buildGalleryHeaderStyle(element), [element]);
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 spanGridStyle = useMemo(
() => buildGallerySpanGridStyle(element),
[element],
);
const cardStyle = useMemo(() => buildGalleryCardStyle(element), [element]);
const cardTitleStyle = useMemo(() => buildGalleryCardTitleStyle(element), [element]);
const cardGridStyle = useMemo(() => buildGalleryCardGridStyle(element), [element]);
const cardTitleStyle = useMemo(
() => buildGalleryCardTitleStyle(element),
[element],
);
const cardGridStyle = useMemo(
() => buildGalleryCardGridStyle(element),
[element],
);
// 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]);
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 wrapper-related styles from outer style (they go to wrapper, not outer div)
const {
@ -95,19 +123,20 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
return (
<div className={className} style={outerStyle}>
<div
className='min-w-[200px]'
style={wrapperStyle}
>
<div className='min-w-[200px]' style={wrapperStyle}>
{/* Header: image takes priority, otherwise render text */}
{/* Header styles (border, borderRadius, etc.) apply to both image and text modes */}
{/* Header styles (border, borderRadius, dimensions) apply to both image and text modes */}
{headerImageUrl ? (
<div style={{ ...headerStyle, padding: 0, overflow: 'hidden' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolve(headerImageUrl)}
alt=''
className='w-full h-auto object-cover'
className='object-cover'
style={{
width: '100%',
height: headerStyle.height || 'auto',
}}
draggable={false}
/>
</div>
@ -157,39 +186,53 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
{/* Gallery cards */}
{cards.length > 0 && (
<div style={cardGridStyle}>
{cards.map((card, index) => (
<div
key={card.id}
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();
onCardClick(index);
{cards.map((card, index) => {
// Build card container style with aspect ratio and dimensions
const cardContainerStyle: CSSProperties = {
...cardStyle,
position: 'relative',
overflow: 'hidden',
minWidth: '50px',
// Use aspect-ratio from cardStyle, fallback to 4/3
aspectRatio: cardStyle.aspectRatio || '4/3',
// Use minHeight from cardStyle, fallback to 40px
minHeight: cardStyle.minHeight || '40px',
};
return (
<div
key={card.id}
className={
onCardClick
? 'cursor-pointer hover:ring-2 hover:ring-white hover:ring-offset-1 hover:ring-offset-black/50 transition-all'
: ''
}
}}
>
{card.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(card.imageUrl)}
alt={card.title || ''}
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 style={cardTitleStyle}>{card.title}</span>
</div>
)}
</div>
))}
style={cardContainerStyle}
onClick={(e) => {
if (onCardClick) {
e.stopPropagation();
onCardClick(index);
}
}}
>
{card.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(card.imageUrl)}
alt={card.title || ''}
className='absolute inset-0 w-full h-full object-cover'
style={{ borderRadius: cardContainerStyle.borderRadius }}
draggable={false}
/>
)}
{card.title && (
<div className='absolute inset-0 flex items-center justify-center'>
<span style={cardTitleStyle}>{card.title}</span>
</div>
)}
</div>
);
})}
</div>
)}
</div>

View File

@ -69,12 +69,8 @@ const TooltipElement: React.FC<TooltipElementProps> = ({
return (
<div className={className} style={style}>
<div className='p-3 max-w-[200px]'>
<p style={titleFontStyle}>
{element.tooltipTitle}
</p>
<p style={{ opacity: 0.7, ...textFontStyle }}>
{element.tooltipText}
</p>
<p style={titleFontStyle}>{element.tooltipTitle}</p>
<p style={{ opacity: 0.7, ...textFontStyle }}>{element.tooltipText}</p>
</div>
</div>
);

View File

@ -51,7 +51,8 @@ function validateAssetType(file, expectedType) {
const hasExtensionMatch = extension && extensions.includes(extension);
if (!hasMimeMatch && !hasExtensionMatch) {
const typeLabel = expectedType.charAt(0).toUpperCase() + expectedType.slice(1);
const typeLabel =
expectedType.charAt(0).toUpperCase() + expectedType.slice(1);
return {
valid: false,
error: `Invalid file type. Expected ${typeLabel} file but got "${mimeType || 'unknown'}" (${file.name})`,
@ -114,7 +115,9 @@ export default class FileUploader {
if (schema.size && file.size > schema.size) {
const maxSizeMB = (schema.size / (1024 * 1024)).toFixed(1);
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1);
throw new Error(`File is too big. Maximum ${maxSizeMB}MB, got ${fileSizeMB}MB`);
throw new Error(
`File is too big. Maximum ${maxSizeMB}MB, got ${fileSizeMB}MB`,
);
}
// Extension validation

View File

@ -363,8 +363,8 @@ export function useConstructorElements({
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => {
if (!selectedElement || !isGalleryElementType(selectedElement.type))
return;
const nextSpans = (selectedElement.galleryInfoSpans || []).map((span) =>
span.id === spanId ? { ...span, ...patch } : span,
const nextSpans = (selectedElement.galleryInfoSpans || []).map(
(span) => (span.id === spanId ? { ...span, ...patch } : span),
);
updateSelectedElement({ galleryInfoSpans: nextSpans });
},

View File

@ -630,7 +630,11 @@ export function usePreloadOrchestrator(
const checkObject = (obj: Record<string, unknown>) => {
if (!obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value && urlFields.includes(key)) {
if (
typeof value === 'string' &&
value &&
urlFields.includes(key)
) {
elementAssetUrls.push(value);
} else if (typeof value === 'object' && value !== null) {
checkObject(value as Record<string, unknown>);

View File

@ -30,7 +30,9 @@ interface ProjectAssetInput {
* Extracts storage paths from URLs (handles both formats) and resolves them
* to presigned URLs for access.
*/
export function useProjectAssets(project: ProjectAssetInput | null): ProjectAssets {
export function useProjectAssets(
project: ProjectAssetInput | null,
): ProjectAssets {
const [assets, setAssets] = useState<ProjectAssets>({
faviconUrl: null,
ogImageUrl: null,
@ -89,7 +91,12 @@ export function useProjectAssets(project: ProjectAssetInput | null): ProjectAsse
// Reset loading state and resolve
setAssets((prev) => ({ ...prev, isLoading: true }));
resolveAssets();
}, [project?.favicon_url, project?.og_image_url, project?.logo_url, resolveAssets]);
}, [
project?.favicon_url,
project?.og_image_url,
project?.logo_url,
resolveAssets,
]);
return assets;
}

View File

@ -101,8 +101,7 @@ export type EffectPropName = (typeof EFFECT_PROPS)[number];
export function buildTransitionStyle(
effects: Partial<ElementEffectProperties>,
): CSSProperties {
const duration =
normalizeDuration(effects.hoverTransitionDuration) || '0.2s';
const duration = normalizeDuration(effects.hoverTransitionDuration) || '0.2s';
return {
transition: `all ${duration} ease`,
};
@ -228,8 +227,7 @@ export function buildAppearAnimationStyle(
return {};
}
const duration =
normalizeDuration(effects.appearAnimationDuration) || '0.3s';
const duration = normalizeDuration(effects.appearAnimationDuration) || '0.3s';
const easing = effects.appearAnimationEasing || 'ease';
return {

View File

@ -12,12 +12,20 @@ import { getFontByKey, getFontStyle } from './fonts';
/**
* Gallery section names for styling
*/
export type GallerySectionName = 'header' | 'title' | 'span' | 'card' | 'wrapper';
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> = {
export const GALLERY_SECTION_DEFAULTS: Record<
GallerySectionName,
CSSProperties
> = {
header: {
fontSize: '1.5rem', // text-2xl
fontWeight: '700', // font-bold
@ -104,10 +112,12 @@ const normalizeWithUnit = (value: unknown, unit: string): string => {
};
/** Normalize rem values (fontSize, padding, borderRadius, gap) */
const normalizeRemValue = (value: unknown): string => normalizeWithUnit(value, 'rem');
const normalizeRemValue = (value: unknown): string =>
normalizeWithUnit(value, 'rem');
/** Normalize pixel values (for properties that use px) */
const normalizePxValue = (value: unknown): string => normalizeWithUnit(value, 'px');
const normalizePxValue = (value: unknown): string =>
normalizeWithUnit(value, 'px');
/**
* Apply value with default fallback and optional unit normalization
@ -155,11 +165,27 @@ export function buildGalleryHeaderStyle(
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, normalizeRemValue);
applyIfSet(
style,
'fontSize',
element.galleryHeaderFontSize,
normalizeRemValue,
);
applyIfSet(style, 'fontWeight', element.galleryHeaderFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.galleryHeaderPadding, defaults.padding, normalizeRemValue);
applyIfSet(style, 'borderRadius', element.galleryHeaderBorderRadius, normalizeRemValue);
applyWithDefault(
style,
'padding',
element.galleryHeaderPadding,
defaults.padding,
normalizeRemValue,
);
applyIfSet(
style,
'borderRadius',
element.galleryHeaderBorderRadius,
normalizeRemValue,
);
applyIfSet(style, 'border', element.galleryHeaderBorder); // Complex value, no normalization
// Apply font family with font library resolution
@ -173,6 +199,22 @@ export function buildGalleryHeaderStyle(
}
}
// Dimension properties - only apply if explicitly set (allows CSS defaults)
applyIfSet(style, 'width', element.galleryHeaderWidth);
applyIfSet(style, 'height', element.galleryHeaderHeight, normalizePxValue);
applyIfSet(
style,
'minHeight',
element.galleryHeaderMinHeight,
normalizePxValue,
);
applyIfSet(
style,
'maxHeight',
element.galleryHeaderMaxHeight,
normalizePxValue,
);
return style;
}
@ -185,14 +227,36 @@ export function buildGalleryTitleStyle(
const defaults = GALLERY_SECTION_DEFAULTS.title;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.galleryTitleBackgroundColor, defaults.backgroundColor);
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, normalizeRemValue);
applyIfSet(
style,
'fontSize',
element.galleryTitleFontSize,
normalizeRemValue,
);
applyIfSet(style, 'fontWeight', element.galleryTitleFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.galleryTitlePadding, defaults.padding, normalizeRemValue);
applyWithDefault(style, 'borderRadius', element.galleryTitleBorderRadius, defaults.borderRadius, normalizeRemValue);
applyWithDefault(
style,
'padding',
element.galleryTitlePadding,
defaults.padding,
normalizeRemValue,
);
applyWithDefault(
style,
'borderRadius',
element.galleryTitleBorderRadius,
defaults.borderRadius,
normalizeRemValue,
);
applyIfSet(style, 'border', element.galleryTitleBorder); // Complex value, no normalization
// Apply font family with font library resolution
@ -218,18 +282,36 @@ export function buildGallerySpanStyle(
const defaults = GALLERY_SECTION_DEFAULTS.span;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.gallerySpanBackgroundColor, defaults.backgroundColor);
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, normalizeRemValue);
applyIfSet(style, 'fontWeight', element.gallerySpanFontWeight);
// Non-inheritable properties: use defaults
applyWithDefault(style, 'padding', element.gallerySpanPadding, defaults.padding, normalizeRemValue);
applyWithDefault(style, 'borderRadius', element.gallerySpanBorderRadius, defaults.borderRadius, normalizeRemValue);
applyWithDefault(
style,
'padding',
element.gallerySpanPadding,
defaults.padding,
normalizeRemValue,
);
applyWithDefault(
style,
'borderRadius',
element.gallerySpanBorderRadius,
defaults.borderRadius,
normalizeRemValue,
);
applyIfSet(style, 'border', element.gallerySpanBorder); // Complex value, no normalization
// Apply font family with font library resolution (fallback to galleryTextFontFamily for legacy support)
const fontKey = element.gallerySpanFontFamily || element.galleryTextFontFamily;
const fontKey =
element.gallerySpanFontFamily || element.galleryTextFontFamily;
if (fontKey) {
const font = getFontByKey(fontKey);
if (font) {
@ -267,10 +349,32 @@ export function buildGalleryCardStyle(
const defaults = GALLERY_SECTION_DEFAULTS.card;
const style: CSSProperties = {};
applyWithDefault(style, 'backgroundColor', element.galleryCardBackgroundColor, undefined);
applyWithDefault(style, 'borderRadius', element.galleryCardBorderRadius, defaults.borderRadius, normalizeRemValue);
applyWithDefault(
style,
'backgroundColor',
element.galleryCardBackgroundColor,
undefined,
);
applyWithDefault(
style,
'borderRadius',
element.galleryCardBorderRadius,
defaults.borderRadius,
normalizeRemValue,
);
applyIfSet(style, 'border', element.galleryCardBorder); // Complex value, no normalization
// Dimension properties - only apply if explicitly set
applyIfSet(style, 'width', element.galleryCardWidth);
applyIfSet(style, 'height', element.galleryCardHeight, normalizePxValue);
applyIfSet(
style,
'minHeight',
element.galleryCardMinHeight,
normalizePxValue,
);
applyIfSet(style, 'aspectRatio', element.galleryCardAspectRatio);
return style;
}
@ -285,7 +389,12 @@ export function buildGalleryCardTitleStyle(
// 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, normalizeRemValue);
applyIfSet(
style,
'fontSize',
element.galleryCardTitleFontSize,
normalizeRemValue,
);
applyIfSet(style, 'fontWeight', element.galleryCardTitleFontWeight);
if (element.galleryCardTitleBackgroundColor) {
@ -298,7 +407,8 @@ export function buildGalleryCardTitleStyle(
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))';
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
@ -359,6 +469,10 @@ export const GALLERY_SECTION_STYLE_PROPS = [
'galleryHeaderPadding',
'galleryHeaderBorderRadius',
'galleryHeaderBorder',
'galleryHeaderWidth',
'galleryHeaderHeight',
'galleryHeaderMinHeight',
'galleryHeaderMaxHeight',
// Title
'galleryTitleBackgroundColor',
'galleryTitleColor',
@ -390,6 +504,11 @@ export const GALLERY_SECTION_STYLE_PROPS = [
'galleryCardTitleFontSize',
'galleryCardTitleFontWeight',
'galleryCardTitleShadow',
'galleryCardWidth',
'galleryCardHeight',
'galleryCardMinHeight',
'galleryCardAspectRatio',
] as const;
export type GallerySectionStyleProp = (typeof GALLERY_SECTION_STYLE_PROPS)[number];
export type GallerySectionStyleProp =
(typeof GALLERY_SECTION_STYLE_PROPS)[number];

View File

@ -283,15 +283,31 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' />
<meta key='og:title' property='og:title' content={title} />
<meta key='og:description' property='og:description' content={description} />
<meta
key='og:description'
property='og:description'
content={description}
/>
<meta key='og:image' property='og:image' content={image} />
<meta property='og:image:type' content='image/png' />
<meta property='og:image:width' content={imageWidth} />
<meta property='og:image:height' content={imageHeight} />
<meta property='twitter:card' content='summary_large_image' />
<meta key='twitter:title' property='twitter:title' content={title} />
<meta key='twitter:description' property='twitter:description' content={description} />
<meta key='twitter:image:src' property='twitter:image:src' content={image} />
<meta
key='twitter:title'
property='twitter:title'
content={title}
/>
<meta
key='twitter:description'
property='twitter:description'
content={description}
/>
<meta
key='twitter:image:src'
property='twitter:image:src'
content={image}
/>
<meta property='twitter:image:width' content={imageWidth} />
<meta property='twitter:image:height' content={imageHeight} />
<link key='favicon' rel='icon' href='/favicon.svg' />

View File

@ -1164,7 +1164,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Update the active carousel element to reflect the new positions
setActiveGalleryCarousel((prev) =>
prev ? { ...prev, element: { ...prev.element, ...positionPatch } } : null,
prev
? { ...prev, element: { ...prev.element, ...positionPatch } }
: null,
);
},
[activeGalleryCarousel, updateSelectedElement],
@ -1459,15 +1461,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
}
nextIconUrl={
activeGalleryCarousel.element.galleryCarouselNextIconUrl
}
backIconUrl={
activeGalleryCarousel.element.galleryCarouselBackIconUrl
}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
}

View File

@ -363,7 +363,9 @@ const ElementTypeDefaultDetailsPage = () => {
<CarouselSettingsSection
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
carouselNextIconUrl={form.state.carouselNextIconUrl}
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
carouselCaptionFontFamily={
form.state.carouselCaptionFontFamily
}
carouselSlides={form.state.carouselSlides}
onAddSlide={form.addCarouselSlide}
onRemoveSlide={form.removeCarouselSlide}

View File

@ -548,7 +548,9 @@ const ProjectElementDefaultDetailsPage = () => {
<CarouselSettingsSection
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
carouselNextIconUrl={form.state.carouselNextIconUrl}
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
carouselCaptionFontFamily={
form.state.carouselCaptionFontFamily
}
carouselSlides={form.state.carouselSlides}
onAddSlide={form.addCarouselSlide}
onRemoveSlide={form.removeCarouselSlide}

View File

@ -226,13 +226,18 @@ const EditProjectsPage = () => {
: 'Select logo from Assets'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
<option
key={asset.id}
value={asset.storage_key || asset.cdn_url}
>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.logo_url &&
!logoAssets.some(
(asset) => (asset.storage_key || asset.cdn_url) === values.logo_url,
(asset) =>
(asset.storage_key || asset.cdn_url) ===
values.logo_url,
) && (
<option value={values.logo_url}>
{values.logo_url}
@ -259,13 +264,18 @@ const EditProjectsPage = () => {
: 'Select favicon from Assets logos'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
<option
key={asset.id}
value={asset.storage_key || asset.cdn_url}
>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.favicon_url &&
!logoAssets.some(
(asset) => (asset.storage_key || asset.cdn_url) === values.favicon_url,
(asset) =>
(asset.storage_key || asset.cdn_url) ===
values.favicon_url,
) && (
<option value={values.favicon_url}>
{values.favicon_url}
@ -292,13 +302,18 @@ const EditProjectsPage = () => {
: 'Select OG image from Assets logos'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
<option
key={asset.id}
value={asset.storage_key || asset.cdn_url}
>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.og_image_url &&
!logoAssets.some(
(asset) => (asset.storage_key || asset.cdn_url) === values.og_image_url,
(asset) =>
(asset.storage_key || asset.cdn_url) ===
values.og_image_url,
) && (
<option value={values.og_image_url}>
{values.og_image_url}

View File

@ -110,6 +110,11 @@ export interface CanvasElement extends BaseCanvasElement {
galleryHeaderPadding?: string;
galleryHeaderBorderRadius?: string;
galleryHeaderBorder?: string;
// Gallery Section Styles - Header Dimensions
galleryHeaderWidth?: string;
galleryHeaderHeight?: string;
galleryHeaderMinHeight?: string;
galleryHeaderMaxHeight?: string;
// Gallery Section Styles - Title
galleryTitleBackgroundColor?: string;
galleryTitleColor?: string;
@ -140,6 +145,11 @@ export interface CanvasElement extends BaseCanvasElement {
galleryCardTitleFontSize?: string;
galleryCardTitleFontWeight?: string;
galleryCardTitleShadow?: string;
// Gallery Section Styles - Card Dimensions
galleryCardWidth?: string;
galleryCardHeight?: string;
galleryCardMinHeight?: string;
galleryCardAspectRatio?: string;
// Gallery Section Styles - Wrapper
galleryWrapperBackgroundColor?: string;
galleryWrapperPadding?: string;