updated page dropdown in the constructor
This commit is contained in:
parent
06a29dbf6a
commit
027af5082b
123
frontend/src/components/Constructor/CreatePageModal.tsx
Normal file
123
frontend/src/components/Constructor/CreatePageModal.tsx
Normal file
@ -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<string>;
|
||||||
|
/** 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<CreatePageModalProps> = ({
|
||||||
|
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 (
|
||||||
|
<CardBoxModal
|
||||||
|
title="Create page"
|
||||||
|
buttonColor="info"
|
||||||
|
buttonLabel={isCreating ? 'Creating...' : 'Create'}
|
||||||
|
isConfirmDisabled={isConfirmDisabled}
|
||||||
|
isActive={isActive}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={isCreating ? undefined : handleCancel}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-page-name"
|
||||||
|
className="block text-sm font-semibold mb-1"
|
||||||
|
>
|
||||||
|
Page name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-page-name"
|
||||||
|
type="text"
|
||||||
|
value={pageName}
|
||||||
|
onChange={(e) => 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) && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{nameError || nameValidationError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBoxModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreatePageModal;
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
mdiViewDashboard,
|
mdiViewDashboard,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
|
mdiPencil,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import Icon from '@mdi/react';
|
import Icon from '@mdi/react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@ -22,7 +23,7 @@ import { getPageTitle } from '../config';
|
|||||||
import { hasPermission } from '../helpers/userPermissions';
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers';
|
||||||
import {
|
import {
|
||||||
toRoutePath,
|
toRoutePath,
|
||||||
compareRoutes,
|
compareRoutes,
|
||||||
@ -114,10 +115,15 @@ const TourFlowManager = () => {
|
|||||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||||
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
||||||
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
||||||
const [newPageSlug, setNewPageSlug] = useState('');
|
const [newPageName, setNewPageName] = useState('');
|
||||||
const [newPageSlugError, setNewPageSlugError] = useState('');
|
|
||||||
const [deletingId, setDeletingId] = useState('');
|
const [deletingId, setDeletingId] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = 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
|
// Use selector for current project's dev transition settings
|
||||||
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
||||||
@ -337,15 +343,25 @@ const TourFlowManager = () => {
|
|||||||
[pages, targetEnvironment],
|
[pages, targetEnvironment],
|
||||||
);
|
);
|
||||||
|
|
||||||
const slugValidationError = useMemo(() => {
|
const nameValidationError = useMemo(() => {
|
||||||
const slug = newPageSlug.trim();
|
const name = newPageName.trim();
|
||||||
if (!slug) return 'Slug is required.';
|
if (!name) return 'Page name is required.';
|
||||||
if (!slugPattern.test(slug))
|
if (name.length > 255) return 'Page name must be 255 characters or less.';
|
||||||
return 'Use lowercase letters, numbers, and hyphens only.';
|
|
||||||
if (pageSlugsInEnvironment.has(slug))
|
|
||||||
return 'This slug already exists in the selected environment.';
|
|
||||||
return '';
|
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]);
|
const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]);
|
||||||
|
|
||||||
@ -407,24 +423,113 @@ const TourFlowManager = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestedSlug = buildUniqueSlug(
|
const suggestedName = `Page ${nextPageNumber}`;
|
||||||
`page-${nextPageNumber}`,
|
setNewPageName(suggestedName);
|
||||||
pageSlugsInEnvironment,
|
|
||||||
);
|
|
||||||
setNewPageSlug(suggestedSlug);
|
|
||||||
setNewPageSlugError('');
|
|
||||||
setIsCreatePageModalActive(true);
|
setIsCreatePageModalActive(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSlugChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
setNewPageSlug(value);
|
setNewPageName(value);
|
||||||
setNewPageSlugError('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCreatePageModal = () => {
|
const closeCreatePageModal = () => {
|
||||||
setIsCreatePageModalActive(false);
|
setIsCreatePageModalActive(false);
|
||||||
setNewPageSlug('');
|
setNewPageName('');
|
||||||
setNewPageSlugError('');
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
const handleCreatePage = async () => {
|
||||||
@ -433,23 +538,22 @@ const TourFlowManager = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = newPageSlug.trim();
|
const name = newPageName.trim();
|
||||||
const validationError = slugValidationError || newPageSlugError;
|
|
||||||
if (validationError) {
|
// Validate name
|
||||||
setNewPageSlugError(validationError);
|
if (nameValidationError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsCreatingPage(true);
|
setIsCreatingPage(true);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setNewPageSlugError('');
|
|
||||||
const payload = {
|
const payload = {
|
||||||
project: activeProjectId,
|
project: activeProjectId,
|
||||||
environment: targetEnvironment,
|
environment: targetEnvironment,
|
||||||
source_key: '',
|
source_key: '',
|
||||||
name: `Page ${nextPageNumber}`,
|
name,
|
||||||
slug,
|
slug: generatedSlug,
|
||||||
sort_order:
|
sort_order:
|
||||||
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
|
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
|
||||||
background_image_url: '',
|
background_image_url: '',
|
||||||
@ -461,8 +565,7 @@ const TourFlowManager = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await axios.post('/tour_pages', { data: payload });
|
await axios.post('/tour_pages', { data: payload });
|
||||||
setIsCreatePageModalActive(false);
|
closeCreatePageModal();
|
||||||
setNewPageSlug('');
|
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
@ -473,7 +576,6 @@ const TourFlowManager = () => {
|
|||||||
(error instanceof Error ? error.message : null) ||
|
(error instanceof Error ? error.message : null) ||
|
||||||
'Failed to create page.';
|
'Failed to create page.';
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
setNewPageSlugError(message);
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Failed to create page:',
|
'Failed to create page:',
|
||||||
error instanceof Error ? error : { error },
|
error instanceof Error ? error : { error },
|
||||||
@ -776,33 +878,66 @@ const TourFlowManager = () => {
|
|||||||
title='Create page'
|
title='Create page'
|
||||||
buttonColor='info'
|
buttonColor='info'
|
||||||
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
|
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
|
||||||
isConfirmDisabled={Boolean(slugValidationError) || isCreatingPage}
|
isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage}
|
||||||
isActive={isCreatePageModalActive}
|
isActive={isCreatePageModalActive}
|
||||||
onConfirm={handleCreatePage}
|
onConfirm={handleCreatePage}
|
||||||
onCancel={isCreatingPage ? undefined : closeCreatePageModal}
|
onCancel={isCreatingPage ? undefined : closeCreatePageModal}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor='new-page-slug'
|
htmlFor='new-page-name'
|
||||||
className='block text-sm font-semibold mb-1'
|
className='block text-sm font-semibold mb-1'
|
||||||
>
|
>
|
||||||
Page slug
|
Page name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id='new-page-slug'
|
id='new-page-name'
|
||||||
type='text'
|
type='text'
|
||||||
value={newPageSlug}
|
value={newPageName}
|
||||||
onChange={(event) => handleSlugChange(event.target.value)}
|
onChange={(event) => handleNameChange(event.target.value)}
|
||||||
placeholder='my-page-slug'
|
placeholder='Enter page name'
|
||||||
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
|
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
|
||||||
autoFocus
|
autoFocus
|
||||||
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
<p className='text-xs text-gray-500 mt-2'>
|
{nameValidationError && (
|
||||||
Use lowercase letters, numbers, and hyphens.
|
<p className='text-xs text-red-600 mt-1'>
|
||||||
|
{nameValidationError}
|
||||||
</p>
|
</p>
|
||||||
{(newPageSlugError || slugValidationError) && (
|
)}
|
||||||
<p className='text-xs text-red-600 mt-2'>
|
</div>
|
||||||
{newPageSlugError || slugValidationError}
|
</CardBoxModal>
|
||||||
|
|
||||||
|
{/* Edit Page Name Modal */}
|
||||||
|
<CardBoxModal
|
||||||
|
title='Edit page name'
|
||||||
|
buttonColor='info'
|
||||||
|
buttonLabel={isSavingPageName ? 'Saving...' : 'Save'}
|
||||||
|
isConfirmDisabled={Boolean(editNameValidationError) || isSavingPageName}
|
||||||
|
isActive={isEditPageModalActive}
|
||||||
|
onConfirm={handleSavePageName}
|
||||||
|
onCancel={isSavingPageName ? undefined : closeEditPageModal}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor='edit-page-name'
|
||||||
|
className='block text-sm font-semibold mb-1'
|
||||||
|
>
|
||||||
|
Page name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='edit-page-name'
|
||||||
|
type='text'
|
||||||
|
value={editPageName}
|
||||||
|
onChange={(event) => 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) && (
|
||||||
|
<p className='text-xs text-red-600 mt-1'>
|
||||||
|
{editPageNameError || editNameValidationError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -829,6 +964,9 @@ const TourFlowManager = () => {
|
|||||||
const canDelete =
|
const canDelete =
|
||||||
entry.type === 'page' ? canDeletePage : canDeleteTransition;
|
entry.type === 'page' ? canDeletePage : canDeleteTransition;
|
||||||
const isDeleting = deletingId === entry.id;
|
const isDeleting = deletingId === entry.id;
|
||||||
|
const pageData = entry.type === 'page'
|
||||||
|
? pages.find((p) => p.id === entry.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={`${entry.type}-${entry.id}`}>
|
<li key={`${entry.type}-${entry.id}`}>
|
||||||
@ -854,7 +992,7 @@ const TourFlowManager = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className='pr-8'>
|
<div className='pr-20'>
|
||||||
<p className='text-xs uppercase text-gray-500 mb-1'>
|
<p className='text-xs uppercase text-gray-500 mb-1'>
|
||||||
{entry.description}
|
{entry.description}
|
||||||
</p>
|
</p>
|
||||||
@ -864,8 +1002,19 @@ const TourFlowManager = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='absolute top-3 right-3 flex gap-1'>
|
||||||
|
{entry.type === 'page' && pageData && (
|
||||||
|
<BaseButton
|
||||||
|
icon={mdiPencil}
|
||||||
|
color='info'
|
||||||
|
outline
|
||||||
|
small
|
||||||
|
onClick={(event) =>
|
||||||
|
openEditPageModal(event, pageData)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className='!absolute top-3 right-3'
|
|
||||||
icon={mdiClose}
|
icon={mdiClose}
|
||||||
color='danger'
|
color='danger'
|
||||||
outline
|
outline
|
||||||
@ -876,6 +1025,7 @@ const TourFlowManager = () => {
|
|||||||
disabled={!canDelete || isDeleting}
|
disabled={!canDelete || isDeleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -105,9 +105,16 @@ export function useElementWrapperStyle({
|
|||||||
// Build inline style from element properties
|
// Build inline style from element properties
|
||||||
const inlineStyle = buildElementStyle(element);
|
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 {
|
return {
|
||||||
className: classNames,
|
className: classNames,
|
||||||
style: { ...paddingStyle, ...inlineStyle },
|
style: finalStyle,
|
||||||
};
|
};
|
||||||
}, [element, isSelected, isEditMode]);
|
}, [element, isSelected, isEditMode]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,8 +80,8 @@ interface UseConstructorPageActionsResult {
|
|||||||
saveConstructor: () => Promise<void>;
|
saveConstructor: () => Promise<void>;
|
||||||
/** Save dev content to stage environment */
|
/** Save dev content to stage environment */
|
||||||
saveToStage: () => Promise<void>;
|
saveToStage: () => Promise<void>;
|
||||||
/** Create a new page */
|
/** Create a new page with the given name and slug */
|
||||||
createPage: () => Promise<void>;
|
createPage: (pageName: string, slug: string) => Promise<void>;
|
||||||
/** Create a transition (legacy - transitions are now stored on elements) */
|
/** Create a transition (legacy - transitions are now stored on elements) */
|
||||||
createTransition: (params: {
|
createTransition: (params: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -256,24 +256,33 @@ export function useConstructorPageActions({
|
|||||||
}
|
}
|
||||||
}, [projectId, saveConstructor, onError, onSuccess]);
|
}, [projectId, saveConstructor, onError, onSuccess]);
|
||||||
|
|
||||||
const createPage = useCallback(async () => {
|
const createPage = useCallback(async (pageName: string, slug: string) => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
onError?.('Project is required.');
|
onError?.('Project is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!pageName.trim()) {
|
||||||
|
onError?.('Page name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug.trim()) {
|
||||||
|
onError?.('Page slug is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const maxSortOrder = Math.max(
|
const maxSortOrder = Math.max(
|
||||||
0,
|
0,
|
||||||
...pages.map((item) => Number(item.sort_order || 0)),
|
...pages.map((item) => Number(item.sort_order || 0)),
|
||||||
);
|
);
|
||||||
const nextPageNumber = pages.length + 1;
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
project: projectId,
|
project: projectId,
|
||||||
environment: activePage?.environment || 'dev',
|
environment: activePage?.environment || 'dev',
|
||||||
source_key: '',
|
source_key: '',
|
||||||
name: `Page ${nextPageNumber}`,
|
name: pageName.trim(),
|
||||||
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
|
slug: slug.trim(),
|
||||||
sort_order: maxSortOrder + 1,
|
sort_order: maxSortOrder + 1,
|
||||||
background_image_url: '',
|
background_image_url: '',
|
||||||
background_video_url: '',
|
background_video_url: '',
|
||||||
|
|||||||
@ -47,17 +47,14 @@ export function sanitizeSlug(value: string): string {
|
|||||||
*/
|
*/
|
||||||
export function buildUniqueSlug(
|
export function buildUniqueSlug(
|
||||||
baseValue: string,
|
baseValue: string,
|
||||||
usedSlugs: Set<string>,
|
_usedSlugs: Set<string>,
|
||||||
): string {
|
): string {
|
||||||
const baseSlug = sanitizeSlug(baseValue) || 'page';
|
const baseSlug = sanitizeSlug(baseValue) || 'page';
|
||||||
if (!usedSlugs.has(baseSlug)) return baseSlug;
|
|
||||||
|
|
||||||
let suffix = 2;
|
// Always append timestamp to guarantee uniqueness
|
||||||
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
|
// (_usedSlugs kept for API compatibility but not used - cached data may be stale)
|
||||||
suffix += 1;
|
const timestamp = Date.now().toString(36);
|
||||||
}
|
return `${baseSlug}-${timestamp}`;
|
||||||
|
|
||||||
return `${baseSlug}-${suffix}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import TransitionPreviewOverlay from '../components/Constructor/TransitionPrevie
|
|||||||
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||||
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
||||||
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||||
|
import CreatePageModal from '../components/Constructor/CreatePageModal';
|
||||||
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
import { BackdropPortalProvider } from '../components/BackdropPortal';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
@ -286,6 +287,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
currentElementTransitionSettings,
|
currentElementTransitionSettings,
|
||||||
setCurrentElementTransitionSettings,
|
setCurrentElementTransitionSettings,
|
||||||
] = useState<ElementTransitionSettings | null>(null);
|
] = useState<ElementTransitionSettings | null>(null);
|
||||||
|
// Create page modal state
|
||||||
|
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
||||||
|
|
||||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
@ -368,6 +371,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
[activePageId, pages],
|
[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
|
// Last project-level save: most recent updatedAt across all pages
|
||||||
const lastProjectSaveAt = useMemo(() => {
|
const lastProjectSaveAt = useMemo(() => {
|
||||||
if (!pages.length) return null;
|
if (!pages.length) return null;
|
||||||
@ -765,6 +782,25 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
await refreshPublishStatus();
|
await refreshPublishStatus();
|
||||||
}, [saveToStage, 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(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady || typeof window === 'undefined') return;
|
if (!router.isReady || typeof window === 'undefined') return;
|
||||||
|
|
||||||
@ -1077,17 +1113,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateBackgroundFromPage,
|
updateBackgroundFromPage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Separate effect for initial background loading (matches RuntimePresentation pattern)
|
// Separate effect for background loading when activePage changes.
|
||||||
// This effect ONLY handles initial page load when backgrounds are empty.
|
// This handles both initial page load AND page switches (e.g., after creating a new page).
|
||||||
// switchToPage handles all subsequent navigation by calling navNavigateToPage directly.
|
// The lastInitializedPageIdRef prevents redundant calls for the same page.
|
||||||
// Keeping this separate prevents race conditions where state updates trigger
|
|
||||||
// this effect before activePageId has been updated via applyPageSelection.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePage || lastInitializedPageIdRef.current === activePage.id)
|
if (!activePage || lastInitializedPageIdRef.current === activePage.id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only initialize when backgrounds are EMPTY (initial load)
|
// Update navigation state with new page's backgrounds
|
||||||
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
|
|
||||||
lastInitializedPageIdRef.current = activePage.id;
|
lastInitializedPageIdRef.current = activePage.id;
|
||||||
navNavigateToPage({
|
navNavigateToPage({
|
||||||
id: activePage.id,
|
id: activePage.id,
|
||||||
@ -1095,13 +1128,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
background_video_url: activePage.background_video_url,
|
background_video_url: activePage.background_video_url,
|
||||||
background_audio_url: activePage.background_audio_url,
|
background_audio_url: activePage.background_audio_url,
|
||||||
});
|
});
|
||||||
}
|
}, [activePage, navNavigateToPage]);
|
||||||
}, [
|
|
||||||
activePage,
|
|
||||||
navCurrentBgImageUrl,
|
|
||||||
navCurrentBgVideoUrl,
|
|
||||||
navNavigateToPage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowedNavigationTypes.length !== 1) return;
|
if (allowedNavigationTypes.length !== 1) return;
|
||||||
@ -1735,7 +1762,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onSelectMenuItem={selectMenuItemForEdit}
|
onSelectMenuItem={selectMenuItemForEdit}
|
||||||
allowedNavigationTypes={allowedNavigationTypes}
|
allowedNavigationTypes={allowedNavigationTypes}
|
||||||
onAddElement={addElement}
|
onAddElement={addElement}
|
||||||
onCreatePage={createPage}
|
onCreatePage={handleShowCreatePageModal}
|
||||||
isCreatingPage={isCreatingPage}
|
isCreatingPage={isCreatingPage}
|
||||||
onSave={saveConstructor}
|
onSave={saveConstructor}
|
||||||
onSaveToStage={handleSaveToStage}
|
onSaveToStage={handleSaveToStage}
|
||||||
@ -1844,7 +1871,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
isCreatingPage ? 'Creating...' : 'Create First Page'
|
isCreatingPage ? 'Creating...' : 'Create First Page'
|
||||||
}
|
}
|
||||||
icon={mdiPlus}
|
icon={mdiPlus}
|
||||||
onClick={createPage}
|
onClick={handleShowCreatePageModal}
|
||||||
disabled={isCreatingPage}
|
disabled={isCreatingPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1962,6 +1989,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Page Modal */}
|
||||||
|
<CreatePageModal
|
||||||
|
isActive={isCreatePageModalActive}
|
||||||
|
isCreating={isCreatingPage}
|
||||||
|
existingSlugs={existingSlugs}
|
||||||
|
suggestedPageNumber={suggestedPageNumber}
|
||||||
|
onConfirm={handleCreatePageWithName}
|
||||||
|
onCancel={handleCloseCreatePageModal}
|
||||||
|
/>
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.menu-action-btn {
|
.menu-action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user