From 027af5082bb276506ff323522e721cb287fac2df Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 8 May 2026 16:44:25 +0200 Subject: [PATCH] updated page dropdown in the constructor --- .../Constructor/CreatePageModal.tsx | 123 ++++++++ frontend/src/components/TourFlowManager.tsx | 264 ++++++++++++++---- .../shared/useElementWrapperStyle.ts | 9 +- .../src/hooks/useConstructorPageActions.ts | 21 +- frontend/src/lib/slugHelpers.ts | 13 +- frontend/src/pages/constructor.tsx | 83 ++++-- 6 files changed, 418 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/Constructor/CreatePageModal.tsx diff --git a/frontend/src/components/Constructor/CreatePageModal.tsx b/frontend/src/components/Constructor/CreatePageModal.tsx new file mode 100644 index 0000000..f2aa2f9 --- /dev/null +++ b/frontend/src/components/Constructor/CreatePageModal.tsx @@ -0,0 +1,123 @@ +/** + * CreatePageModal Component + * + * Modal dialog for creating new pages with custom name. + * Slug is auto-generated from the page name behind the scenes. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import CardBoxModal from '../CardBoxModal'; +import { sanitizeSlug, buildUniqueSlug } from '../../lib/slugHelpers'; + +interface CreatePageModalProps { + /** Whether the modal is visible */ + isActive: boolean; + /** Whether page creation is in progress */ + isCreating: boolean; + /** Set of existing slugs for uniqueness validation */ + existingSlugs: Set; + /** Suggested page number for default name */ + suggestedPageNumber: number; + /** Called when user confirms with valid name and slug */ + onConfirm: (pageName: string, slug: string) => void; + /** Called when user cancels */ + onCancel: () => void; +} + +/** + * Modal for creating new pages - only asks for name, slug is auto-generated + */ +const CreatePageModal: React.FC = ({ + isActive, + isCreating, + existingSlugs, + suggestedPageNumber, + onConfirm, + onCancel, +}) => { + const [pageName, setPageName] = useState(''); + const [nameError, setNameError] = useState(''); + + // Reset form when modal opens + useEffect(() => { + if (isActive) { + const defaultName = `Page ${suggestedPageNumber}`; + setPageName(defaultName); + setNameError(''); + } + }, [isActive, suggestedPageNumber]); + + // Validate name + const nameValidationError = useMemo(() => { + const trimmed = pageName.trim(); + if (!trimmed) return 'Page name is required'; + if (trimmed.length > 255) return 'Page name must be 255 characters or less'; + return ''; + }, [pageName]); + + // Auto-generate unique slug from name + const generatedSlug = useMemo(() => { + const baseSlug = sanitizeSlug(pageName) || 'page'; + return buildUniqueSlug(baseSlug, existingSlugs); + }, [pageName, existingSlugs]); + + const handleNameChange = useCallback((value: string) => { + setPageName(value); + setNameError(''); + }, []); + + const handleConfirm = useCallback(() => { + if (nameValidationError) { + setNameError(nameValidationError); + return; + } + + onConfirm(pageName.trim(), generatedSlug); + }, [pageName, generatedSlug, nameValidationError, onConfirm]); + + const handleCancel = useCallback(() => { + if (!isCreating) { + onCancel(); + } + }, [isCreating, onCancel]); + + const isConfirmDisabled = isCreating || Boolean(nameValidationError); + + return ( + +
+ + handleNameChange(e.target.value)} + placeholder="Enter page name" + className="w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800" + autoFocus + maxLength={255} + /> + {(nameError || nameValidationError) && ( +

+ {nameError || nameValidationError} +

+ )} +
+
+ ); +}; + +export default CreatePageModal; diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index aa16e36..a379d42 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -6,6 +6,7 @@ import { mdiViewDashboard, mdiChevronDown, mdiChevronUp, + mdiPencil, } from '@mdi/js'; import Icon from '@mdi/react'; import axios from 'axios'; @@ -22,7 +23,7 @@ import { getPageTitle } from '../config'; import { hasPermission } from '../helpers/userPermissions'; import { useAppSelector, useAppDispatch } from '../stores/hooks'; import { logger } from '../lib/logger'; -import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers'; +import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers'; import { toRoutePath, compareRoutes, @@ -114,10 +115,15 @@ const TourFlowManager = () => { const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingTransition, setIsCreatingTransition] = useState(false); const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false); - const [newPageSlug, setNewPageSlug] = useState(''); - const [newPageSlugError, setNewPageSlugError] = useState(''); + const [newPageName, setNewPageName] = useState(''); const [deletingId, setDeletingId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + // Edit page modal state + const [isEditPageModalActive, setIsEditPageModalActive] = useState(false); + const [editingPageId, setEditingPageId] = useState(''); + const [editPageName, setEditPageName] = useState(''); + const [editPageNameError, setEditPageNameError] = useState(''); + const [isSavingPageName, setIsSavingPageName] = useState(false); // Use selector for current project's dev transition settings const projectTransitionSettingsEntity = useAppSelector((state) => @@ -337,15 +343,25 @@ const TourFlowManager = () => { [pages, targetEnvironment], ); - const slugValidationError = useMemo(() => { - const slug = newPageSlug.trim(); - if (!slug) return 'Slug is required.'; - if (!slugPattern.test(slug)) - return 'Use lowercase letters, numbers, and hyphens only.'; - if (pageSlugsInEnvironment.has(slug)) - return 'This slug already exists in the selected environment.'; + const nameValidationError = useMemo(() => { + const name = newPageName.trim(); + if (!name) return 'Page name is required.'; + if (name.length > 255) return 'Page name must be 255 characters or less.'; return ''; - }, [newPageSlug, pageSlugsInEnvironment]); + }, [newPageName]); + + // Auto-generate unique slug from name + const generatedSlug = useMemo(() => { + const baseSlug = sanitizeSlug(newPageName) || 'page'; + return buildUniqueSlug(baseSlug, pageSlugsInEnvironment); + }, [newPageName, pageSlugsInEnvironment]); + + const editNameValidationError = useMemo(() => { + const name = editPageName.trim(); + if (!name) return 'Page name is required.'; + if (name.length > 255) return 'Page name must be 255 characters or less.'; + return ''; + }, [editPageName]); const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]); @@ -407,24 +423,113 @@ const TourFlowManager = () => { return; } - const suggestedSlug = buildUniqueSlug( - `page-${nextPageNumber}`, - pageSlugsInEnvironment, - ); - setNewPageSlug(suggestedSlug); - setNewPageSlugError(''); + const suggestedName = `Page ${nextPageNumber}`; + setNewPageName(suggestedName); setIsCreatePageModalActive(true); }; - const handleSlugChange = (value: string) => { - setNewPageSlug(value); - setNewPageSlugError(''); + const handleNameChange = (value: string) => { + setNewPageName(value); }; const closeCreatePageModal = () => { setIsCreatePageModalActive(false); - setNewPageSlug(''); - setNewPageSlugError(''); + setNewPageName(''); + }; + + // Edit page modal handlers + const openEditPageModal = (event: React.MouseEvent, page: TourPage) => { + event.stopPropagation(); + setEditingPageId(page.id); + setEditPageName(page.name || ''); + setEditPageNameError(''); + setIsEditPageModalActive(true); + }; + + const handleEditNameChange = (value: string) => { + setEditPageName(value); + setEditPageNameError(''); + }; + + const closeEditPageModal = () => { + setIsEditPageModalActive(false); + setEditingPageId(''); + setEditPageName(''); + setEditPageNameError(''); + }; + + const handleSavePageName = async () => { + if (editNameValidationError) { + setEditPageNameError(editNameValidationError); + return; + } + + try { + setIsSavingPageName(true); + setEditPageNameError(''); + + // Fetch full page data from API to preserve all fields + // The backend's getFieldMapping converts missing fields to null + const pageResponse = await axios.get(`/tour_pages/${editingPageId}`); + const fullPageData = pageResponse.data; + + if (!fullPageData) { + setEditPageNameError('Page not found'); + return; + } + + // Send all existing fields with only name changed + await axios.put(`/tour_pages/${editingPageId}`, { + id: editingPageId, + data: { + environment: fullPageData.environment, + source_key: fullPageData.source_key, + name: editPageName.trim(), + slug: fullPageData.slug, + sort_order: fullPageData.sort_order, + background_image_url: fullPageData.background_image_url, + background_video_url: fullPageData.background_video_url, + background_audio_url: fullPageData.background_audio_url, + background_loop: fullPageData.background_loop, + background_video_autoplay: fullPageData.background_video_autoplay, + background_video_loop: fullPageData.background_video_loop, + background_video_muted: fullPageData.background_video_muted, + background_video_start_time: fullPageData.background_video_start_time, + background_video_end_time: fullPageData.background_video_end_time, + design_width: fullPageData.design_width, + design_height: fullPageData.design_height, + requires_auth: fullPageData.requires_auth, + ui_schema_json: fullPageData.ui_schema_json, + }, + }); + + // Update local state + setPages((prev) => + prev.map((page) => + page.id === editingPageId + ? { ...page, name: editPageName.trim() } + : page, + ), + ); + + closeEditPageModal(); + toast.success('Page name updated'); + } catch (error: unknown) { + const axiosError = error as { + response?: { data?: { message?: string } }; + }; + const message = + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || + 'Failed to update page name.'; + setEditPageNameError(message); + logger.error( + 'Failed to update page name:', + error instanceof Error ? error : { error }, + ); + } finally { + setIsSavingPageName(false); + } }; const handleCreatePage = async () => { @@ -433,23 +538,22 @@ const TourFlowManager = () => { return; } - const slug = newPageSlug.trim(); - const validationError = slugValidationError || newPageSlugError; - if (validationError) { - setNewPageSlugError(validationError); + const name = newPageName.trim(); + + // Validate name + if (nameValidationError) { return; } try { setIsCreatingPage(true); setErrorMessage(''); - setNewPageSlugError(''); const payload = { project: activeProjectId, environment: targetEnvironment, source_key: '', - name: `Page ${nextPageNumber}`, - slug, + name, + slug: generatedSlug, sort_order: Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1, background_image_url: '', @@ -461,8 +565,7 @@ const TourFlowManager = () => { }; await axios.post('/tour_pages', { data: payload }); - setIsCreatePageModalActive(false); - setNewPageSlug(''); + closeCreatePageModal(); await loadData(); } catch (error: unknown) { const axiosError = error as { @@ -473,7 +576,6 @@ const TourFlowManager = () => { (error instanceof Error ? error.message : null) || 'Failed to create page.'; setErrorMessage(message); - setNewPageSlugError(message); logger.error( 'Failed to create page:', error instanceof Error ? error : { error }, @@ -776,33 +878,66 @@ const TourFlowManager = () => { title='Create page' buttonColor='info' buttonLabel={isCreatingPage ? 'Creating...' : 'Create'} - isConfirmDisabled={Boolean(slugValidationError) || isCreatingPage} + isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage} isActive={isCreatePageModalActive} onConfirm={handleCreatePage} onCancel={isCreatingPage ? undefined : closeCreatePageModal} >
handleSlugChange(event.target.value)} - placeholder='my-page-slug' + value={newPageName} + onChange={(event) => handleNameChange(event.target.value)} + placeholder='Enter page name' className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800' autoFocus + maxLength={255} /> -

- Use lowercase letters, numbers, and hyphens. -

- {(newPageSlugError || slugValidationError) && ( -

- {newPageSlugError || slugValidationError} + {nameValidationError && ( +

+ {nameValidationError} +

+ )} +
+ + + {/* Edit Page Name Modal */} + +
+ + handleEditNameChange(event.target.value)} + placeholder='Enter page name' + className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800' + autoFocus + maxLength={255} + /> + {(editPageNameError || editNameValidationError) && ( +

+ {editPageNameError || editNameValidationError}

)}
@@ -829,6 +964,9 @@ const TourFlowManager = () => { const canDelete = entry.type === 'page' ? canDeletePage : canDeleteTransition; const isDeleting = deletingId === entry.id; + const pageData = entry.type === 'page' + ? pages.find((p) => p.id === entry.id) + : null; return (
  • @@ -854,7 +992,7 @@ const TourFlowManager = () => { } }} > -
    +

    {entry.description}

    @@ -864,17 +1002,29 @@ const TourFlowManager = () => {

    - - handleDelete(event, entry.id, entry.type) - } - disabled={!canDelete || isDeleting} - /> +
    + {entry.type === 'page' && pageData && ( + + openEditPageModal(event, pageData) + } + /> + )} + + handleDelete(event, entry.id, entry.type) + } + disabled={!canDelete || isDeleting} + /> +
  • ); diff --git a/frontend/src/components/UiElements/shared/useElementWrapperStyle.ts b/frontend/src/components/UiElements/shared/useElementWrapperStyle.ts index de913de..afe2d39 100644 --- a/frontend/src/components/UiElements/shared/useElementWrapperStyle.ts +++ b/frontend/src/components/UiElements/shared/useElementWrapperStyle.ts @@ -105,9 +105,16 @@ export function useElementWrapperStyle({ // Build inline style from element properties const inlineStyle = buildElementStyle(element); + // If element has configured padding, use it exclusively (skip default paddingStyle) + // This avoids React warning about conflicting shorthand/non-shorthand properties + const finalStyle = + 'padding' in inlineStyle + ? inlineStyle + : { ...paddingStyle, ...inlineStyle }; + return { className: classNames, - style: { ...paddingStyle, ...inlineStyle }, + style: finalStyle, }; }, [element, isSelected, isEditMode]); } diff --git a/frontend/src/hooks/useConstructorPageActions.ts b/frontend/src/hooks/useConstructorPageActions.ts index 3b3099b..99e01b5 100644 --- a/frontend/src/hooks/useConstructorPageActions.ts +++ b/frontend/src/hooks/useConstructorPageActions.ts @@ -80,8 +80,8 @@ interface UseConstructorPageActionsResult { saveConstructor: () => Promise; /** Save dev content to stage environment */ saveToStage: () => Promise; - /** Create a new page */ - createPage: () => Promise; + /** Create a new page with the given name and slug */ + createPage: (pageName: string, slug: string) => Promise; /** Create a transition (legacy - transitions are now stored on elements) */ createTransition: (params: { name?: string; @@ -256,24 +256,33 @@ export function useConstructorPageActions({ } }, [projectId, saveConstructor, onError, onSuccess]); - const createPage = useCallback(async () => { + const createPage = useCallback(async (pageName: string, slug: string) => { if (!projectId) { onError?.('Project is required.'); return; } + if (!pageName.trim()) { + onError?.('Page name is required.'); + return; + } + + if (!slug.trim()) { + onError?.('Page slug is required.'); + return; + } + const maxSortOrder = Math.max( 0, ...pages.map((item) => Number(item.sort_order || 0)), ); - const nextPageNumber = pages.length + 1; const payload = { project: projectId, environment: activePage?.environment || 'dev', source_key: '', - name: `Page ${nextPageNumber}`, - slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`, + name: pageName.trim(), + slug: slug.trim(), sort_order: maxSortOrder + 1, background_image_url: '', background_video_url: '', diff --git a/frontend/src/lib/slugHelpers.ts b/frontend/src/lib/slugHelpers.ts index bbba202..0706b4a 100644 --- a/frontend/src/lib/slugHelpers.ts +++ b/frontend/src/lib/slugHelpers.ts @@ -47,17 +47,14 @@ export function sanitizeSlug(value: string): string { */ export function buildUniqueSlug( baseValue: string, - usedSlugs: Set, + _usedSlugs: Set, ): string { const baseSlug = sanitizeSlug(baseValue) || 'page'; - if (!usedSlugs.has(baseSlug)) return baseSlug; - let suffix = 2; - while (usedSlugs.has(`${baseSlug}-${suffix}`)) { - suffix += 1; - } - - return `${baseSlug}-${suffix}`; + // Always append timestamp to guarantee uniqueness + // (_usedSlugs kept for API compatibility but not used - cached data may be stale) + const timestamp = Date.now().toString(36); + return `${baseSlug}-${timestamp}`; } /** diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 350192c..83f1d5b 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -19,6 +19,7 @@ import TransitionPreviewOverlay from '../components/Constructor/TransitionPrevie import CanvasElementComponent from '../components/Constructor/CanvasElement'; import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay'; import ElementEditorPanel from '../components/Constructor/ElementEditorPanel'; +import CreatePageModal from '../components/Constructor/CreatePageModal'; import { BackdropPortalProvider } from '../components/BackdropPortal'; import { getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; @@ -286,6 +287,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { currentElementTransitionSettings, setCurrentElementTransitionSettings, ] = useState(null); + // Create page modal state + const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false); const isConstructorEditMode = constructorInteractionMode === 'edit'; const allowedNavigationTypes = useMemo(() => { @@ -368,6 +371,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { [activePageId, pages], ); + // Existing page slugs for uniqueness validation (current environment only) + const existingSlugs = useMemo(() => { + const targetEnv = activePage?.environment || 'dev'; + return new Set( + pages + .filter((p) => (p.environment || 'dev') === targetEnv) + .map((p) => p.slug || '') + .filter(Boolean), + ); + }, [pages, activePage?.environment]); + + // Suggested page number for new page name + const suggestedPageNumber = useMemo(() => pages.length + 1, [pages.length]); + // Last project-level save: most recent updatedAt across all pages const lastProjectSaveAt = useMemo(() => { if (!pages.length) return null; @@ -765,6 +782,25 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { await refreshPublishStatus(); }, [saveToStage, refreshPublishStatus]); + // Handlers for create page modal + const handleShowCreatePageModal = useCallback(async () => { + // Refresh pages data to ensure existingSlugs is up to date + await refetchData(); + setIsCreatePageModalActive(true); + }, [refetchData]); + + const handleCreatePageWithName = useCallback( + async (pageName: string, slug: string) => { + await createPage(pageName, slug); + setIsCreatePageModalActive(false); + }, + [createPage], + ); + + const handleCloseCreatePageModal = useCallback(() => { + setIsCreatePageModalActive(false); + }, []); + useEffect(() => { if (!router.isReady || typeof window === 'undefined') return; @@ -1077,31 +1113,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { updateBackgroundFromPage, ]); - // Separate effect for initial background loading (matches RuntimePresentation pattern) - // This effect ONLY handles initial page load when backgrounds are empty. - // switchToPage handles all subsequent navigation by calling navNavigateToPage directly. - // Keeping this separate prevents race conditions where state updates trigger - // this effect before activePageId has been updated via applyPageSelection. + // Separate effect for background loading when activePage changes. + // This handles both initial page load AND page switches (e.g., after creating a new page). + // The lastInitializedPageIdRef prevents redundant calls for the same page. useEffect(() => { if (!activePage || lastInitializedPageIdRef.current === activePage.id) return; - // Only initialize when backgrounds are EMPTY (initial load) - if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) { - lastInitializedPageIdRef.current = activePage.id; - navNavigateToPage({ - id: activePage.id, - background_image_url: activePage.background_image_url, - background_video_url: activePage.background_video_url, - background_audio_url: activePage.background_audio_url, - }); - } - }, [ - activePage, - navCurrentBgImageUrl, - navCurrentBgVideoUrl, - navNavigateToPage, - ]); + // Update navigation state with new page's backgrounds + lastInitializedPageIdRef.current = activePage.id; + navNavigateToPage({ + id: activePage.id, + background_image_url: activePage.background_image_url, + background_video_url: activePage.background_video_url, + background_audio_url: activePage.background_audio_url, + }); + }, [activePage, navNavigateToPage]); useEffect(() => { if (allowedNavigationTypes.length !== 1) return; @@ -1735,7 +1762,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { onSelectMenuItem={selectMenuItemForEdit} allowedNavigationTypes={allowedNavigationTypes} onAddElement={addElement} - onCreatePage={createPage} + onCreatePage={handleShowCreatePageModal} isCreatingPage={isCreatingPage} onSave={saveConstructor} onSaveToStage={handleSaveToStage} @@ -1844,7 +1871,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { isCreatingPage ? 'Creating...' : 'Create First Page' } icon={mdiPlus} - onClick={createPage} + onClick={handleShowCreatePageModal} disabled={isCreatingPage} /> @@ -1962,6 +1989,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { /> )} + {/* Create Page Modal */} + +