39948-vm/frontend/src/hooks/useConstructorPageActions.ts
2026-05-08 16:44:25 +02:00

406 lines
11 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';
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_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;
}
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;
/** Whether transition creation is in progress */
isCreatingTransition: 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>;
/** Create a transition (legacy - transitions are now stored on elements) */
createTransition: (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => 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,
audioUrl: backgroundAudioUrl,
videoSettings: {
autoplay: backgroundVideoAutoplay,
loop: backgroundVideoLoop,
muted: backgroundVideoMuted,
startTime: backgroundVideoStartTime,
endTime: backgroundVideoEndTime,
},
} = pageBackground;
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
const saveConstructor = useCallback(async () => {
if (!activePageId) {
onError?.('Select a page before saving.');
return;
}
try {
setIsSaving(true);
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_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,
// 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);
} 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,
]);
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_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,
]);
const createTransition = useCallback(
async (params: {
name?: string;
videoUrl: string;
supportsReverse?: boolean;
durationSec?: number;
}) => {
if (!projectId) {
onError?.('Project is required.');
return;
}
const sanitizedVideoUrl = String(params.videoUrl || '').trim();
if (!sanitizedVideoUrl) {
onError?.('Select a transition video asset first.');
return;
}
if (!params.durationSec) {
onError?.(
'Could not resolve transition video duration yet. Please wait a moment and try again.',
);
return;
}
try {
setIsCreatingTransition(true);
// Transitions are now stored directly in navigation elements as transitionVideoUrl
// This method is kept for backwards compatibility but just shows a message
onSuccess?.(
'Transition video can be set directly on navigation elements.',
);
} 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 transition.';
logger.error(
'Failed to create transition from constructor:',
error instanceof Error ? error : { error },
);
onError?.(message);
} finally {
setIsCreatingTransition(false);
}
},
[projectId, onError, onSuccess],
);
return {
isSaving,
isSavingToStage,
isCreatingPage,
isCreatingTransition,
saveConstructor,
saveToStage,
createPage,
createTransition,
};
}
export default useConstructorPageActions;