diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 3f3e87b..26fad9c 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -84,6 +84,28 @@ type CanvasElement = { label: string; xPercent: 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; appearDurationSec?: number | null; iconUrl?: string; @@ -193,6 +215,11 @@ const normalizeAppearDurationSec = (value: unknown) => { return Number(parsed); }; +const getTrimmedCssValue = (value: unknown) => { + if (value === null || value === undefined) return ''; + return String(value).trim(); +}; + const createLocalId = () => { if (typeof window !== 'undefined' && window.crypto?.randomUUID) { return window.crypto.randomUUID(); @@ -529,12 +556,14 @@ const createDefaultElement = ( const mergeElementWithDefaults = ( element: CanvasElement, defaults?: Partial, + options?: { preferElementValues?: boolean }, ): CanvasElement => { if (!defaults) return element; + const preferElementValues = Boolean(options?.preferElementValues); const merged: CanvasElement = { - ...element, - ...defaults, + ...(preferElementValues ? defaults : element), + ...(preferElementValues ? element : defaults), id: element.id, type: element.type, }; @@ -545,9 +574,13 @@ const mergeElementWithDefaults = ( merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec); if (merged.type === 'gallery') { - const cards = Array.isArray(defaults.galleryCards) - ? defaults.galleryCards - : element.galleryCards || []; + const cards = preferElementValues + ? Array.isArray(element.galleryCards) + ? element.galleryCards + : defaults.galleryCards || [] + : Array.isArray(defaults.galleryCards) + ? defaults.galleryCards + : element.galleryCards || []; merged.galleryCards = cards.map((card, cardIndex) => ({ id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl || ''), @@ -557,9 +590,13 @@ const mergeElementWithDefaults = ( } if (merged.type === 'carousel') { - const slides = Array.isArray(defaults.carouselSlides) - ? defaults.carouselSlides - : element.carouselSlides || []; + const slides = preferElementValues + ? Array.isArray(element.carouselSlides) + ? element.carouselSlides + : defaults.carouselSlides || [] + : Array.isArray(defaults.carouselSlides) + ? defaults.carouselSlides + : element.carouselSlides || []; merged.carouselSlides = slides.map((slide, slideIndex) => ({ id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl || ''), @@ -582,7 +619,10 @@ const getElementButtonTitle = (element: CanvasElement) => { if (element.type === 'tooltip') return element.tooltipTitle ?? ''; if (element.type === 'description') return element.descriptionTitle ?? ''; if (element.type === 'navigation_next' || element.type === 'navigation_prev') - return element.navLabel ?? ''; + return ( + element.navLabel?.trim() || + getNavigationButtonLabel(element.type as NavigationElementType) + ); if ( (element.type === 'video_player' || element.type === 'audio_player') && element.mediaUrl @@ -593,6 +633,65 @@ const getElementButtonTitle = (element: CanvasElement) => { 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 router = useRouter(); const canvasRef = useRef(null); @@ -1104,86 +1203,98 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { (item) => item && item.type && labelByType[item.type as CanvasElementType], ) - .map((item) => ({ - ...item, - id: String(item.id || createLocalId()), - label: labelByType[item.type as CanvasElementType], - xPercent: clamp(Number(item.xPercent || 0), 0, 100), - yPercent: clamp(Number(item.yPercent || 0), 0, 100), - appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec), - appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec), - galleryCards: Array.isArray(item.galleryCards) - ? item.galleryCards.map((card: any, index: number) => ({ - id: String(card?.id || createLocalId()), - imageUrl: String(card?.imageUrl || ''), - title: String(card?.title || `Card ${index + 1}`), - description: String(card?.description || ''), - })) - : undefined, - carouselSlides: Array.isArray(item.carouselSlides) - ? item.carouselSlides.map((slide: any, index: number) => ({ - id: String(slide?.id || createLocalId()), - imageUrl: String(slide?.imageUrl || ''), - caption: String(slide?.caption || `Slide ${index + 1}`), - })) - : undefined, - iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '', - carouselPrevIconUrl: - typeof item.carouselPrevIconUrl === 'string' - ? item.carouselPrevIconUrl - : '', - carouselNextIconUrl: - typeof item.carouselNextIconUrl === 'string' - ? item.carouselNextIconUrl - : '', - tooltipTitle: - typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '', - tooltipText: - typeof item.tooltipText === 'string' ? item.tooltipText : '', - descriptionTitle: - typeof item.descriptionTitle === 'string' - ? item.descriptionTitle - : '', - descriptionText: - typeof item.descriptionText === 'string' - ? item.descriptionText - : '', - navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', - navType: - item.navType === 'back' || item.navType === 'forward' - ? item.navType - : isNavigationElementType(item.type as CanvasElementType) - ? getNavigationButtonKind(item.type as NavigationElementType) - : undefined, - targetPageId: - typeof item.targetPageId === 'string' ? item.targetPageId : '', - transitionVideoUrl: - typeof item.transitionVideoUrl === 'string' - ? item.transitionVideoUrl - : '', - transitionReverseMode: - item.transitionReverseMode === 'separate_video' - ? 'separate_video' - : ('auto_reverse' as const), - reverseVideoUrl: - typeof item.reverseVideoUrl === 'string' - ? item.reverseVideoUrl - : '', - transitionDurationSec: item.transitionDurationSec - ? Number(item.transitionDurationSec) - : undefined, - mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '', - mediaAutoplay: - typeof item.mediaAutoplay === 'boolean' - ? item.mediaAutoplay - : true, - mediaLoop: - typeof item.mediaLoop === 'boolean' ? item.mediaLoop : true, - mediaMuted: - typeof item.mediaMuted === 'boolean' - ? item.mediaMuted - : item.type === 'video_player', - })) + .map((item) => { + const elementType = item.type as CanvasElementType; + const normalizedElement: CanvasElement = { + ...item, + id: String(item.id || createLocalId()), + label: + typeof item.label === 'string' && item.label.trim() + ? item.label + : labelByType[elementType], + xPercent: clamp(Number(item.xPercent || 0), 0, 100), + yPercent: clamp(Number(item.yPercent || 0), 0, 100), + appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec), + appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec), + galleryCards: Array.isArray(item.galleryCards) + ? item.galleryCards.map((card: any, index: number) => ({ + id: String(card?.id || createLocalId()), + imageUrl: String(card?.imageUrl || ''), + title: String(card?.title || `Card ${index + 1}`), + description: String(card?.description || ''), + })) + : undefined, + carouselSlides: Array.isArray(item.carouselSlides) + ? item.carouselSlides.map((slide: any, index: number) => ({ + id: String(slide?.id || createLocalId()), + imageUrl: String(slide?.imageUrl || ''), + caption: String(slide?.caption || `Slide ${index + 1}`), + })) + : undefined, + iconUrl: typeof item.iconUrl === 'string' ? item.iconUrl : '', + carouselPrevIconUrl: + typeof item.carouselPrevIconUrl === 'string' + ? item.carouselPrevIconUrl + : '', + carouselNextIconUrl: + typeof item.carouselNextIconUrl === 'string' + ? item.carouselNextIconUrl + : '', + tooltipTitle: + typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '', + tooltipText: + typeof item.tooltipText === 'string' ? item.tooltipText : '', + descriptionTitle: + typeof item.descriptionTitle === 'string' + ? item.descriptionTitle + : '', + descriptionText: + typeof item.descriptionText === 'string' + ? item.descriptionText + : '', + navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', + navType: + item.navType === 'back' || item.navType === 'forward' + ? item.navType + : isNavigationElementType(elementType) + ? getNavigationButtonKind(elementType as NavigationElementType) + : undefined, + targetPageId: + typeof item.targetPageId === 'string' ? item.targetPageId : '', + transitionVideoUrl: + typeof item.transitionVideoUrl === 'string' + ? item.transitionVideoUrl + : '', + transitionReverseMode: + item.transitionReverseMode === 'separate_video' + ? 'separate_video' + : ('auto_reverse' as const), + reverseVideoUrl: + typeof item.reverseVideoUrl === 'string' + ? item.reverseVideoUrl + : '', + transitionDurationSec: item.transitionDurationSec + ? Number(item.transitionDurationSec) + : undefined, + mediaUrl: typeof item.mediaUrl === 'string' ? item.mediaUrl : '', + mediaAutoplay: + typeof item.mediaAutoplay === 'boolean' + ? item.mediaAutoplay + : true, + 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); @@ -1203,7 +1314,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundAudioUrl(activePage.background_audio_url || ''); - }, [activePage, elementIdFromRoute]); + }, [activePage, elementIdFromRoute, uiElementDefaultsByType]); useEffect(() => { if (allowedNavigationTypes.length !== 1) return; @@ -1697,6 +1808,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { element.type === 'navigation_next' || element.type === 'navigation_prev' ) { + const fallbackNavLabel = getNavigationButtonLabel(element.type); + const navigationLabel = element.navLabel?.trim() || fallbackNavLabel; if (element.iconUrl) { return ( // eslint-disable-next-line @next/next/no-img-element @@ -1714,7 +1827,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return (
- {element.navLabel} + {navigationLabel}
{targetPageName ? ( @@ -2189,6 +2302,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { : 'border-blue-200 bg-white/95' }`} style={{ + ...buildCanvasElementStyle(element), left: `${element.xPercent}%`, top: `${element.yPercent}%`, transform: 'translate(-50%, -50%)', diff --git a/frontend/src/pages/page_elements/page_elements-project-edit.tsx b/frontend/src/pages/page_elements/page_elements-project-edit.tsx index f121c83..3f472ce 100644 --- a/frontend/src/pages/page_elements/page_elements-project-edit.tsx +++ b/frontend/src/pages/page_elements/page_elements-project-edit.tsx @@ -47,6 +47,28 @@ type ConstructorElement = { label?: string; xPercent?: 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; appearDurationSec?: number | null; iconUrl?: string; @@ -113,6 +135,31 @@ const parseNullableNumber = (value: string) => { 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 = () => { if (typeof window !== 'undefined' && window.crypto?.randomUUID) { return window.crypto.randomUUID(); @@ -141,6 +188,28 @@ const PageElementsProjectEditPage = () => { const [label, setLabel] = useState(''); const [xPercent, setXPercent] = 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 [appearDurationSec, setAppearDurationSec] = useState(''); const [iconUrl, setIconUrl] = useState(''); @@ -168,6 +237,28 @@ const PageElementsProjectEditPage = () => { setLabel(String(item.label || '')); setXPercent(String(item.xPercent ?? 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)); setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec)); setIconUrl(String(item.iconUrl || '')); @@ -316,15 +407,62 @@ const PageElementsProjectEditPage = () => { setSuccessMessage(''); try { + const borderWidthValue = toUnitValue(border, 'px'); const nextElement: ConstructorElement = { ...element, label: label.trim(), xPercent: clampPercent(xPercent), yPercent: clampPercent(yPercent), + border: borderWidthValue ? `${borderWidthValue} solid currentColor` : 'none', + borderRadius: toUnitValue(borderRadius, 'px') || '0px', appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0, 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) { nextElement.iconUrl = iconUrl.trim(); nextElement.navLabel = navLabel.trim(); @@ -515,6 +653,89 @@ const PageElementsProjectEditPage = () => {
+ +

View & stylization

+

+ Fill numbers only: width is saved as vw, height as vh, border and radius as px. +

+
+ + setWidth(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 24' /> + + + setHeight(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 8' /> + + setMinWidth(event.target.value)} disabled={!hasUpdatePermission} /> + setMaxWidth(event.target.value)} disabled={!hasUpdatePermission} /> + setMinHeight(event.target.value)} disabled={!hasUpdatePermission} /> + setMaxHeight(event.target.value)} disabled={!hasUpdatePermission} /> + setMargin(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0 auto / 0.5rem' /> + setPadding(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0.5rem 0.75rem' /> + setGap(event.target.value)} disabled={!hasUpdatePermission} /> + setFontSize(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0.875rem / clamp(...)' /> + setLineHeight(event.target.value)} disabled={!hasUpdatePermission} /> + setFontWeight(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 500 / bold' /> + setBorder(event.target.value)} disabled={!hasUpdatePermission} placeholder='empty = none' /> + setBorderRadius(event.target.value)} disabled={!hasUpdatePermission} placeholder='empty = 0' /> + setOpacity(event.target.value)} disabled={!hasUpdatePermission} placeholder='0..1' /> + setBoxShadow(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 0 4px 12px rgba(...)' /> + + + + + + + + + + + + + + + + + setZIndex(event.target.value)} disabled={!hasUpdatePermission} placeholder='e.g. 1 / 10' /> + +
+
+ {isNavigationType ? (

Navigation

diff --git a/frontend/src/pages/ui-elements/[id].tsx b/frontend/src/pages/ui-elements/[id].tsx index 08d8cc1..e0979f0 100644 --- a/frontend/src/pages/ui-elements/[id].tsx +++ b/frontend/src/pages/ui-elements/[id].tsx @@ -77,6 +77,31 @@ const parseNullableNumber = (value: string) => { 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 = () => { if (typeof window !== 'undefined' && window.crypto?.randomUUID) { return window.crypto.randomUUID(); @@ -101,6 +126,28 @@ const UiElementDetailsPage = () => { const [label, setLabel] = useState(''); const [xPercent, setXPercent] = 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 [appearDurationSec, setAppearDurationSec] = useState(''); const [iconUrl, setIconUrl] = useState(''); @@ -135,6 +182,28 @@ const UiElementDetailsPage = () => { setLabel(String(settings.label || '')); setXPercent(String(settings.xPercent ?? 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)); setAppearDurationSec(settings.appearDurationSec === null || settings.appearDurationSec === undefined ? '' : String(settings.appearDurationSec)); setIconUrl(String(settings.iconUrl || '')); @@ -271,14 +340,61 @@ const UiElementDetailsPage = () => { const handleSave = useCallback(async () => { if (!id || !item) return; + const borderWidthValue = toUnitValue(border, 'px'); const defaultSettings: Record = { label: label.trim(), xPercent: clampPercent(xPercent), yPercent: clampPercent(yPercent), + border: borderWidthValue ? `${borderWidthValue} solid currentColor` : 'none', + borderRadius: toUnitValue(borderRadius, 'px') || '0px', appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0, 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) { defaultSettings.iconUrl = iconUrl.trim(); defaultSettings.navLabel = navLabel.trim(); @@ -365,8 +481,13 @@ const UiElementDetailsPage = () => { carouselSlides, currentElementType, descriptionText, + display, descriptionTitle, + fontSize, + fontWeight, + gap, galleryCards, + height, iconUrl, id, isActive, @@ -377,25 +498,42 @@ const UiElementDetailsPage = () => { isNavigationType, isTooltipType, item, + justifyContent, label, + lineHeight, loadItem, + margin, + maxHeight, + maxWidth, mediaAutoplay, mediaLoop, mediaMuted, mediaUrl, + minHeight, + minWidth, name, navLabel, navType, + opacity, + padding, + position, reverseVideoUrl, sortOrder, + textAlign, targetPageId, tooltipText, tooltipTitle, transitionDurationSec, transitionReverseMode, transitionVideoUrl, + width, xPercent, yPercent, + zIndex, + alignItems, + border, + borderRadius, + boxShadow, ]); return ( @@ -490,6 +628,83 @@ const UiElementDetailsPage = () => { + +

View & stylization

+

+ Fill numbers only: width is saved as vw, height as vh, border and radius as px. +

+
+ setWidth(event.target.value)} placeholder='e.g. 24' /> + setHeight(event.target.value)} placeholder='e.g. 8' /> + setMinWidth(event.target.value)} /> + setMaxWidth(event.target.value)} /> + setMinHeight(event.target.value)} /> + setMaxHeight(event.target.value)} /> + setMargin(event.target.value)} placeholder='e.g. 0 auto / 0.5rem' /> + setPadding(event.target.value)} placeholder='e.g. 0.5rem 0.75rem' /> + setGap(event.target.value)} /> + setFontSize(event.target.value)} placeholder='e.g. 0.875rem / clamp(...)' /> + setLineHeight(event.target.value)} /> + setFontWeight(event.target.value)} placeholder='e.g. 500 / bold' /> + setBorder(event.target.value)} placeholder='empty = none' /> + setBorderRadius(event.target.value)} placeholder='empty = 0' /> + setOpacity(event.target.value)} placeholder='0..1' /> + setBoxShadow(event.target.value)} placeholder='e.g. 0 4px 12px rgba(...)' /> + + + + + + + + + + + + + + + + setZIndex(event.target.value)} placeholder='e.g. 1 / 10' /> +
+
+ {isNavigationType ? ( <>