diff --git a/backend/src/routes/tour_pages.js b/backend/src/routes/tour_pages.js index 8608257..4470fc2 100644 --- a/backend/src/routes/tour_pages.js +++ b/backend/src/routes/tour_pages.js @@ -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', diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index 4b4e60c..1512641 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -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 */ diff --git a/frontend/src/components/Constructor/ConstructorToolbar.tsx b/frontend/src/components/Constructor/ConstructorToolbar.tsx index dd043ea..25f3a28 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbar.tsx @@ -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( onPageChange, onMovePage, isReorderingPages = false, + onDuplicatePage, + isDuplicatingPage = false, + onDeletePage, + canDeletePage = true, + isDeletingPage = false, interactionMode, onModeChange, onSelectMenuItem, @@ -93,6 +100,17 @@ const ConstructorToolbar = forwardRef( !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( > + + {/* Mode Toggle - reuse with compact=true */} diff --git a/frontend/src/components/Constructor/types.ts b/frontend/src/components/Constructor/types.ts index 6df01e6..3dea6de 100644 --- a/frontend/src/components/Constructor/types.ts +++ b/frontend/src/components/Constructor/types.ts @@ -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; diff --git a/frontend/src/hooks/useConstructorPageActions.ts b/frontend/src/hooks/useConstructorPageActions.ts index c81b6fe..bd7a555 100644 --- a/frontend/src/hooks/useConstructorPageActions.ts +++ b/frontend/src/hooks/useConstructorPageActions.ts @@ -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; + saveConstructor: () => Promise; /** Save dev content to stage environment */ saveToStage: () => Promise; /** Create a new page with the given name and slug */ createPage: (pageName: string, slug: string) => Promise; + /** Duplicate an existing page with the given name and slug */ + duplicatePage: ( + sourcePageId: string, + pageName: string, + slug: string, + ) => Promise; } /** @@ -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, }; } diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 2791656..807ea5c 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -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(null); const elementEditorRef = useRef(null); const toolbarRef = useRef(null); @@ -319,6 +323,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ] = useState(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(() => { @@ -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} /> + +

+ Delete {activePage?.name || 'this page'} from this presentation? +

+

+ This removes the dev page immediately. Stage and production are + updated only after Save to Stage and Publish. +

+
+