From afabb0cce1c3cd93754f0149e3cbf4654fc6b0cb Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 19 Mar 2026 07:38:52 +0000 Subject: [PATCH] Autosave: 20260319-073852 --- backend/src/db/api/ui_elements.js | 235 ++++ backend/src/db/models/ui_elements.js | 72 ++ backend/src/index.js | 2 + backend/src/routes/ui_elements.js | 7 + backend/src/services/ui_elements.js | 6 + frontend/src/menuAside.ts | 1 + frontend/src/pages/constructor.tsx | 97 +- .../page_elements-project-edit.tsx | 671 ++++++++--- frontend/src/pages/ui-elements.tsx | 209 ++-- frontend/src/pages/ui-elements/[id].tsx | 1002 +++++++++++------ 10 files changed, 1643 insertions(+), 659 deletions(-) create mode 100644 backend/src/db/api/ui_elements.js create mode 100644 backend/src/db/models/ui_elements.js create mode 100644 backend/src/routes/ui_elements.js create mode 100644 backend/src/services/ui_elements.js diff --git a/backend/src/db/api/ui_elements.js b/backend/src/db/api/ui_elements.js new file mode 100644 index 0000000..26a0b3f --- /dev/null +++ b/backend/src/db/api/ui_elements.js @@ -0,0 +1,235 @@ +const GenericDBApi = require('./base.api'); +const db = require('../models'); + +class Ui_elementsDBApi extends GenericDBApi { + static get MODEL() { + return db.ui_elements; + } + + static get TABLE_NAME() { + return 'ui_elements'; + } + + static get SEARCHABLE_FIELDS() { + return ['name', 'element_type']; + } + + static get RANGE_FIELDS() { + return ['sort_order']; + } + + static get ENUM_FIELDS() { + return []; + } + + static get CSV_FIELDS() { + return ['id', 'element_type', 'name', 'sort_order', 'is_active', 'createdAt']; + } + + static get AUTOCOMPLETE_FIELD() { + return 'name'; + } + + static getFieldMapping(data) { + return { + id: data.id || undefined, + element_type: data.element_type ?? null, + name: data.name ?? null, + sort_order: data.sort_order ?? 0, + default_settings_json: + data.default_settings_json === null || data.default_settings_json === undefined + ? null + : typeof data.default_settings_json === 'string' + ? data.default_settings_json + : JSON.stringify(data.default_settings_json), + }; + } + + static get DEFAULT_ROWS() { + return [ + { + element_type: 'navigation_next', + name: 'Navigation Forward Button', + sort_order: 1, + default_settings_json: { + label: 'Navigation: Forward', + navLabel: 'Forward', + navType: 'forward', + transitionReverseMode: 'auto_reverse', + transitionDurationSec: 0.7, + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'navigation_prev', + name: 'Navigation Back Button', + sort_order: 2, + default_settings_json: { + label: 'Navigation: Back', + navLabel: 'Back', + navType: 'back', + transitionReverseMode: 'auto_reverse', + transitionDurationSec: 0.7, + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'tooltip', + name: 'Tooltip', + sort_order: 3, + default_settings_json: { + label: 'Tooltip', + tooltipTitle: 'Tooltip title', + tooltipText: 'Tooltip text', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'description', + name: 'Description', + sort_order: 4, + default_settings_json: { + label: 'Description', + descriptionTitle: 'Description title', + descriptionText: 'Description text', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'gallery', + name: 'Gallery', + sort_order: 5, + default_settings_json: { + label: 'Gallery', + galleryCards: [{ imageUrl: '', title: 'Card 1', description: '' }], + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'carousel', + name: 'Carousel', + sort_order: 6, + default_settings_json: { + label: 'Carousel', + carouselSlides: [{ imageUrl: '', caption: 'Slide 1' }], + carouselPrevIconUrl: '', + carouselNextIconUrl: '', + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'video_player', + name: 'Video Player', + sort_order: 7, + default_settings_json: { + label: 'Video Player', + mediaUrl: '', + mediaAutoplay: true, + mediaLoop: true, + mediaMuted: true, + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + { + element_type: 'audio_player', + name: 'Audio Player', + sort_order: 8, + default_settings_json: { + label: 'Audio Player', + mediaUrl: '', + mediaAutoplay: true, + mediaLoop: true, + mediaMuted: false, + appearDelaySec: 0, + appearDurationSec: null, + }, + }, + ]; + } + + static async ensureInitialized() { + if (!this.initializationPromise) { + this.initializationPromise = (async () => { + let count = 0; + + try { + count = await this.MODEL.count(); + } catch (error) { + if (error?.original?.code !== '42P01') { + throw error; + } + + await this.MODEL.sync(); + count = await this.MODEL.count(); + } + + if (count > 0) return; + + const now = new Date(); + await this.MODEL.bulkCreate( + this.DEFAULT_ROWS.map((item) => ({ + ...item, + createdAt: now, + updatedAt: now, + })), + ); + })().catch((error) => { + this.initializationPromise = null; + throw error; + }); + } + + await this.initializationPromise; + } + + static async create(data, options = {}) { + await this.ensureInitialized(); + return super.create(data, options); + } + + static async bulkImport(data, options = {}) { + await this.ensureInitialized(); + return super.bulkImport(data, options); + } + + static async update(id, data, options = {}) { + await this.ensureInitialized(); + return super.update(id, data, options); + } + + static async deleteByIds(ids, options = {}) { + await this.ensureInitialized(); + return super.deleteByIds(ids, options); + } + + static async remove(id, options = {}) { + await this.ensureInitialized(); + return super.remove(id, options); + } + + static async findBy(where, options = {}) { + await this.ensureInitialized(); + return super.findBy(where, options); + } + + static async findAll(filter = {}, options = {}) { + await this.ensureInitialized(); + return super.findAll(filter, options); + } + + static async findAllAutocomplete(query, limit, offset) { + await this.ensureInitialized(); + return super.findAllAutocomplete(query, limit, offset); + } +} + +Ui_elementsDBApi.initializationPromise = null; + +module.exports = Ui_elementsDBApi; diff --git a/backend/src/db/models/ui_elements.js b/backend/src/db/models/ui_elements.js new file mode 100644 index 0000000..5d56cad --- /dev/null +++ b/backend/src/db/models/ui_elements.js @@ -0,0 +1,72 @@ +module.exports = function (sequelize, DataTypes) { + const ui_elements = sequelize.define( + 'ui_elements', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + element_type: { + type: DataTypes.TEXT, + allowNull: false, + unique: true, + validate: { + notEmpty: { msg: 'Element type is required' }, + len: { args: [1, 100], msg: 'Element type must be between 1 and 100 characters' }, + }, + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + validate: { + notEmpty: { msg: 'Name is required' }, + len: { args: [1, 255], msg: 'Name must be between 1 and 255 characters' }, + }, + }, + sort_order: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + is_active: { + type: DataTypes.VIRTUAL, + get() { + return true; + }, + }, + default_settings_json: { + type: DataTypes.TEXT, + field: 'settings_json', + allowNull: true, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + indexes: [ + { fields: ['element_type'] }, + { fields: ['sort_order'] }, + { fields: ['deletedAt'] }, + ], + }, + ); + + ui_elements.associate = (db) => { + db.ui_elements.belongsTo(db.users, { + as: 'createdBy', + }); + + db.ui_elements.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return ui_elements; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 3f2acfa..a274faa 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -52,6 +52,7 @@ const publish_eventsRoutes = require('./routes/publish_events'); const pwa_cachesRoutes = require('./routes/pwa_caches'); const access_logsRoutes = require('./routes/access_logs'); +const ui_elementsRoutes = require('./routes/ui_elements'); const publishRoutes = require('./routes/publish'); const runtimeContextRoutes = require('./routes/runtime-context'); @@ -208,6 +209,7 @@ app.use('/api/publish_events', jwtAuth, publish_eventsRoutes); app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes); app.use('/api/access_logs', jwtAuth, access_logsRoutes); +app.use('/api/ui-elements', jwtAuth, ui_elementsRoutes); app.use('/api/publish', jwtAuth, publishRoutes); diff --git a/backend/src/routes/ui_elements.js b/backend/src/routes/ui_elements.js new file mode 100644 index 0000000..318f99a --- /dev/null +++ b/backend/src/routes/ui_elements.js @@ -0,0 +1,7 @@ +const Ui_elementsService = require('../services/ui_elements'); +const Ui_elementsDBApi = require('../db/api/ui_elements'); +const { createEntityRouter } = require('../factories/router.factory'); + +module.exports = createEntityRouter('ui_elements', Ui_elementsService, Ui_elementsDBApi, { + permissionEntity: 'page_elements', +}); diff --git a/backend/src/services/ui_elements.js b/backend/src/services/ui_elements.js new file mode 100644 index 0000000..614519a --- /dev/null +++ b/backend/src/services/ui_elements.js @@ -0,0 +1,6 @@ +const Ui_elementsDBApi = require('../db/api/ui_elements'); +const { createEntityService } = require('../factories/service.factory'); + +module.exports = createEntityService(Ui_elementsDBApi, { + entityName: 'ui_elements', +}); diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 8215338..74641ed 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -22,6 +22,7 @@ const menuAside: MenuAsideItem[] = [ href: '/ui-elements', label: 'UI Elements', icon: icon.mdiPaletteSwatch, + permissions: 'READ_PAGE_ELEMENTS', }, { href: '/users/users-list', diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index acfe24f..3f3e87b 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -125,6 +125,13 @@ type ConstructorSchema = { elements?: CanvasElement[]; }; +type UiElementDefault = { + id: string; + element_type?: string; + is_active?: boolean; + default_settings_json?: Partial | string | null; +}; + type DragElementState = { id: string; pointerOffsetX: number; @@ -413,6 +420,20 @@ const labelByType: Record = { audio_player: 'Audio Player', }; +const canvasElementTypes: CanvasElementType[] = [ + 'navigation_next', + 'navigation_prev', + 'gallery', + 'carousel', + 'tooltip', + 'description', + 'video_player', + 'audio_player', +]; + +const isCanvasElementType = (value: string): value is CanvasElementType => + canvasElementTypes.includes(value as CanvasElementType); + const isNavigationElementType = ( type: CanvasElementType, ): type is NavigationElementType => @@ -505,6 +526,50 @@ const createDefaultElement = ( return base; }; +const mergeElementWithDefaults = ( + element: CanvasElement, + defaults?: Partial, +): CanvasElement => { + if (!defaults) return element; + + const merged: CanvasElement = { + ...element, + ...defaults, + id: element.id, + type: element.type, + }; + + merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100); + merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100); + merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec); + merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec); + + if (merged.type === 'gallery') { + const cards = Array.isArray(defaults.galleryCards) + ? defaults.galleryCards + : element.galleryCards || []; + merged.galleryCards = cards.map((card, cardIndex) => ({ + id: String(card?.id || createLocalId()), + imageUrl: String(card?.imageUrl || ''), + title: String(card?.title || `Card ${cardIndex + 1}`), + description: String(card?.description || ''), + })); + } + + if (merged.type === 'carousel') { + const slides = Array.isArray(defaults.carouselSlides) + ? defaults.carouselSlides + : element.carouselSlides || []; + merged.carouselSlides = slides.map((slide, slideIndex) => ({ + id: String(slide?.id || createLocalId()), + imageUrl: String(slide?.imageUrl || ''), + caption: String(slide?.caption || `Slide ${slideIndex + 1}`), + })); + } + + return merged; +}; + const getElementButtonTitle = (element: CanvasElement) => { if (element.type === 'gallery') { return `${element.label} (${element.galleryCards?.length || 0})`; @@ -558,6 +623,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const [pages, setPages] = useState([]); const [assets, setAssets] = useState([]); + const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState< + Partial>> + >({}); const [activePageId, setActivePageId] = useState(''); const [projectName, setProjectName] = useState(''); @@ -866,7 +934,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { setErrorMessage(''); setSuccessMessage(''); - const [projectResponse, pagesResponse, assetsResponse] = + const [projectResponse, pagesResponse, assetsResponse, uiElementsResponse] = await Promise.all([ axios.get(`/projects/${projectId}`), axios.get( @@ -875,6 +943,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { axios.get( `/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`, ), + axios.get('/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true'), ]); const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows) @@ -889,6 +958,25 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { setPages(pageRows); setAssets(assetRows); + const uiElementRows: UiElementDefault[] = Array.isArray( + uiElementsResponse?.data?.rows, + ) + ? uiElementsResponse.data.rows + : []; + const defaultsByType: Partial< + Record> + > = {}; + uiElementRows.forEach((row) => { + const elementType = String(row.element_type || '').trim(); + if (!isCanvasElementType(elementType)) return; + const rawDefaults = parseJsonObject>( + row.default_settings_json, + {}, + ); + defaultsByType[elementType] = rawDefaults; + }); + setUiElementDefaultsByType(defaultsByType); + const defaultPageId = pageIdFromRoute || pageRows[0]?.id || ''; setActivePageId(defaultPageId); setIsMenuOpen(false); @@ -911,6 +999,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { setErrorMessage(message); setPages([]); setAssets([]); + setUiElementDefaultsByType({}); } finally { setIsLoading(false); } @@ -1239,7 +1328,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ? type : allowedNavigationTypes[0] : type; - const nextElement = createDefaultElement(nextElementType, elements.length); + const baseElement = createDefaultElement(nextElementType, elements.length); + const nextElement = mergeElementWithDefaults( + baseElement, + uiElementDefaultsByType[nextElementType], + ); setElements((prev) => [...prev, nextElement]); selectElementForEdit(nextElement.id); setSuccessMessage('Element added. Drag it to set position.'); 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 ccfb422..f121c83 100644 --- a/frontend/src/pages/page_elements/page_elements-project-edit.tsx +++ b/frontend/src/pages/page_elements/page_elements-project-edit.tsx @@ -1,4 +1,4 @@ -import { mdiArrowLeft, mdiContentSave, mdiPencil } from '@mdi/js'; +import { mdiArrowLeft, mdiContentSave, mdiPencil, mdiPlus, mdiTrashCan } from '@mdi/js'; import axios from 'axios'; import Head from 'next/head'; import Link from 'next/link'; @@ -6,6 +6,7 @@ import { useRouter } from 'next/router'; import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import BaseButton from '../../components/BaseButton'; import CardBox from '../../components/CardBox'; +import FormField from '../../components/FormField'; import SectionMain from '../../components/SectionMain'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import { getPageTitle } from '../../config'; @@ -27,6 +28,19 @@ type TourPage = { background_loop?: boolean; }; +type GalleryCard = { + id: string; + imageUrl: string; + title: string; + description: string; +}; + +type CarouselSlide = { + id: string; + imageUrl: string; + caption: string; +}; + type ConstructorElement = { id: string; type: string; @@ -35,8 +49,14 @@ type ConstructorElement = { yPercent?: number; appearDelaySec?: number; appearDurationSec?: number | null; + iconUrl?: string; navLabel?: string; + navType?: 'forward' | 'back'; targetPageId?: string; + transitionVideoUrl?: string; + transitionReverseMode?: 'auto_reverse' | 'separate_video'; + reverseVideoUrl?: string; + transitionDurationSec?: number; tooltipTitle?: string; tooltipText?: string; descriptionTitle?: string; @@ -45,8 +65,10 @@ type ConstructorElement = { mediaAutoplay?: boolean; mediaLoop?: boolean; mediaMuted?: boolean; - galleryCards?: any[]; - carouselSlides?: any[]; + carouselPrevIconUrl?: string; + carouselNextIconUrl?: string; + galleryCards?: GalleryCard[]; + carouselSlides?: CarouselSlide[]; }; type ConstructorSchema = { @@ -91,17 +113,12 @@ const parseNullableNumber = (value: string) => { return parsed; }; -const parseArrayJson = (value: string, fieldName: string) => { - try { - const parsed = JSON.parse(value); - if (!Array.isArray(parsed)) { - throw new Error(`${fieldName} must be a JSON array.`); - } - return parsed; - } catch (error) { - console.error(`Failed to parse ${fieldName}:`, error); - throw error; +const createLocalId = () => { + if (typeof window !== 'undefined' && window.crypto?.randomUUID) { + return window.crypto.randomUUID(); } + + return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }; const PageElementsProjectEditPage = () => { @@ -126,8 +143,14 @@ const PageElementsProjectEditPage = () => { const [yPercent, setYPercent] = useState('0'); const [appearDelaySec, setAppearDelaySec] = useState('0'); const [appearDurationSec, setAppearDurationSec] = useState(''); + const [iconUrl, setIconUrl] = useState(''); const [navLabel, setNavLabel] = useState(''); + const [navType, setNavType] = useState<'forward' | 'back'>('forward'); const [targetPageId, setTargetPageId] = useState(''); + const [transitionVideoUrl, setTransitionVideoUrl] = useState(''); + const [transitionReverseMode, setTransitionReverseMode] = useState<'auto_reverse' | 'separate_video'>('auto_reverse'); + const [reverseVideoUrl, setReverseVideoUrl] = useState(''); + const [transitionDurationSec, setTransitionDurationSec] = useState('0.7'); const [tooltipTitle, setTooltipTitle] = useState(''); const [tooltipText, setTooltipText] = useState(''); const [descriptionTitle, setDescriptionTitle] = useState(''); @@ -136,8 +159,10 @@ const PageElementsProjectEditPage = () => { const [mediaAutoplay, setMediaAutoplay] = useState(false); const [mediaLoop, setMediaLoop] = useState(false); const [mediaMuted, setMediaMuted] = useState(false); - const [galleryCardsJson, setGalleryCardsJson] = useState('[]'); - const [carouselSlidesJson, setCarouselSlidesJson] = useState('[]'); + const [carouselPrevIconUrl, setCarouselPrevIconUrl] = useState(''); + const [carouselNextIconUrl, setCarouselNextIconUrl] = useState(''); + const [galleryCards, setGalleryCards] = useState([]); + const [carouselSlides, setCarouselSlides] = useState([]); const applyElementToForm = useCallback((item: ConstructorElement) => { setLabel(String(item.label || '')); @@ -145,8 +170,14 @@ const PageElementsProjectEditPage = () => { setYPercent(String(item.yPercent ?? 0)); setAppearDelaySec(String(item.appearDelaySec ?? 0)); setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec)); + setIconUrl(String(item.iconUrl || '')); setNavLabel(String(item.navLabel || '')); + setNavType(item.navType === 'back' ? 'back' : 'forward'); setTargetPageId(String(item.targetPageId || '')); + setTransitionVideoUrl(String(item.transitionVideoUrl || '')); + setTransitionReverseMode(item.transitionReverseMode === 'separate_video' ? 'separate_video' : 'auto_reverse'); + setReverseVideoUrl(String(item.reverseVideoUrl || '')); + setTransitionDurationSec(String(item.transitionDurationSec ?? 0.7)); setTooltipTitle(String(item.tooltipTitle || '')); setTooltipText(String(item.tooltipText || '')); setDescriptionTitle(String(item.descriptionTitle || '')); @@ -155,8 +186,27 @@ const PageElementsProjectEditPage = () => { setMediaAutoplay(Boolean(item.mediaAutoplay)); setMediaLoop(Boolean(item.mediaLoop)); setMediaMuted(Boolean(item.mediaMuted)); - setGalleryCardsJson(JSON.stringify(item.galleryCards || [], null, 2)); - setCarouselSlidesJson(JSON.stringify(item.carouselSlides || [], null, 2)); + setCarouselPrevIconUrl(String(item.carouselPrevIconUrl || '')); + setCarouselNextIconUrl(String(item.carouselNextIconUrl || '')); + setGalleryCards( + Array.isArray(item.galleryCards) + ? item.galleryCards.map((card, index) => ({ + id: String(card?.id || createLocalId()), + imageUrl: String(card?.imageUrl || ''), + title: String(card?.title || `Card ${index + 1}`), + description: String(card?.description || ''), + })) + : [], + ); + setCarouselSlides( + Array.isArray(item.carouselSlides) + ? item.carouselSlides.map((slide, index) => ({ + id: String(slide?.id || createLocalId()), + imageUrl: String(slide?.imageUrl || ''), + caption: String(slide?.caption || `Slide ${index + 1}`), + })) + : [], + ); }, []); const loadData = useCallback(async () => { @@ -208,6 +258,56 @@ const PageElementsProjectEditPage = () => { loadData(); }, [loadData]); + const isNavigationType = element?.type === 'navigation_next' || element?.type === 'navigation_prev'; + const isTooltipType = element?.type === 'tooltip'; + const isDescriptionType = element?.type === 'description'; + const isGalleryType = element?.type === 'gallery'; + const isCarouselType = element?.type === 'carousel'; + const isMediaType = element?.type === 'video_player' || element?.type === 'audio_player'; + + const updateGalleryCard = (cardId: string, field: keyof GalleryCard, value: string) => { + setGalleryCards((previous) => + previous.map((card) => (card.id === cardId ? { ...card, [field]: value } : card)), + ); + }; + + const addGalleryCard = () => { + setGalleryCards((previous) => [ + ...previous, + { + id: createLocalId(), + imageUrl: '', + title: `Card ${previous.length + 1}`, + description: '', + }, + ]); + }; + + const removeGalleryCard = (cardId: string) => { + setGalleryCards((previous) => previous.filter((card) => card.id !== cardId)); + }; + + const updateCarouselSlide = (slideId: string, field: keyof CarouselSlide, value: string) => { + setCarouselSlides((previous) => + previous.map((slide) => (slide.id === slideId ? { ...slide, [field]: value } : slide)), + ); + }; + + const addCarouselSlide = () => { + setCarouselSlides((previous) => [ + ...previous, + { + id: createLocalId(), + imageUrl: '', + caption: `Slide ${previous.length + 1}`, + }, + ]); + }; + + const removeCarouselSlide = (slideId: string) => { + setCarouselSlides((previous) => previous.filter((slide) => slide.id !== slideId)); + }; + const saveElement = async () => { if (!currentPage || !element || !hasUpdatePermission) return; @@ -223,24 +323,55 @@ const PageElementsProjectEditPage = () => { yPercent: clampPercent(yPercent), appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0, appearDurationSec: parseNullableNumber(appearDurationSec), - navLabel: navLabel.trim(), - targetPageId: targetPageId.trim(), - tooltipTitle: tooltipTitle.trim(), - tooltipText: tooltipText, - descriptionTitle: descriptionTitle.trim(), - descriptionText: descriptionText, - mediaUrl: mediaUrl.trim(), - mediaAutoplay, - mediaLoop, - mediaMuted, }; - if (element.type === 'gallery') { - nextElement.galleryCards = parseArrayJson(galleryCardsJson, 'Gallery cards'); + if (isNavigationType) { + nextElement.iconUrl = iconUrl.trim(); + nextElement.navLabel = navLabel.trim(); + nextElement.navType = navType; + nextElement.targetPageId = targetPageId.trim(); + nextElement.transitionVideoUrl = transitionVideoUrl.trim(); + nextElement.transitionReverseMode = transitionReverseMode; + nextElement.reverseVideoUrl = reverseVideoUrl.trim(); + nextElement.transitionDurationSec = Number(transitionDurationSec) > 0 ? Number(transitionDurationSec) : 0.7; } - if (element.type === 'carousel') { - nextElement.carouselSlides = parseArrayJson(carouselSlidesJson, 'Carousel slides'); + if (isTooltipType) { + nextElement.iconUrl = iconUrl.trim(); + nextElement.tooltipTitle = tooltipTitle.trim(); + nextElement.tooltipText = tooltipText; + } + + if (isDescriptionType) { + nextElement.iconUrl = iconUrl.trim(); + nextElement.descriptionTitle = descriptionTitle.trim(); + nextElement.descriptionText = descriptionText; + } + + if (isGalleryType) { + nextElement.galleryCards = galleryCards.map((card, index) => ({ + id: String(card.id || createLocalId()), + imageUrl: card.imageUrl.trim(), + title: card.title.trim() || `Card ${index + 1}`, + description: card.description, + })); + } + + if (isCarouselType) { + nextElement.carouselSlides = carouselSlides.map((slide, index) => ({ + id: String(slide.id || createLocalId()), + imageUrl: slide.imageUrl.trim(), + caption: slide.caption.trim() || `Slide ${index + 1}`, + })); + nextElement.carouselPrevIconUrl = carouselPrevIconUrl.trim(); + nextElement.carouselNextIconUrl = carouselNextIconUrl.trim(); + } + + if (isMediaType) { + nextElement.mediaUrl = mediaUrl.trim(); + nextElement.mediaAutoplay = mediaAutoplay; + nextElement.mediaLoop = mediaLoop; + nextElement.mediaMuted = mediaMuted; } const existingSchema = parseJsonObject(currentPage.ui_schema_json, {}); @@ -345,185 +476,373 @@ const PageElementsProjectEditPage = () => {

General

-
- + setLabel(event.target.value)} disabled={!hasUpdatePermission} /> -
-
- - -
-
- + + setXPercent(event.target.value)} disabled={!hasUpdatePermission} /> -
-
- + + setYPercent(event.target.value)} disabled={!hasUpdatePermission} /> -
-
- + + setAppearDelaySec(event.target.value)} disabled={!hasUpdatePermission} /> -
-
- + + setAppearDurationSec(event.target.value)} placeholder='Leave empty for none' disabled={!hasUpdatePermission} /> -
+
- -

Navigation / Text Settings

-
-
- - setNavLabel(event.target.value)} - disabled={!hasUpdatePermission} - /> -
-
- - setTooltipTitle(event.target.value)} - disabled={!hasUpdatePermission} - /> -
-
- -