added pages copy and deletion functionality

This commit is contained in:
Dmitri 2026-06-27 11:31:43 +02:00
parent 788342808f
commit cd588bac8b
6 changed files with 477 additions and 4 deletions

View File

@ -199,6 +199,23 @@ router.post(
}),
);
// POST - Duplicate a dev page within a project
router.post(
'/:id/duplicate',
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid tour_pages id');
}
const payload = await Tour_pagesService.duplicatePage(
req.params.id,
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}),
);
// PUT - Update
router.put(
'/:id',

View File

@ -14,6 +14,7 @@ const ValidationError = require('./notifications/errors/validation');
const videoProcessing = require('./videoProcessing');
const { logger } = require('../utils/logger');
const db = require('../db/models');
const crypto = require('crypto');
const projectRegenInProgress = new Set();
const singleReverseGenerationInProgress = new Set();
@ -23,6 +24,81 @@ const DEFAULT_AUTO_REVERSE_FALLBACK_FPS = 30;
const YUV420_BYTES_PER_PIXEL = 1.5;
const MAX_AUTO_REVERSE_ESTIMATED_DECODED_BYTES = 2 * 1024 * 1024 * 1024;
const createLocalElementId = () => crypto.randomUUID();
const sanitizeSlug = (value) => {
const slug = String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'page';
};
const buildCopyName = (name) => {
const baseName = String(name || 'Page').trim() || 'Page';
const copyName = `${baseName} Copy`;
return copyName.length > 255 ? copyName.slice(0, 255) : copyName;
};
const parseUiSchema = (uiSchema) => {
if (!uiSchema) return null;
if (typeof uiSchema !== 'string') return JSON.parse(JSON.stringify(uiSchema));
try {
return JSON.parse(uiSchema);
} catch {
return uiSchema;
}
};
const regenerateNestedItemIds = (items) => {
if (!Array.isArray(items)) return items;
return items.map((item) => ({
...item,
id: createLocalElementId(),
}));
};
const regenerateElementInstanceIds = (uiSchema) => {
if (!uiSchema || typeof uiSchema !== 'object') return uiSchema;
const clonedSchema = JSON.parse(JSON.stringify(uiSchema));
if (!Array.isArray(clonedSchema.elements)) return clonedSchema;
clonedSchema.elements = clonedSchema.elements.map((element) => {
const clonedElement = {
...element,
id: createLocalElementId(),
};
clonedElement.galleryCards = regenerateNestedItemIds(
clonedElement.galleryCards,
);
clonedElement.galleryInfoSpans = regenerateNestedItemIds(
clonedElement.galleryInfoSpans,
);
clonedElement.carouselSlides = regenerateNestedItemIds(
clonedElement.carouselSlides,
);
if (Array.isArray(clonedElement.infoPanelSections)) {
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
(section) => ({
...section,
id: `section-${createLocalElementId()}`,
spans: regenerateNestedItemIds(section.spans),
images: regenerateNestedItemIds(section.images),
}),
);
}
return clonedElement;
});
return clonedSchema;
};
// Create base service from factory
const BaseService = createEntityService(Tour_pagesDBApi, {
entityName: 'tour_pages',
@ -238,6 +314,126 @@ class TourPagesService extends BaseService {
});
}
static async buildUniqueDuplicateSlug({
projectId,
environment,
preferredSlug,
sourceSlug,
transaction,
}) {
const baseSlug = sanitizeSlug(
preferredSlug || `${sourceSlug || 'page'} copy`,
);
let candidate = baseSlug;
let counter = 2;
while (counter < 1000) {
const existingPage = await db.tour_pages.findOne({
where: { projectId, environment, slug: candidate },
attributes: ['id'],
transaction,
});
if (!existingPage) {
return candidate;
}
candidate = `${baseSlug}-${counter}`;
counter += 1;
}
return `${baseSlug}-${Date.now().toString(36)}`;
}
static async duplicatePage(sourcePageId, data = {}, currentUser) {
if (!sourcePageId) {
throw new ValidationError('Source page is required');
}
return db.sequelize.transaction(async (transaction) => {
const sourcePage = await db.tour_pages.findOne({
where: { id: sourcePageId },
transaction,
});
if (!sourcePage) {
throw new ValidationError('Source page not found');
}
const source = sourcePage.get({ plain: true });
const projectId = data.projectId || data.project || source.projectId;
const environment = data.environment || source.environment || 'dev';
if (source.projectId !== projectId) {
throw new ValidationError(
'Source page does not belong to the selected project',
);
}
if (source.environment !== 'dev' || environment !== 'dev') {
throw new ValidationError(
'Pages can only be duplicated in the dev environment. Use Save to Stage and Publish to update presentations.',
);
}
const maxSortOrder = await db.tour_pages.max('sort_order', {
where: { projectId, environment },
transaction,
});
const slug = await TourPagesService.buildUniqueDuplicateSlug({
projectId,
environment,
preferredSlug: data.slug,
sourceSlug: source.slug,
transaction,
});
const uiSchema = regenerateElementInstanceIds(
parseUiSchema(source.ui_schema_json),
);
const requestedName = String(data.name || '').trim();
const duplicatePayload = {
project: projectId,
environment,
source_key: '',
name: requestedName
? requestedName.slice(0, 255)
: buildCopyName(source.name),
slug,
sort_order: Number(maxSortOrder || 0) + 1,
background_image_url: source.background_image_url || '',
background_video_url: source.background_video_url || '',
background_embed_url: source.background_embed_url || '',
background_audio_url: source.background_audio_url || '',
background_audio_autoplay: source.background_audio_autoplay,
background_audio_loop: source.background_audio_loop,
background_audio_start_time: source.background_audio_start_time,
background_audio_end_time: source.background_audio_end_time,
background_loop: source.background_loop,
background_video_autoplay: source.background_video_autoplay,
background_video_loop: source.background_video_loop,
background_video_muted: source.background_video_muted,
background_video_start_time: source.background_video_start_time,
background_video_end_time: source.background_video_end_time,
design_width: source.design_width,
design_height: source.design_height,
requires_auth: source.requires_auth,
ui_schema_json: uiSchema,
};
const processedPayload =
await TourPagesService.processReversedVideosAndUpdateSchema(
duplicatePayload,
currentUser,
);
return Tour_pagesDBApi.create(processedPayload, {
currentUser,
transaction,
});
});
}
/**
* Create tour page - generate reversed videos if needed
*/

View File

@ -9,6 +9,7 @@ import React, { useState, useRef, useEffect, forwardRef } from 'react';
import {
mdiDotsVertical,
mdiChevronDown,
mdiDelete,
mdiImageMultiple,
mdiViewCarousel,
mdiSwapHorizontal,
@ -18,6 +19,7 @@ import {
mdiChevronLeft,
mdiChevronRight,
mdiChevronUp,
mdiContentDuplicate,
mdiMusicNote,
mdiVideo,
mdiInformationOutline,
@ -42,6 +44,11 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
onPageChange,
onMovePage,
isReorderingPages = false,
onDuplicatePage,
isDuplicatingPage = false,
onDeletePage,
canDeletePage = true,
isDeletingPage = false,
interactionMode,
onModeChange,
onSelectMenuItem,
@ -93,6 +100,17 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
!isReorderingPages &&
activePageIndex >= 0 &&
activePageIndex < sortedPages.length - 1;
const canDuplicatePage =
Boolean(onDuplicatePage) &&
!isDuplicatingPage &&
!isReorderingPages &&
activePageIndex >= 0;
const canDeleteCurrentPage =
Boolean(onDeletePage) &&
canDeletePage &&
!isDeletingPage &&
!isReorderingPages &&
activePageIndex >= 0;
// Keyboard handling (Escape closes dropdown)
useEffect(() => {
@ -195,6 +213,26 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
>
<BaseIcon path={mdiChevronDown} size={22} />
</button>
<button
type='button'
onClick={onDuplicatePage}
disabled={!canDuplicatePage}
className='flex h-9 w-9 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35'
title='Duplicate page'
aria-label='Duplicate page'
>
<BaseIcon path={mdiContentDuplicate} size={20} />
</button>
<button
type='button'
onClick={onDeletePage}
disabled={!canDeleteCurrentPage}
className='flex h-9 w-9 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page'
aria-label='Delete page'
>
<BaseIcon path={mdiDelete} size={20} />
</button>
</div>
{/* Mode Toggle - reuse with compact=true */}

View File

@ -253,6 +253,11 @@ export interface ConstructorToolbarProps {
onPageChange: (pageId: string) => void;
onMovePage?: (direction: 'up' | 'down') => void;
isReorderingPages?: boolean;
onDuplicatePage?: () => void;
isDuplicatingPage?: boolean;
onDeletePage?: () => void;
canDeletePage?: boolean;
isDeletingPage?: boolean;
// Mode toggle (reuse InteractionModeToggle with compact=true)
interactionMode: ConstructorInteractionMode;

View File

@ -81,12 +81,20 @@ interface UseConstructorPageActionsResult {
isSavingToStage: boolean;
/** Whether page creation is in progress */
isCreatingPage: boolean;
/** Whether page duplication is in progress */
isDuplicatingPage: boolean;
/** Save current constructor state */
saveConstructor: () => Promise<void>;
saveConstructor: () => Promise<boolean>;
/** Save dev content to stage environment */
saveToStage: () => Promise<void>;
/** Create a new page with the given name and slug */
createPage: (pageName: string, slug: string) => Promise<void>;
/** Duplicate an existing page with the given name and slug */
duplicatePage: (
sourcePageId: string,
pageName: string,
slug: string,
) => Promise<TourPage | null>;
}
/**
@ -147,6 +155,7 @@ export function useConstructorPageActions({
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isDuplicatingPage, setIsDuplicatingPage] = useState(false);
// Polling hook for reverse video generation status
const { startPolling } = useReverseVideoPolling({
@ -180,7 +189,7 @@ export function useConstructorPageActions({
const saveConstructor = useCallback(async () => {
if (!activePageId) {
onError?.('Select a page before saving.');
return;
return false;
}
try {
@ -241,6 +250,7 @@ export function useConstructorPageActions({
});
startPolling(pendingReverseKeys, activePageId);
}
return true;
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } };
@ -254,6 +264,7 @@ export function useConstructorPageActions({
error instanceof Error ? error : { error },
);
onError?.(message);
return false;
} finally {
setIsSaving(false);
}
@ -283,7 +294,8 @@ export function useConstructorPageActions({
return;
}
await saveConstructor();
const didSave = await saveConstructor();
if (!didSave) return;
try {
setIsSavingToStage(true);
@ -394,13 +406,83 @@ export function useConstructorPageActions({
],
);
const duplicatePage = useCallback(
async (sourcePageId: string, pageName: string, slug: string) => {
if (!projectId) {
onError?.('Project is required.');
return null;
}
if (!sourcePageId) {
onError?.('Select a page before duplicating.');
return null;
}
if (!pageName.trim()) {
onError?.('Page name is required.');
return null;
}
if (!slug.trim()) {
onError?.('Page slug is required.');
return null;
}
try {
setIsDuplicatingPage(true);
const response = await axios.post(
`/tour_pages/${sourcePageId}/duplicate`,
{
data: {
projectId,
environment: 'dev',
name: pageName.trim(),
slug: slug.trim(),
},
},
);
const createdPage = response?.data as TourPage | null;
await onReload(createdPage?.id);
if (createdPage?.id) {
onSetActivePageId(createdPage.id);
}
return createdPage;
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } | string };
};
const responseData = axiosError?.response?.data;
const message =
(typeof responseData === 'string'
? responseData
: responseData?.message) ||
(error instanceof Error ? error.message : null) ||
'Failed to duplicate page.';
logger.error(
'Failed to duplicate page:',
error instanceof Error ? error : { error },
);
onError?.(message);
return null;
} finally {
setIsDuplicatingPage(false);
}
},
[projectId, onError, onReload, onSetActivePageId],
);
return {
isSaving,
isSavingToStage,
isCreatingPage,
isDuplicatingPage,
saveConstructor,
saveToStage,
createPage,
duplicatePage,
};
}

View File

@ -12,6 +12,7 @@ import React, {
} from 'react';
import { flushSync } from 'react-dom';
import BaseButton from '../components/BaseButton';
import CardBoxModal from '../components/CardBoxModal';
import CanvasBackground from '../components/Constructor/CanvasBackground';
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
@ -113,6 +114,8 @@ import { useCanvasScale } from '../hooks/useCanvasScale';
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
import { useNetworkAware } from '../hooks/useNetworkAware';
import { queryClient, queryKeys } from '../lib/queryClient';
import { buildUniqueSlug } from '../lib/slugHelpers';
import { hasPermission } from '../helpers/userPermissions';
// TourPage type is imported from '../types/entities'
// NavigationElementType is imported from '../context/ConstructorContext'
@ -142,6 +145,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const globalTransitionDefaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
@ -319,6 +323,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
] = useState<ElementTransitionSettings | null>(null);
// Create page modal state
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
const [isDeletePageModalActive, setIsDeletePageModalActive] = useState(false);
const [isDeletingPage, setIsDeletingPage] = useState(false);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
@ -464,6 +470,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Suggested page number for new page name
const suggestedPageNumber = useMemo(() => pages.length + 1, [pages.length]);
const canDeletePage = useMemo(
() => hasPermission(currentUser, 'DELETE_TOUR_PAGES'),
[currentUser],
);
// Last project-level save: most recent updatedAt across all pages
const lastProjectSaveAt = useMemo(() => {
@ -1000,9 +1010,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isSaving,
isSavingToStage,
isCreatingPage,
isDuplicatingPage,
saveConstructor,
saveToStage,
createPage,
duplicatePage,
} = useConstructorPageActions({
projectId,
project,
@ -1048,6 +1060,104 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setIsCreatePageModalActive(false);
}, []);
const handleDuplicatePage = useCallback(async () => {
if (!activePageId || !activePage) {
setErrorMessage('Select a page before duplicating.');
return;
}
const didSave = await saveConstructor();
if (!didSave) return;
const pageName = `${activePage.name?.trim() || 'Page'} Copy`;
const slug = buildUniqueSlug(pageName, existingSlugs);
const createdPage = await duplicatePage(activePageId, pageName, slug);
if (createdPage?.id) {
setSuccessMessage('Page duplicated.');
}
}, [
activePage,
activePageId,
duplicatePage,
existingSlugs,
saveConstructor,
]);
const handleShowDeletePageModal = useCallback(() => {
if (!activePageId || !activePage) {
setErrorMessage('Select a page before deleting.');
return;
}
setIsDeletePageModalActive(true);
}, [activePage, activePageId]);
const handleCloseDeletePageModal = useCallback(() => {
if (!isDeletingPage) {
setIsDeletePageModalActive(false);
}
}, [isDeletingPage]);
const handleDeletePage = useCallback(async () => {
if (!activePageId || !activePage) {
setErrorMessage('Select a page before deleting.');
return;
}
const sortedPages = sortTourPagesForDisplay(pages);
const currentIndex = sortedPages.findIndex(
(page) => page.id === activePageId,
);
const remainingPages = sortedPages.filter((page) => page.id !== activePageId);
const fallbackPage =
currentIndex >= 0
? remainingPages[Math.min(currentIndex, remainingPages.length - 1)]
: remainingPages[0];
try {
setIsDeletingPage(true);
setErrorMessage('');
await axios.delete(`/tour_pages/${activePageId}`);
await queryClient.invalidateQueries({
queryKey: queryKeys.tourPages.all,
});
clearSelection();
setSelectedMenuItem('none');
await refetchData();
setActivePageId(fallbackPage?.id || '');
setIsDeletePageModalActive(false);
setSuccessMessage('Page deleted.');
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } | string };
};
const responseData = axiosError?.response?.data;
const message =
(typeof responseData === 'string'
? responseData
: responseData?.message) ||
(error instanceof Error ? error.message : null) ||
'Failed to delete page.';
setErrorMessage(message);
logger.error(
'Failed to delete page:',
error instanceof Error ? error : { error },
);
} finally {
setIsDeletingPage(false);
}
}, [
activePage,
activePageId,
clearSelection,
pages,
refetchData,
setActivePageId,
]);
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
@ -1918,7 +2028,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
normalizeNavigationType,
// Actions
save: saveConstructor,
save: async () => {
await saveConstructor();
},
isSaving,
}),
[
@ -2017,6 +2129,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}}
onMovePage={handleMovePage}
isReorderingPages={isReorderingPages}
onDuplicatePage={handleDuplicatePage}
isDuplicatingPage={isSaving || isDuplicatingPage}
onDeletePage={handleShowDeletePageModal}
canDeletePage={canDeletePage}
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage}
interactionMode={constructorInteractionMode}
onModeChange={setConstructorInteractionMode}
onSelectMenuItem={selectMenuItemForEdit}
@ -2402,6 +2519,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onCancel={handleCloseCreatePageModal}
/>
<CardBoxModal
title='Delete page'
buttonColor='danger'
buttonLabel={isDeletingPage ? 'Deleting...' : 'Delete'}
isConfirmDisabled={isDeletingPage}
isActive={isDeletePageModalActive}
onConfirm={handleDeletePage}
onCancel={isDeletingPage ? undefined : handleCloseDeletePageModal}
>
<p className='text-sm text-gray-700 dark:text-gray-200'>
Delete {activePage?.name || 'this page'} from this presentation?
</p>
<p className='text-xs text-gray-500'>
This removes the dev page immediately. Stage and production are
updated only after Save to Stage and Publish.
</p>
</CardBoxModal>
<style jsx>{`
.menu-action-btn {
width: 100%;