Autosave: 20260319-081925

This commit is contained in:
Flatlogic Bot 2026-03-19 08:19:25 +00:00
parent afabb0cce1
commit ce847e87d6
3 changed files with 641 additions and 91 deletions

View File

@ -84,6 +84,28 @@ type CanvasElement = {
label: string; label: string;
xPercent: number; xPercent: number;
yPercent: number; yPercent: number;
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string;
padding?: string;
gap?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
border?: string;
borderRadius?: string;
opacity?: string;
boxShadow?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: string;
appearDelaySec?: number; appearDelaySec?: number;
appearDurationSec?: number | null; appearDurationSec?: number | null;
iconUrl?: string; iconUrl?: string;
@ -193,6 +215,11 @@ const normalizeAppearDurationSec = (value: unknown) => {
return Number(parsed); return Number(parsed);
}; };
const getTrimmedCssValue = (value: unknown) => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
const createLocalId = () => { const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) { if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID(); return window.crypto.randomUUID();
@ -529,12 +556,14 @@ const createDefaultElement = (
const mergeElementWithDefaults = ( const mergeElementWithDefaults = (
element: CanvasElement, element: CanvasElement,
defaults?: Partial<CanvasElement>, defaults?: Partial<CanvasElement>,
options?: { preferElementValues?: boolean },
): CanvasElement => { ): CanvasElement => {
if (!defaults) return element; if (!defaults) return element;
const preferElementValues = Boolean(options?.preferElementValues);
const merged: CanvasElement = { const merged: CanvasElement = {
...element, ...(preferElementValues ? defaults : element),
...defaults, ...(preferElementValues ? element : defaults),
id: element.id, id: element.id,
type: element.type, type: element.type,
}; };
@ -545,9 +574,13 @@ const mergeElementWithDefaults = (
merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec); merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec);
if (merged.type === 'gallery') { if (merged.type === 'gallery') {
const cards = Array.isArray(defaults.galleryCards) const cards = preferElementValues
? defaults.galleryCards ? Array.isArray(element.galleryCards)
: element.galleryCards || []; ? element.galleryCards
: defaults.galleryCards || []
: Array.isArray(defaults.galleryCards)
? defaults.galleryCards
: element.galleryCards || [];
merged.galleryCards = cards.map((card, cardIndex) => ({ merged.galleryCards = cards.map((card, cardIndex) => ({
id: String(card?.id || createLocalId()), id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl || ''), imageUrl: String(card?.imageUrl || ''),
@ -557,9 +590,13 @@ const mergeElementWithDefaults = (
} }
if (merged.type === 'carousel') { if (merged.type === 'carousel') {
const slides = Array.isArray(defaults.carouselSlides) const slides = preferElementValues
? defaults.carouselSlides ? Array.isArray(element.carouselSlides)
: element.carouselSlides || []; ? element.carouselSlides
: defaults.carouselSlides || []
: Array.isArray(defaults.carouselSlides)
? defaults.carouselSlides
: element.carouselSlides || [];
merged.carouselSlides = slides.map((slide, slideIndex) => ({ merged.carouselSlides = slides.map((slide, slideIndex) => ({
id: String(slide?.id || createLocalId()), id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl || ''), imageUrl: String(slide?.imageUrl || ''),
@ -582,7 +619,10 @@ const getElementButtonTitle = (element: CanvasElement) => {
if (element.type === 'tooltip') return element.tooltipTitle ?? ''; if (element.type === 'tooltip') return element.tooltipTitle ?? '';
if (element.type === 'description') return element.descriptionTitle ?? ''; if (element.type === 'description') return element.descriptionTitle ?? '';
if (element.type === 'navigation_next' || element.type === 'navigation_prev') if (element.type === 'navigation_next' || element.type === 'navigation_prev')
return element.navLabel ?? ''; return (
element.navLabel?.trim() ||
getNavigationButtonLabel(element.type as NavigationElementType)
);
if ( if (
(element.type === 'video_player' || element.type === 'audio_player') && (element.type === 'video_player' || element.type === 'audio_player') &&
element.mediaUrl element.mediaUrl
@ -593,6 +633,65 @@ const getElementButtonTitle = (element: CanvasElement) => {
return element.label; return element.label;
}; };
const buildCanvasElementStyle = (
element: CanvasElement,
): React.CSSProperties => {
const style: React.CSSProperties = {};
const width = getTrimmedCssValue(element.width);
if (width) style.width = width;
const height = getTrimmedCssValue(element.height);
if (height) style.height = height;
const minWidth = getTrimmedCssValue(element.minWidth);
if (minWidth) style.minWidth = minWidth;
const maxWidth = getTrimmedCssValue(element.maxWidth);
if (maxWidth) style.maxWidth = maxWidth;
const minHeight = getTrimmedCssValue(element.minHeight);
if (minHeight) style.minHeight = minHeight;
const maxHeight = getTrimmedCssValue(element.maxHeight);
if (maxHeight) style.maxHeight = maxHeight;
const margin = getTrimmedCssValue(element.margin);
if (margin) style.margin = margin;
const padding = getTrimmedCssValue(element.padding);
if (padding) style.padding = padding;
const gap = getTrimmedCssValue(element.gap);
if (gap) style.gap = gap;
const fontSize = getTrimmedCssValue(element.fontSize);
if (fontSize) style.fontSize = fontSize;
const lineHeight = getTrimmedCssValue(element.lineHeight);
if (lineHeight) style.lineHeight = lineHeight;
const fontWeight = getTrimmedCssValue(element.fontWeight);
if (fontWeight) style.fontWeight = fontWeight as React.CSSProperties['fontWeight'];
const border = getTrimmedCssValue(element.border);
if (border) style.border = border;
const borderRadius = getTrimmedCssValue(element.borderRadius);
if (borderRadius) style.borderRadius = borderRadius;
const opacity = getTrimmedCssValue(element.opacity);
if (opacity) {
const parsed = Number(opacity);
if (Number.isFinite(parsed)) style.opacity = parsed;
}
const boxShadow = getTrimmedCssValue(element.boxShadow);
if (boxShadow) style.boxShadow = boxShadow;
const display = getTrimmedCssValue(element.display);
if (display) style.display = display;
const position = getTrimmedCssValue(element.position);
if (position) style.position = position as React.CSSProperties['position'];
const justifyContent = getTrimmedCssValue(element.justifyContent);
if (justifyContent)
style.justifyContent = justifyContent as React.CSSProperties['justifyContent'];
const alignItems = getTrimmedCssValue(element.alignItems);
if (alignItems)
style.alignItems = alignItems as React.CSSProperties['alignItems'];
const textAlign = getTrimmedCssValue(element.textAlign);
if (textAlign) style.textAlign = textAlign as React.CSSProperties['textAlign'];
const zIndex = getTrimmedCssValue(element.zIndex);
if (zIndex) {
const parsed = Number(zIndex);
if (Number.isFinite(parsed)) style.zIndex = parsed;
}
return style;
};
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const router = useRouter(); const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
@ -1104,86 +1203,98 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
(item) => (item) =>
item && item.type && labelByType[item.type as CanvasElementType], item && item.type && labelByType[item.type as CanvasElementType],
) )
.map((item) => ({ .map((item) => {
...item, const elementType = item.type as CanvasElementType;
id: String(item.id || createLocalId()), const normalizedElement: CanvasElement = {
label: labelByType[item.type as CanvasElementType], ...item,
xPercent: clamp(Number(item.xPercent || 0), 0, 100), id: String(item.id || createLocalId()),
yPercent: clamp(Number(item.yPercent || 0), 0, 100), label:
appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec), typeof item.label === 'string' && item.label.trim()
appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec), ? item.label
galleryCards: Array.isArray(item.galleryCards) : labelByType[elementType],
? item.galleryCards.map((card: any, index: number) => ({ xPercent: clamp(Number(item.xPercent || 0), 0, 100),
id: String(card?.id || createLocalId()), yPercent: clamp(Number(item.yPercent || 0), 0, 100),
imageUrl: String(card?.imageUrl || ''), appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec),
title: String(card?.title || `Card ${index + 1}`), appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec),
description: String(card?.description || ''), galleryCards: Array.isArray(item.galleryCards)
})) ? item.galleryCards.map((card: any, index: number) => ({
: undefined, id: String(card?.id || createLocalId()),
carouselSlides: Array.isArray(item.carouselSlides) imageUrl: String(card?.imageUrl || ''),
? item.carouselSlides.map((slide: any, index: number) => ({ title: String(card?.title || `Card ${index + 1}`),
id: String(slide?.id || createLocalId()), description: String(card?.description || ''),
imageUrl: String(slide?.imageUrl || ''), }))
caption: String(slide?.caption || `Slide ${index + 1}`), : undefined,
})) carouselSlides: Array.isArray(item.carouselSlides)
: undefined, ? item.carouselSlides.map((slide: any, index: number) => ({
iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '', id: String(slide?.id || createLocalId()),
carouselPrevIconUrl: imageUrl: String(slide?.imageUrl || ''),
typeof item.carouselPrevIconUrl === 'string' caption: String(slide?.caption || `Slide ${index + 1}`),
? item.carouselPrevIconUrl }))
: '', : undefined,
carouselNextIconUrl: iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '',
typeof item.carouselNextIconUrl === 'string' carouselPrevIconUrl:
? item.carouselNextIconUrl typeof item.carouselPrevIconUrl === 'string'
: '', ? item.carouselPrevIconUrl
tooltipTitle: : '',
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '', carouselNextIconUrl:
tooltipText: typeof item.carouselNextIconUrl === 'string'
typeof item.tooltipText === 'string' ? item.tooltipText : '', ? item.carouselNextIconUrl
descriptionTitle: : '',
typeof item.descriptionTitle === 'string' tooltipTitle:
? item.descriptionTitle typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
: '', tooltipText:
descriptionText: typeof item.tooltipText === 'string' ? item.tooltipText : '',
typeof item.descriptionText === 'string' descriptionTitle:
? item.descriptionText typeof item.descriptionTitle === 'string'
: '', ? item.descriptionTitle
navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', : '',
navType: descriptionText:
item.navType === 'back' || item.navType === 'forward' typeof item.descriptionText === 'string'
? item.navType ? item.descriptionText
: isNavigationElementType(item.type as CanvasElementType) : '',
? getNavigationButtonKind(item.type as NavigationElementType) navLabel: typeof item.navLabel === 'string' ? item.navLabel : '',
: undefined, navType:
targetPageId: item.navType === 'back' || item.navType === 'forward'
typeof item.targetPageId === 'string' ? item.targetPageId : '', ? item.navType
transitionVideoUrl: : isNavigationElementType(elementType)
typeof item.transitionVideoUrl === 'string' ? getNavigationButtonKind(elementType as NavigationElementType)
? item.transitionVideoUrl : undefined,
: '', targetPageId:
transitionReverseMode: typeof item.targetPageId === 'string' ? item.targetPageId : '',
item.transitionReverseMode === 'separate_video' transitionVideoUrl:
? 'separate_video' typeof item.transitionVideoUrl === 'string'
: ('auto_reverse' as const), ? item.transitionVideoUrl
reverseVideoUrl: : '',
typeof item.reverseVideoUrl === 'string' transitionReverseMode:
? item.reverseVideoUrl item.transitionReverseMode === 'separate_video'
: '', ? 'separate_video'
transitionDurationSec: item.transitionDurationSec : ('auto_reverse' as const),
? Number(item.transitionDurationSec) reverseVideoUrl:
: undefined, typeof item.reverseVideoUrl === 'string'
mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '', ? item.reverseVideoUrl
mediaAutoplay: : '',
typeof item.mediaAutoplay === 'boolean' transitionDurationSec: item.transitionDurationSec
? item.mediaAutoplay ? Number(item.transitionDurationSec)
: true, : undefined,
mediaLoop: mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '',
typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true, mediaAutoplay:
mediaMuted: typeof item.mediaAutoplay === 'boolean'
typeof item.mediaMuted === 'boolean' ? item.mediaAutoplay
? item.mediaMuted : true,
: item.type === 'video_player', mediaLoop:
})) typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true,
mediaMuted:
typeof item.mediaMuted === 'boolean'
? item.mediaMuted
: item.type === 'video_player',
};
return mergeElementWithDefaults(
normalizedElement,
uiElementDefaultsByType[elementType],
{ preferElementValues: true },
);
})
: []; : [];
setElements(normalizedElements); setElements(normalizedElements);
@ -1203,7 +1314,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundVideoUrl(activePage.background_video_url || '');
setBackgroundAudioUrl(activePage.background_audio_url || ''); setBackgroundAudioUrl(activePage.background_audio_url || '');
}, [activePage, elementIdFromRoute]); }, [activePage, elementIdFromRoute, uiElementDefaultsByType]);
useEffect(() => { useEffect(() => {
if (allowedNavigationTypes.length !== 1) return; if (allowedNavigationTypes.length !== 1) return;
@ -1697,6 +1808,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
element.type === 'navigation_next' || element.type === 'navigation_next' ||
element.type === 'navigation_prev' element.type === 'navigation_prev'
) { ) {
const fallbackNavLabel = getNavigationButtonLabel(element.type);
const navigationLabel = element.navLabel?.trim() || fallbackNavLabel;
if (element.iconUrl) { if (element.iconUrl) {
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -1714,7 +1827,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return ( return (
<div className='flex flex-col items-start gap-1'> <div className='flex flex-col items-start gap-1'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span>{element.navLabel}</span> <span>{navigationLabel}</span>
</div> </div>
{targetPageName ? ( {targetPageName ? (
<span className='text-[10px] text-gray-500'> <span className='text-[10px] text-gray-500'>
@ -2189,6 +2302,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: 'border-blue-200 bg-white/95' : 'border-blue-200 bg-white/95'
}`} }`}
style={{ style={{
...buildCanvasElementStyle(element),
left: `${element.xPercent}%`, left: `${element.xPercent}%`,
top: `${element.yPercent}%`, top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',

View File

@ -47,6 +47,28 @@ type ConstructorElement = {
label?: string; label?: string;
xPercent?: number; xPercent?: number;
yPercent?: number; yPercent?: number;
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string;
padding?: string;
gap?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
border?: string;
borderRadius?: string;
opacity?: string;
boxShadow?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: string;
appearDelaySec?: number; appearDelaySec?: number;
appearDurationSec?: number | null; appearDurationSec?: number | null;
iconUrl?: string; iconUrl?: string;
@ -113,6 +135,31 @@ const parseNullableNumber = (value: string) => {
return parsed; return parsed;
}; };
const extractNumericValue = (value: unknown) => {
const normalized = String(value ?? '').trim();
if (!normalized) return '';
const matched = normalized.match(/-?\d*\.?\d+/);
return matched ? matched[0] : '';
};
const normalizeNumberString = (value: string) => {
const normalized = String(value || '').trim();
if (!normalized) return '';
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return '';
return String(parsed);
};
const toOptionalTrimmed = (value: string) => {
const normalized = String(value || '').trim();
return normalized ? normalized : undefined;
};
const toUnitValue = (value: string, unit: 'vw' | 'vh' | 'px') => {
const normalized = normalizeNumberString(value);
return normalized ? `${normalized}${unit}` : undefined;
};
const createLocalId = () => { const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) { if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID(); return window.crypto.randomUUID();
@ -141,6 +188,28 @@ const PageElementsProjectEditPage = () => {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [xPercent, setXPercent] = useState('0'); const [xPercent, setXPercent] = useState('0');
const [yPercent, setYPercent] = useState('0'); const [yPercent, setYPercent] = useState('0');
const [width, setWidth] = useState('');
const [height, setHeight] = useState('');
const [minWidth, setMinWidth] = useState('');
const [maxWidth, setMaxWidth] = useState('');
const [minHeight, setMinHeight] = useState('');
const [maxHeight, setMaxHeight] = useState('');
const [margin, setMargin] = useState('');
const [padding, setPadding] = useState('');
const [gap, setGap] = useState('');
const [fontSize, setFontSize] = useState('');
const [lineHeight, setLineHeight] = useState('');
const [fontWeight, setFontWeight] = useState('');
const [border, setBorder] = useState('');
const [borderRadius, setBorderRadius] = useState('');
const [opacity, setOpacity] = useState('');
const [boxShadow, setBoxShadow] = useState('');
const [display, setDisplay] = useState('');
const [position, setPosition] = useState('');
const [justifyContent, setJustifyContent] = useState('');
const [alignItems, setAlignItems] = useState('');
const [textAlign, setTextAlign] = useState('');
const [zIndex, setZIndex] = useState('');
const [appearDelaySec, setAppearDelaySec] = useState('0'); const [appearDelaySec, setAppearDelaySec] = useState('0');
const [appearDurationSec, setAppearDurationSec] = useState(''); const [appearDurationSec, setAppearDurationSec] = useState('');
const [iconUrl, setIconUrl] = useState(''); const [iconUrl, setIconUrl] = useState('');
@ -168,6 +237,28 @@ const PageElementsProjectEditPage = () => {
setLabel(String(item.label || '')); setLabel(String(item.label || ''));
setXPercent(String(item.xPercent ?? 0)); setXPercent(String(item.xPercent ?? 0));
setYPercent(String(item.yPercent ?? 0)); setYPercent(String(item.yPercent ?? 0));
setWidth(extractNumericValue(item.width));
setHeight(extractNumericValue(item.height));
setMinWidth(extractNumericValue(item.minWidth));
setMaxWidth(extractNumericValue(item.maxWidth));
setMinHeight(extractNumericValue(item.minHeight));
setMaxHeight(extractNumericValue(item.maxHeight));
setMargin(String(item.margin || ''));
setPadding(String(item.padding || ''));
setGap(String(item.gap || ''));
setFontSize(String(item.fontSize || ''));
setLineHeight(String(item.lineHeight || ''));
setFontWeight(String(item.fontWeight || ''));
setBorder(extractNumericValue(item.border));
setBorderRadius(extractNumericValue(item.borderRadius));
setOpacity(item.opacity === '0' || item.opacity === 0 ? '0' : String(item.opacity || ''));
setBoxShadow(String(item.boxShadow || ''));
setDisplay(String(item.display || ''));
setPosition(String(item.position || ''));
setJustifyContent(String(item.justifyContent || ''));
setAlignItems(String(item.alignItems || ''));
setTextAlign(String(item.textAlign || ''));
setZIndex(String(item.zIndex || ''));
setAppearDelaySec(String(item.appearDelaySec ?? 0)); setAppearDelaySec(String(item.appearDelaySec ?? 0));
setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec)); setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec));
setIconUrl(String(item.iconUrl || '')); setIconUrl(String(item.iconUrl || ''));
@ -316,15 +407,62 @@ const PageElementsProjectEditPage = () => {
setSuccessMessage(''); setSuccessMessage('');
try { try {
const borderWidthValue = toUnitValue(border, 'px');
const nextElement: ConstructorElement = { const nextElement: ConstructorElement = {
...element, ...element,
label: label.trim(), label: label.trim(),
xPercent: clampPercent(xPercent), xPercent: clampPercent(xPercent),
yPercent: clampPercent(yPercent), yPercent: clampPercent(yPercent),
border: borderWidthValue ? `${borderWidthValue} solid currentColor` : 'none',
borderRadius: toUnitValue(borderRadius, 'px') || '0px',
appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0, appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0,
appearDurationSec: parseNullableNumber(appearDurationSec), appearDurationSec: parseNullableNumber(appearDurationSec),
}; };
const widthValue = toUnitValue(width, 'vw');
const heightValue = toUnitValue(height, 'vh');
const minWidthValue = toUnitValue(minWidth, 'vw');
const maxWidthValue = toUnitValue(maxWidth, 'vw');
const minHeightValue = toUnitValue(minHeight, 'vh');
const maxHeightValue = toUnitValue(maxHeight, 'vh');
if (widthValue) nextElement.width = widthValue; else delete nextElement.width;
if (heightValue) nextElement.height = heightValue; else delete nextElement.height;
if (minWidthValue) nextElement.minWidth = minWidthValue; else delete nextElement.minWidth;
if (maxWidthValue) nextElement.maxWidth = maxWidthValue; else delete nextElement.maxWidth;
if (minHeightValue) nextElement.minHeight = minHeightValue; else delete nextElement.minHeight;
if (maxHeightValue) nextElement.maxHeight = maxHeightValue; else delete nextElement.maxHeight;
const marginValue = toOptionalTrimmed(margin);
const paddingValue = toOptionalTrimmed(padding);
const gapValue = toOptionalTrimmed(gap);
const fontSizeValue = toOptionalTrimmed(fontSize);
const lineHeightValue = toOptionalTrimmed(lineHeight);
const fontWeightValue = toOptionalTrimmed(fontWeight);
const opacityValue = toOptionalTrimmed(opacity);
const boxShadowValue = toOptionalTrimmed(boxShadow);
const displayValue = toOptionalTrimmed(display);
const positionValue = toOptionalTrimmed(position);
const justifyContentValue = toOptionalTrimmed(justifyContent);
const alignItemsValue = toOptionalTrimmed(alignItems);
const textAlignValue = toOptionalTrimmed(textAlign);
const zIndexValue = toOptionalTrimmed(zIndex);
if (marginValue) nextElement.margin = marginValue; else delete nextElement.margin;
if (paddingValue) nextElement.padding = paddingValue; else delete nextElement.padding;
if (gapValue) nextElement.gap = gapValue; else delete nextElement.gap;
if (fontSizeValue) nextElement.fontSize = fontSizeValue; else delete nextElement.fontSize;
if (lineHeightValue) nextElement.lineHeight = lineHeightValue; else delete nextElement.lineHeight;
if (fontWeightValue) nextElement.fontWeight = fontWeightValue; else delete nextElement.fontWeight;
if (opacityValue) nextElement.opacity = opacityValue; else delete nextElement.opacity;
if (boxShadowValue) nextElement.boxShadow = boxShadowValue; else delete nextElement.boxShadow;
if (displayValue) nextElement.display = displayValue; else delete nextElement.display;
if (positionValue) nextElement.position = positionValue; else delete nextElement.position;
if (justifyContentValue) nextElement.justifyContent = justifyContentValue; else delete nextElement.justifyContent;
if (alignItemsValue) nextElement.alignItems = alignItemsValue; else delete nextElement.alignItems;
if (textAlignValue) nextElement.textAlign = textAlignValue; else delete nextElement.textAlign;
if (zIndexValue) nextElement.zIndex = zIndexValue; else delete nextElement.zIndex;
if (isNavigationType) { if (isNavigationType) {
nextElement.iconUrl = iconUrl.trim(); nextElement.iconUrl = iconUrl.trim();
nextElement.navLabel = navLabel.trim(); nextElement.navLabel = navLabel.trim();
@ -515,6 +653,89 @@ const PageElementsProjectEditPage = () => {
</div> </div>
</CardBox> </CardBox>
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>View & stylization</h3>
<p className='mb-3 text-xs text-gray-500'>
Fill numbers only: width is saved as vw, height as vh, border and radius as px.
</p>
<div className='grid gap-4 md:grid-cols-2'>
<FormField label='Width (vw)'>
<input type='number' step='0.1' min='0' value={width} onChange={(event) => setWidth(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 24' />
</FormField>
<FormField label='Height (vh)'>
<input type='number' step='0.1' min='0' value={height} onChange={(event) => setHeight(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 8' />
</FormField>
<FormField label='Min width (vw)'><input type='number' step='0.1' min='0' value={minWidth} onChange={(event) => setMinWidth(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Max width (vw)'><input type='number' step='0.1' min='0' value={maxWidth} onChange={(event) => setMaxWidth(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Min height (vh)'><input type='number' step='0.1' min='0' value={minHeight} onChange={(event) => setMinHeight(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Max height (vh)'><input type='number' step='0.1' min='0' value={maxHeight} onChange={(event) => setMaxHeight(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Margin'><input value={margin} onChange={(event) => setMargin(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0 auto / 0.5rem' /></FormField>
<FormField label='Padding'><input value={padding} onChange={(event) => setPadding(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0.5rem 0.75rem' /></FormField>
<FormField label='Gap'><input value={gap} onChange={(event) => setGap(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Font size'><input value={fontSize} onChange={(event) => setFontSize(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0.875rem / clamp(...)' /></FormField>
<FormField label='Line height'><input value={lineHeight} onChange={(event) => setLineHeight(event.target.value)} disabled={!hasUpdatePermission} /></FormField>
<FormField label='Font weight'><input value={fontWeight} onChange={(event) => setFontWeight(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 500 / bold' /></FormField>
<FormField label='Border width (px)'><input type='number' step='1' min='0' value={border} onChange={(event) => setBorder(event.target.value)} disabled={!hasUpdatePermission} placeholder='empty = none' /></FormField>
<FormField label='Border radius (px)'><input type='number' step='1' min='0' value={borderRadius} onChange={(event) => setBorderRadius(event.target.value)} disabled={!hasUpdatePermission} placeholder='empty = 0' /></FormField>
<FormField label='Opacity'><input value={opacity} onChange={(event) => setOpacity(event.target.value)} disabled={!hasUpdatePermission} placeholder='0..1' /></FormField>
<FormField label='Shadow'><input value={boxShadow} onChange={(event) => setBoxShadow(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0 4px 12px rgba(...)' /></FormField>
<FormField label='Display'>
<select value={display} onChange={(event) => setDisplay(event.target.value)} disabled={!hasUpdatePermission}>
<option value=''>Not set</option>
<option value='block'>block</option>
<option value='inline-block'>inline-block</option>
<option value='flex'>flex</option>
<option value='inline-flex'>inline-flex</option>
<option value='grid'>grid</option>
<option value='none'>none</option>
</select>
</FormField>
<FormField label='Position'>
<select value={position} onChange={(event) => setPosition(event.target.value)} disabled={!hasUpdatePermission}>
<option value=''>Not set</option>
<option value='static'>static</option>
<option value='relative'>relative</option>
<option value='absolute'>absolute</option>
<option value='fixed'>fixed</option>
<option value='sticky'>sticky</option>
</select>
</FormField>
<FormField label='Justify content'>
<select value={justifyContent} onChange={(event) => setJustifyContent(event.target.value)} disabled={!hasUpdatePermission}>
<option value=''>Not set</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='space-between'>space-between</option>
<option value='space-around'>space-around</option>
<option value='space-evenly'>space-evenly</option>
</select>
</FormField>
<FormField label='Align items'>
<select value={alignItems} onChange={(event) => setAlignItems(event.target.value)} disabled={!hasUpdatePermission}>
<option value=''>Not set</option>
<option value='stretch'>stretch</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='baseline'>baseline</option>
</select>
</FormField>
<FormField label='Text align'>
<select value={textAlign} onChange={(event) => setTextAlign(event.target.value)} disabled={!hasUpdatePermission}>
<option value=''>Not set</option>
<option value='left'>left</option>
<option value='center'>center</option>
<option value='right'>right</option>
<option value='justify'>justify</option>
</select>
</FormField>
<FormField label='z-index'>
<input value={zIndex} onChange={(event) => setZIndex(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 1 / 10' />
</FormField>
</div>
</CardBox>
{isNavigationType ? ( {isNavigationType ? (
<CardBox className='mb-4'> <CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>Navigation</h3> <h3 className='mb-3 text-sm font-semibold'>Navigation</h3>

View File

@ -77,6 +77,31 @@ const parseNullableNumber = (value: string) => {
return parsed; return parsed;
}; };
const extractNumericValue = (value: unknown) => {
const normalized = String(value ?? '').trim();
if (!normalized) return '';
const matched = normalized.match(/-?\d*\.?\d+/);
return matched ? matched[0] : '';
};
const normalizeNumberString = (value: string) => {
const normalized = String(value || '').trim();
if (!normalized) return '';
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return '';
return String(parsed);
};
const toOptionalTrimmed = (value: string) => {
const normalized = String(value || '').trim();
return normalized ? normalized : undefined;
};
const toUnitValue = (value: string, unit: 'vw' | 'vh' | 'px') => {
const normalized = normalizeNumberString(value);
return normalized ? `${normalized}${unit}` : undefined;
};
const createLocalId = () => { const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) { if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID(); return window.crypto.randomUUID();
@ -101,6 +126,28 @@ const UiElementDetailsPage = () => {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [xPercent, setXPercent] = useState('0'); const [xPercent, setXPercent] = useState('0');
const [yPercent, setYPercent] = useState('0'); const [yPercent, setYPercent] = useState('0');
const [width, setWidth] = useState('');
const [height, setHeight] = useState('');
const [minWidth, setMinWidth] = useState('');
const [maxWidth, setMaxWidth] = useState('');
const [minHeight, setMinHeight] = useState('');
const [maxHeight, setMaxHeight] = useState('');
const [margin, setMargin] = useState('');
const [padding, setPadding] = useState('');
const [gap, setGap] = useState('');
const [fontSize, setFontSize] = useState('');
const [lineHeight, setLineHeight] = useState('');
const [fontWeight, setFontWeight] = useState('');
const [border, setBorder] = useState('');
const [borderRadius, setBorderRadius] = useState('');
const [opacity, setOpacity] = useState('');
const [boxShadow, setBoxShadow] = useState('');
const [display, setDisplay] = useState('');
const [position, setPosition] = useState('');
const [justifyContent, setJustifyContent] = useState('');
const [alignItems, setAlignItems] = useState('');
const [textAlign, setTextAlign] = useState('');
const [zIndex, setZIndex] = useState('');
const [appearDelaySec, setAppearDelaySec] = useState('0'); const [appearDelaySec, setAppearDelaySec] = useState('0');
const [appearDurationSec, setAppearDurationSec] = useState(''); const [appearDurationSec, setAppearDurationSec] = useState('');
const [iconUrl, setIconUrl] = useState(''); const [iconUrl, setIconUrl] = useState('');
@ -135,6 +182,28 @@ const UiElementDetailsPage = () => {
setLabel(String(settings.label || '')); setLabel(String(settings.label || ''));
setXPercent(String(settings.xPercent ?? 0)); setXPercent(String(settings.xPercent ?? 0));
setYPercent(String(settings.yPercent ?? 0)); setYPercent(String(settings.yPercent ?? 0));
setWidth(extractNumericValue(settings.width));
setHeight(extractNumericValue(settings.height));
setMinWidth(extractNumericValue(settings.minWidth));
setMaxWidth(extractNumericValue(settings.maxWidth));
setMinHeight(extractNumericValue(settings.minHeight));
setMaxHeight(extractNumericValue(settings.maxHeight));
setMargin(String(settings.margin || ''));
setPadding(String(settings.padding || ''));
setGap(String(settings.gap || ''));
setFontSize(String(settings.fontSize || ''));
setLineHeight(String(settings.lineHeight || ''));
setFontWeight(String(settings.fontWeight || ''));
setBorder(extractNumericValue(settings.border));
setBorderRadius(extractNumericValue(settings.borderRadius));
setOpacity(settings.opacity === 0 ? '0' : String(settings.opacity || ''));
setBoxShadow(String(settings.boxShadow || ''));
setDisplay(String(settings.display || ''));
setPosition(String(settings.position || ''));
setJustifyContent(String(settings.justifyContent || ''));
setAlignItems(String(settings.alignItems || ''));
setTextAlign(String(settings.textAlign || ''));
setZIndex(String(settings.zIndex || ''));
setAppearDelaySec(String(settings.appearDelaySec ?? 0)); setAppearDelaySec(String(settings.appearDelaySec ?? 0));
setAppearDurationSec(settings.appearDurationSec === null || settings.appearDurationSec === undefined ? '' : String(settings.appearDurationSec)); setAppearDurationSec(settings.appearDurationSec === null || settings.appearDurationSec === undefined ? '' : String(settings.appearDurationSec));
setIconUrl(String(settings.iconUrl || '')); setIconUrl(String(settings.iconUrl || ''));
@ -271,14 +340,61 @@ const UiElementDetailsPage = () => {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!id || !item) return; if (!id || !item) return;
const borderWidthValue = toUnitValue(border, 'px');
const defaultSettings: Record<string, any> = { const defaultSettings: Record<string, any> = {
label: label.trim(), label: label.trim(),
xPercent: clampPercent(xPercent), xPercent: clampPercent(xPercent),
yPercent: clampPercent(yPercent), yPercent: clampPercent(yPercent),
border: borderWidthValue ? `${borderWidthValue} solid currentColor` : 'none',
borderRadius: toUnitValue(borderRadius, 'px') || '0px',
appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0, appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0,
appearDurationSec: parseNullableNumber(appearDurationSec), appearDurationSec: parseNullableNumber(appearDurationSec),
}; };
const widthValue = toUnitValue(width, 'vw');
const heightValue = toUnitValue(height, 'vh');
const minWidthValue = toUnitValue(minWidth, 'vw');
const maxWidthValue = toUnitValue(maxWidth, 'vw');
const minHeightValue = toUnitValue(minHeight, 'vh');
const maxHeightValue = toUnitValue(maxHeight, 'vh');
if (widthValue) defaultSettings.width = widthValue;
if (heightValue) defaultSettings.height = heightValue;
if (minWidthValue) defaultSettings.minWidth = minWidthValue;
if (maxWidthValue) defaultSettings.maxWidth = maxWidthValue;
if (minHeightValue) defaultSettings.minHeight = minHeightValue;
if (maxHeightValue) defaultSettings.maxHeight = maxHeightValue;
const marginValue = toOptionalTrimmed(margin);
const paddingValue = toOptionalTrimmed(padding);
const gapValue = toOptionalTrimmed(gap);
const fontSizeValue = toOptionalTrimmed(fontSize);
const lineHeightValue = toOptionalTrimmed(lineHeight);
const fontWeightValue = toOptionalTrimmed(fontWeight);
const opacityValue = toOptionalTrimmed(opacity);
const boxShadowValue = toOptionalTrimmed(boxShadow);
const displayValue = toOptionalTrimmed(display);
const positionValue = toOptionalTrimmed(position);
const justifyContentValue = toOptionalTrimmed(justifyContent);
const alignItemsValue = toOptionalTrimmed(alignItems);
const textAlignValue = toOptionalTrimmed(textAlign);
const zIndexValue = toOptionalTrimmed(zIndex);
if (marginValue) defaultSettings.margin = marginValue;
if (paddingValue) defaultSettings.padding = paddingValue;
if (gapValue) defaultSettings.gap = gapValue;
if (fontSizeValue) defaultSettings.fontSize = fontSizeValue;
if (lineHeightValue) defaultSettings.lineHeight = lineHeightValue;
if (fontWeightValue) defaultSettings.fontWeight = fontWeightValue;
if (opacityValue) defaultSettings.opacity = opacityValue;
if (boxShadowValue) defaultSettings.boxShadow = boxShadowValue;
if (displayValue) defaultSettings.display = displayValue;
if (positionValue) defaultSettings.position = positionValue;
if (justifyContentValue) defaultSettings.justifyContent = justifyContentValue;
if (alignItemsValue) defaultSettings.alignItems = alignItemsValue;
if (textAlignValue) defaultSettings.textAlign = textAlignValue;
if (zIndexValue) defaultSettings.zIndex = zIndexValue;
if (isNavigationType) { if (isNavigationType) {
defaultSettings.iconUrl = iconUrl.trim(); defaultSettings.iconUrl = iconUrl.trim();
defaultSettings.navLabel = navLabel.trim(); defaultSettings.navLabel = navLabel.trim();
@ -365,8 +481,13 @@ const UiElementDetailsPage = () => {
carouselSlides, carouselSlides,
currentElementType, currentElementType,
descriptionText, descriptionText,
display,
descriptionTitle, descriptionTitle,
fontSize,
fontWeight,
gap,
galleryCards, galleryCards,
height,
iconUrl, iconUrl,
id, id,
isActive, isActive,
@ -377,25 +498,42 @@ const UiElementDetailsPage = () => {
isNavigationType, isNavigationType,
isTooltipType, isTooltipType,
item, item,
justifyContent,
label, label,
lineHeight,
loadItem, loadItem,
margin,
maxHeight,
maxWidth,
mediaAutoplay, mediaAutoplay,
mediaLoop, mediaLoop,
mediaMuted, mediaMuted,
mediaUrl, mediaUrl,
minHeight,
minWidth,
name, name,
navLabel, navLabel,
navType, navType,
opacity,
padding,
position,
reverseVideoUrl, reverseVideoUrl,
sortOrder, sortOrder,
textAlign,
targetPageId, targetPageId,
tooltipText, tooltipText,
tooltipTitle, tooltipTitle,
transitionDurationSec, transitionDurationSec,
transitionReverseMode, transitionReverseMode,
transitionVideoUrl, transitionVideoUrl,
width,
xPercent, xPercent,
yPercent, yPercent,
zIndex,
alignItems,
border,
borderRadius,
boxShadow,
]); ]);
return ( return (
@ -490,6 +628,83 @@ const UiElementDetailsPage = () => {
</FormField> </FormField>
</div> </div>
<CardBox className='border border-gray-200 dark:border-dark-700'>
<h3 className='mb-3 text-sm font-semibold'>View & stylization</h3>
<p className='mb-3 text-xs text-gray-500'>
Fill numbers only: width is saved as vw, height as vh, border and radius as px.
</p>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Width (vw)'><input type='number' step='0.1' min='0' value={width} onChange={(event) => setWidth(event.target.value)} placeholder='e.g. 24' /></FormField>
<FormField label='Height (vh)'><input type='number' step='0.1' min='0' value={height} onChange={(event) => setHeight(event.target.value)} placeholder='e.g. 8' /></FormField>
<FormField label='Min width (vw)'><input type='number' step='0.1' min='0' value={minWidth} onChange={(event) => setMinWidth(event.target.value)} /></FormField>
<FormField label='Max width (vw)'><input type='number' step='0.1' min='0' value={maxWidth} onChange={(event) => setMaxWidth(event.target.value)} /></FormField>
<FormField label='Min height (vh)'><input type='number' step='0.1' min='0' value={minHeight} onChange={(event) => setMinHeight(event.target.value)} /></FormField>
<FormField label='Max height (vh)'><input type='number' step='0.1' min='0' value={maxHeight} onChange={(event) => setMaxHeight(event.target.value)} /></FormField>
<FormField label='Margin'><input value={margin} onChange={(event) => setMargin(event.target.value)} placeholder='e.g. 0 auto / 0.5rem' /></FormField>
<FormField label='Padding'><input value={padding} onChange={(event) => setPadding(event.target.value)} placeholder='e.g. 0.5rem 0.75rem' /></FormField>
<FormField label='Gap'><input value={gap} onChange={(event) => setGap(event.target.value)} /></FormField>
<FormField label='Font size'><input value={fontSize} onChange={(event) => setFontSize(event.target.value)} placeholder='e.g. 0.875rem / clamp(...)' /></FormField>
<FormField label='Line height'><input value={lineHeight} onChange={(event) => setLineHeight(event.target.value)} /></FormField>
<FormField label='Font weight'><input value={fontWeight} onChange={(event) => setFontWeight(event.target.value)} placeholder='e.g. 500 / bold' /></FormField>
<FormField label='Border width (px)'><input type='number' step='1' min='0' value={border} onChange={(event) => setBorder(event.target.value)} placeholder='empty = none' /></FormField>
<FormField label='Border radius (px)'><input type='number' step='1' min='0' value={borderRadius} onChange={(event) => setBorderRadius(event.target.value)} placeholder='empty = 0' /></FormField>
<FormField label='Opacity'><input value={opacity} onChange={(event) => setOpacity(event.target.value)} placeholder='0..1' /></FormField>
<FormField label='Shadow'><input value={boxShadow} onChange={(event) => setBoxShadow(event.target.value)} placeholder='e.g. 0 4px 12px rgba(...)' /></FormField>
<FormField label='Display'>
<select value={display} onChange={(event) => setDisplay(event.target.value)}>
<option value=''>Not set</option>
<option value='block'>block</option>
<option value='inline-block'>inline-block</option>
<option value='flex'>flex</option>
<option value='inline-flex'>inline-flex</option>
<option value='grid'>grid</option>
<option value='none'>none</option>
</select>
</FormField>
<FormField label='Position'>
<select value={position} onChange={(event) => setPosition(event.target.value)}>
<option value=''>Not set</option>
<option value='static'>static</option>
<option value='relative'>relative</option>
<option value='absolute'>absolute</option>
<option value='fixed'>fixed</option>
<option value='sticky'>sticky</option>
</select>
</FormField>
<FormField label='Justify content'>
<select value={justifyContent} onChange={(event) => setJustifyContent(event.target.value)}>
<option value=''>Not set</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='space-between'>space-between</option>
<option value='space-around'>space-around</option>
<option value='space-evenly'>space-evenly</option>
</select>
</FormField>
<FormField label='Align items'>
<select value={alignItems} onChange={(event) => setAlignItems(event.target.value)}>
<option value=''>Not set</option>
<option value='stretch'>stretch</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='baseline'>baseline</option>
</select>
</FormField>
<FormField label='Text align'>
<select value={textAlign} onChange={(event) => setTextAlign(event.target.value)}>
<option value=''>Not set</option>
<option value='left'>left</option>
<option value='center'>center</option>
<option value='right'>right</option>
<option value='justify'>justify</option>
</select>
</FormField>
<FormField label='z-index'><input value={zIndex} onChange={(event) => setZIndex(event.target.value)} placeholder='e.g. 1 / 10' /></FormField>
</div>
</CardBox>
{isNavigationType ? ( {isNavigationType ? (
<> <>
<FormField label='Icon URL'> <FormField label='Icon URL'>