39948-vm/frontend/src/hooks/useConstructorPageActions.ts
Dmitri 6413c7bdf0 implemented:
- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL)
- toggle for info panel media opening (fullscreen or in the panel)
- ability to replace background with info panel media (image, video, 360 panorama)
- ability to make 360 panorama as page background
- global mute button
-
2026-06-15 07:50:45 +02:00

408 lines
12 KiB
TypeScript

/**
* useConstructorPageActions Hook
*
* Handles page create/save/publish operations in the constructor.
* Manages async state and API calls for page operations.
*/
import { useState, useCallback } from 'react';
import axios from 'axios';
import type { CanvasElement, PageBackgroundState } from '../types/constructor';
import { createLocalId } from '../lib/elementDefaults';
import { parseJsonObject } from '../lib/parseJson';
import { logger } from '../lib/logger';
import { useReverseVideoPolling } from './useReverseVideoPolling';
interface TourPage {
id: string;
name?: string;
slug?: string;
sort_order?: number;
environment?: string;
source_key?: string;
requires_auth?: boolean;
ui_schema_json?: string;
background_image_url?: string;
background_video_url?: string;
background_embed_url?: string;
background_audio_url?: string;
background_loop?: boolean;
// Background video playback settings
background_video_autoplay?: boolean;
background_video_loop?: boolean;
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
// Background audio playback settings
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
}
interface Project {
id?: string;
name?: string;
design_width?: number;
design_height?: number;
}
interface UseConstructorPageActionsOptions {
/** Current project ID */
projectId: string;
/** Current project (for design dimensions) */
project?: Project | null;
/** Array of all pages */
pages: TourPage[];
/** Currently active page */
activePage: TourPage | null;
/** Current active page ID */
activePageId: string;
/** Current elements array */
elements: CanvasElement[];
/** Consolidated page background state */
pageBackground: PageBackgroundState;
/** Callback to reload data after operations */
onReload: (preservePageId?: string) => Promise<void>;
/** Callback to set active page ID */
onSetActivePageId: (pageId: string) => void;
/** Callback to set menu open state */
onSetMenuOpen?: (isOpen: boolean) => void;
/** Callback for error messages */
onError?: (message: string) => void;
/** Callback for success messages */
onSuccess?: (message: string) => void;
}
interface UseConstructorPageActionsResult {
/** Whether save operation is in progress */
isSaving: boolean;
/** Whether save-to-stage operation is in progress */
isSavingToStage: boolean;
/** Whether page creation is in progress */
isCreatingPage: boolean;
/** Save current constructor state */
saveConstructor: () => Promise<void>;
/** 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>;
}
/**
* Hook for managing constructor page operations.
*
* @example
* const {
* isSaving,
* saveConstructor,
* createPage,
* isCreatingPage,
* } = useConstructorPageActions({
* projectId,
* pages,
* activePage,
* activePageId,
* elements,
* pageBackground: background,
* onReload: loadData,
* onSetActivePageId: setActivePageId,
* onError: setErrorMessage,
* onSuccess: setSuccessMessage,
* });
*/
export function useConstructorPageActions({
projectId,
project,
pages,
activePage,
activePageId,
elements,
pageBackground,
onReload,
onSetActivePageId,
onSetMenuOpen,
onError,
onSuccess,
}: UseConstructorPageActionsOptions): UseConstructorPageActionsResult {
// Destructure pageBackground for backward compatibility in the save logic
const {
imageUrl: backgroundImageUrl,
videoUrl: backgroundVideoUrl,
embedUrl: backgroundEmbedUrl,
audioUrl: backgroundAudioUrl,
videoSettings: {
autoplay: backgroundVideoAutoplay,
loop: backgroundVideoLoop,
muted: backgroundVideoMuted,
startTime: backgroundVideoStartTime,
endTime: backgroundVideoEndTime,
},
audioSettings: {
loop: backgroundAudioLoop,
startTime: backgroundAudioStartTime,
endTime: backgroundAudioEndTime,
},
} = pageBackground;
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
// Polling hook for reverse video generation status
const { startPolling } = useReverseVideoPolling({
onReload,
pollInterval: 2000,
maxPollDuration: 30000,
});
/**
* Extract storage keys for elements that need reverse video generation.
* These are elements with:
* - transitionVideoUrl set (has a transition video)
* - transitionReverseMode is 'auto_reverse' or not set (default)
* - reverseVideoUrl is NOT set (not yet generated)
*/
const getPendingReverseVideoKeys = useCallback(
(elementsToCheck: CanvasElement[]): string[] => {
return elementsToCheck
.filter((el) => {
const hasTransition = Boolean(el.transitionVideoUrl);
const isAutoReverse = el.transitionReverseMode !== 'separate_video'; // auto_reverse is default
const needsGeneration = !el.reverseVideoUrl;
return hasTransition && isAutoReverse && needsGeneration;
})
.map((el) => el.transitionVideoUrl as string)
.filter(Boolean);
},
[],
);
const saveConstructor = useCallback(async () => {
if (!activePageId) {
onError?.('Select a page before saving.');
return;
}
try {
setIsSaving(true);
// Capture pending reverse video keys BEFORE save
// These are elements that will trigger async reversed video generation
const pendingReverseKeys = getPendingReverseVideoKeys(elements);
const existingSchema = parseJsonObject<Record<string, any>>(
activePage?.ui_schema_json,
{},
);
const schemaToSave = {
...existingSchema,
elements,
};
await axios.put(`/tour_pages/${activePageId}`, {
id: activePageId,
data: {
environment: activePage?.environment,
source_key: activePage?.source_key,
name: activePage?.name,
slug: activePage?.slug,
sort_order: activePage?.sort_order,
requires_auth: activePage?.requires_auth,
ui_schema_json: schemaToSave,
background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl,
background_embed_url: backgroundEmbedUrl,
background_audio_url: backgroundAudioUrl,
background_loop: Boolean(backgroundAudioUrl),
background_video_autoplay: backgroundVideoAutoplay,
background_video_loop: backgroundVideoLoop,
background_video_muted: backgroundVideoMuted,
background_video_start_time: backgroundVideoStartTime,
background_video_end_time: backgroundVideoEndTime,
background_audio_loop: backgroundAudioLoop,
background_audio_start_time: backgroundAudioStartTime,
background_audio_end_time: backgroundAudioEndTime,
// Copy project design dimensions to page for presentation isolation
design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null,
},
});
onSuccess?.(
'Constructor settings saved. Element positions are stored in percentages.',
);
await onReload(activePageId);
// Start polling for reverse video generation if there are pending keys
// This will automatically reload page data when all videos are ready
if (pendingReverseKeys.length > 0) {
logger.info('[SAVE] Starting reverse video polling', {
pendingCount: pendingReverseKeys.length,
});
startPolling(pendingReverseKeys, activePageId);
}
} 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 save constructor changes.';
logger.error(
'Failed to save constructor changes:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsSaving(false);
}
}, [
activePage?.environment,
activePage?.name,
activePage?.requires_auth,
activePage?.slug,
activePage?.sort_order,
activePage?.source_key,
activePage?.ui_schema_json,
activePageId,
pageBackground,
elements,
project?.design_width,
project?.design_height,
onError,
onReload,
onSuccess,
getPendingReverseVideoKeys,
startPolling,
]);
const saveToStage = useCallback(async () => {
if (!projectId) {
onError?.('Project ID is required to save to stage.');
return;
}
await saveConstructor();
try {
setIsSavingToStage(true);
await axios.post('/publish/save-to-stage', { projectId });
onSuccess?.('Saved to stage.');
} 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 save to stage.';
logger.error(
'Failed to save to stage:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsSavingToStage(false);
}
}, [projectId, saveConstructor, onError, onSuccess]);
const createPage = useCallback(
async (pageName: string, slug: string) => {
if (!projectId) {
onError?.('Project is required.');
return;
}
if (!pageName.trim()) {
onError?.('Page name is required.');
return;
}
if (!slug.trim()) {
onError?.('Page slug is required.');
return;
}
const maxSortOrder = Math.max(
0,
...pages.map((item) => Number(item.sort_order || 0)),
);
const payload = {
project: projectId,
environment: activePage?.environment || 'dev',
source_key: '',
name: pageName.trim(),
slug: slug.trim(),
sort_order: maxSortOrder + 1,
background_image_url: '',
background_video_url: '',
background_embed_url: '',
background_audio_url: '',
background_loop: false,
requires_auth: false,
ui_schema_json: { elements: [] },
// Copy project design dimensions to new page
design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null,
};
try {
setIsCreatingPage(true);
const response = await axios.post('/tour_pages', { data: payload });
const createdPage = response?.data;
await onReload();
if (createdPage?.id) {
onSetActivePageId(createdPage.id);
}
onSetMenuOpen?.(true);
onSuccess?.(
'New page created. You can now configure it in constructor.',
);
} 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 create page.';
logger.error(
'Failed to create page from constructor:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsCreatingPage(false);
}
},
[
activePage?.environment,
onError,
onReload,
onSetActivePageId,
onSetMenuOpen,
onSuccess,
pages,
project?.design_width,
project?.design_height,
projectId,
],
);
return {
isSaving,
isSavingToStage,
isCreatingPage,
saveConstructor,
saveToStage,
createPage,
};
}
export default useConstructorPageActions;