added pages copy and deletion functionality
This commit is contained in:
parent
788342808f
commit
cd588bac8b
@ -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
|
// PUT - Update
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const ValidationError = require('./notifications/errors/validation');
|
|||||||
const videoProcessing = require('./videoProcessing');
|
const videoProcessing = require('./videoProcessing');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const projectRegenInProgress = new Set();
|
const projectRegenInProgress = new Set();
|
||||||
const singleReverseGenerationInProgress = 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 YUV420_BYTES_PER_PIXEL = 1.5;
|
||||||
const MAX_AUTO_REVERSE_ESTIMATED_DECODED_BYTES = 2 * 1024 * 1024 * 1024;
|
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
|
// Create base service from factory
|
||||||
const BaseService = createEntityService(Tour_pagesDBApi, {
|
const BaseService = createEntityService(Tour_pagesDBApi, {
|
||||||
entityName: 'tour_pages',
|
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
|
* Create tour page - generate reversed videos if needed
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import React, { useState, useRef, useEffect, forwardRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
|
mdiDelete,
|
||||||
mdiImageMultiple,
|
mdiImageMultiple,
|
||||||
mdiViewCarousel,
|
mdiViewCarousel,
|
||||||
mdiSwapHorizontal,
|
mdiSwapHorizontal,
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
|
mdiContentDuplicate,
|
||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiVideo,
|
mdiVideo,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
@ -42,6 +44,11 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onMovePage,
|
onMovePage,
|
||||||
isReorderingPages = false,
|
isReorderingPages = false,
|
||||||
|
onDuplicatePage,
|
||||||
|
isDuplicatingPage = false,
|
||||||
|
onDeletePage,
|
||||||
|
canDeletePage = true,
|
||||||
|
isDeletingPage = false,
|
||||||
interactionMode,
|
interactionMode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onSelectMenuItem,
|
onSelectMenuItem,
|
||||||
@ -93,6 +100,17 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
!isReorderingPages &&
|
!isReorderingPages &&
|
||||||
activePageIndex >= 0 &&
|
activePageIndex >= 0 &&
|
||||||
activePageIndex < sortedPages.length - 1;
|
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)
|
// Keyboard handling (Escape closes dropdown)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -195,6 +213,26 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
>
|
>
|
||||||
<BaseIcon path={mdiChevronDown} size={22} />
|
<BaseIcon path={mdiChevronDown} size={22} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle - reuse with compact=true */}
|
{/* Mode Toggle - reuse with compact=true */}
|
||||||
|
|||||||
@ -253,6 +253,11 @@ export interface ConstructorToolbarProps {
|
|||||||
onPageChange: (pageId: string) => void;
|
onPageChange: (pageId: string) => void;
|
||||||
onMovePage?: (direction: 'up' | 'down') => void;
|
onMovePage?: (direction: 'up' | 'down') => void;
|
||||||
isReorderingPages?: boolean;
|
isReorderingPages?: boolean;
|
||||||
|
onDuplicatePage?: () => void;
|
||||||
|
isDuplicatingPage?: boolean;
|
||||||
|
onDeletePage?: () => void;
|
||||||
|
canDeletePage?: boolean;
|
||||||
|
isDeletingPage?: boolean;
|
||||||
|
|
||||||
// Mode toggle (reuse InteractionModeToggle with compact=true)
|
// Mode toggle (reuse InteractionModeToggle with compact=true)
|
||||||
interactionMode: ConstructorInteractionMode;
|
interactionMode: ConstructorInteractionMode;
|
||||||
|
|||||||
@ -81,12 +81,20 @@ interface UseConstructorPageActionsResult {
|
|||||||
isSavingToStage: boolean;
|
isSavingToStage: boolean;
|
||||||
/** Whether page creation is in progress */
|
/** Whether page creation is in progress */
|
||||||
isCreatingPage: boolean;
|
isCreatingPage: boolean;
|
||||||
|
/** Whether page duplication is in progress */
|
||||||
|
isDuplicatingPage: boolean;
|
||||||
/** Save current constructor state */
|
/** Save current constructor state */
|
||||||
saveConstructor: () => Promise<void>;
|
saveConstructor: () => Promise<boolean>;
|
||||||
/** Save dev content to stage environment */
|
/** Save dev content to stage environment */
|
||||||
saveToStage: () => Promise<void>;
|
saveToStage: () => Promise<void>;
|
||||||
/** Create a new page with the given name and slug */
|
/** Create a new page with the given name and slug */
|
||||||
createPage: (pageName: string, slug: string) => Promise<void>;
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
||||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||||
|
const [isDuplicatingPage, setIsDuplicatingPage] = useState(false);
|
||||||
|
|
||||||
// Polling hook for reverse video generation status
|
// Polling hook for reverse video generation status
|
||||||
const { startPolling } = useReverseVideoPolling({
|
const { startPolling } = useReverseVideoPolling({
|
||||||
@ -180,7 +189,7 @@ export function useConstructorPageActions({
|
|||||||
const saveConstructor = useCallback(async () => {
|
const saveConstructor = useCallback(async () => {
|
||||||
if (!activePageId) {
|
if (!activePageId) {
|
||||||
onError?.('Select a page before saving.');
|
onError?.('Select a page before saving.');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -241,6 +250,7 @@ export function useConstructorPageActions({
|
|||||||
});
|
});
|
||||||
startPolling(pendingReverseKeys, activePageId);
|
startPolling(pendingReverseKeys, activePageId);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
response?: { data?: { message?: string } };
|
response?: { data?: { message?: string } };
|
||||||
@ -254,6 +264,7 @@ export function useConstructorPageActions({
|
|||||||
error instanceof Error ? error : { error },
|
error instanceof Error ? error : { error },
|
||||||
);
|
);
|
||||||
onError?.(message);
|
onError?.(message);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@ -283,7 +294,8 @@ export function useConstructorPageActions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveConstructor();
|
const didSave = await saveConstructor();
|
||||||
|
if (!didSave) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingToStage(true);
|
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 {
|
return {
|
||||||
isSaving,
|
isSaving,
|
||||||
isSavingToStage,
|
isSavingToStage,
|
||||||
isCreatingPage,
|
isCreatingPage,
|
||||||
|
isDuplicatingPage,
|
||||||
saveConstructor,
|
saveConstructor,
|
||||||
saveToStage,
|
saveToStage,
|
||||||
createPage,
|
createPage,
|
||||||
|
duplicatePage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import CardBoxModal from '../components/CardBoxModal';
|
||||||
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
||||||
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
|
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
|
||||||
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
|
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
|
||||||
@ -113,6 +114,8 @@ import { useCanvasScale } from '../hooks/useCanvasScale';
|
|||||||
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
||||||
import { useNetworkAware } from '../hooks/useNetworkAware';
|
import { useNetworkAware } from '../hooks/useNetworkAware';
|
||||||
import { queryClient, queryKeys } from '../lib/queryClient';
|
import { queryClient, queryKeys } from '../lib/queryClient';
|
||||||
|
import { buildUniqueSlug } from '../lib/slugHelpers';
|
||||||
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
// TourPage type is imported from '../types/entities'
|
// TourPage type is imported from '../types/entities'
|
||||||
// NavigationElementType is imported from '../context/ConstructorContext'
|
// NavigationElementType is imported from '../context/ConstructorContext'
|
||||||
@ -142,6 +145,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const globalTransitionDefaults = useAppSelector(
|
const globalTransitionDefaults = useAppSelector(
|
||||||
(state) => state.global_transition_defaults.data,
|
(state) => state.global_transition_defaults.data,
|
||||||
);
|
);
|
||||||
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
@ -319,6 +323,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
] = useState<ElementTransitionSettings | null>(null);
|
] = useState<ElementTransitionSettings | null>(null);
|
||||||
// Create page modal state
|
// Create page modal state
|
||||||
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
||||||
|
const [isDeletePageModalActive, setIsDeletePageModalActive] = useState(false);
|
||||||
|
const [isDeletingPage, setIsDeletingPage] = useState(false);
|
||||||
|
|
||||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
@ -464,6 +470,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
|
|
||||||
// Suggested page number for new page name
|
// Suggested page number for new page name
|
||||||
const suggestedPageNumber = useMemo(() => pages.length + 1, [pages.length]);
|
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
|
// Last project-level save: most recent updatedAt across all pages
|
||||||
const lastProjectSaveAt = useMemo(() => {
|
const lastProjectSaveAt = useMemo(() => {
|
||||||
@ -1000,9 +1010,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
isSaving,
|
isSaving,
|
||||||
isSavingToStage,
|
isSavingToStage,
|
||||||
isCreatingPage,
|
isCreatingPage,
|
||||||
|
isDuplicatingPage,
|
||||||
saveConstructor,
|
saveConstructor,
|
||||||
saveToStage,
|
saveToStage,
|
||||||
createPage,
|
createPage,
|
||||||
|
duplicatePage,
|
||||||
} = useConstructorPageActions({
|
} = useConstructorPageActions({
|
||||||
projectId,
|
projectId,
|
||||||
project,
|
project,
|
||||||
@ -1048,6 +1060,104 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setIsCreatePageModalActive(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady || typeof window === 'undefined') return;
|
if (!router.isReady || typeof window === 'undefined') return;
|
||||||
|
|
||||||
@ -1918,7 +2028,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
save: saveConstructor,
|
save: async () => {
|
||||||
|
await saveConstructor();
|
||||||
|
},
|
||||||
isSaving,
|
isSaving,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@ -2017,6 +2129,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}}
|
}}
|
||||||
onMovePage={handleMovePage}
|
onMovePage={handleMovePage}
|
||||||
isReorderingPages={isReorderingPages}
|
isReorderingPages={isReorderingPages}
|
||||||
|
onDuplicatePage={handleDuplicatePage}
|
||||||
|
isDuplicatingPage={isSaving || isDuplicatingPage}
|
||||||
|
onDeletePage={handleShowDeletePageModal}
|
||||||
|
canDeletePage={canDeletePage}
|
||||||
|
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage}
|
||||||
interactionMode={constructorInteractionMode}
|
interactionMode={constructorInteractionMode}
|
||||||
onModeChange={setConstructorInteractionMode}
|
onModeChange={setConstructorInteractionMode}
|
||||||
onSelectMenuItem={selectMenuItemForEdit}
|
onSelectMenuItem={selectMenuItemForEdit}
|
||||||
@ -2402,6 +2519,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onCancel={handleCloseCreatePageModal}
|
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>{`
|
<style jsx>{`
|
||||||
.menu-action-btn {
|
.menu-action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user