From f6d0aeafd79005a62f148c921ee895059fdd93f3 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 6 Apr 2026 16:42:02 +0400 Subject: [PATCH] added go back navigation with two modes (target page and previous route) --- .../Constructor/ConstructorMenu.tsx | 200 +++++++------- .../Constructor/ElementEditorPanel.tsx | 7 +- .../NavigationSettingsSectionCompact.tsx | 250 ++++++++++-------- .../src/components/Offline/OfflineToggle.tsx | 5 +- .../src/components/RuntimePresentation.tsx | 47 ++-- frontend/src/components/SelectField.tsx | 5 +- frontend/src/components/SelectFieldMany.tsx | 5 +- .../components/AreaChart/ApexAreaChart.tsx | 5 +- .../components/AreaChart/ChartJSAreaChart.tsx | 5 +- .../components/BarChart/ApexBarChart.tsx | 5 +- .../components/BarChart/ChartJSBarChart.tsx | 5 +- .../SmartWidget/components/FunnelChart.tsx | 5 +- .../components/LineChart/ApexLineChart.tsx | 5 +- .../components/LineChart/ChartJSLineChart.tsx | 5 +- .../components/PieChart/ApexPieChart.tsx | 10 +- .../components/PieChart/ChartJSPieChart.tsx | 5 +- frontend/src/components/TourFlowManager.tsx | 12 +- .../components/WidgetCreator/RoleSelect.tsx | 5 +- .../WidgetCreator/WidgetCreator.tsx | 9 +- frontend/src/context/ConstructorContext.tsx | 4 +- frontend/src/hooks/index.ts | 5 +- frontend/src/hooks/queries/index.ts | 56 +++- .../src/hooks/queries/useAccessLogsQuery.ts | 4 +- .../hooks/queries/useAssetVariantsQuery.ts | 2 +- .../hooks/queries/useElementDefaultsQuery.ts | 4 +- frontend/src/hooks/queries/usePagesQuery.ts | 5 +- .../queries/useProjectAudioTracksQuery.ts | 15 +- .../queries/useProjectMembershipsQuery.ts | 11 +- frontend/src/hooks/queries/useProjectQuery.ts | 5 +- .../hooks/queries/usePublishEventsQuery.ts | 2 +- .../src/hooks/queries/usePwaCachesQuery.ts | 2 +- frontend/src/hooks/useAssetOptions.ts | 24 +- frontend/src/hooks/useConstructorData.ts | 29 +- .../src/hooks/useConstructorPageActions.ts | 16 +- frontend/src/hooks/useOfflineMode.ts | 4 +- frontend/src/hooks/usePageBackground.ts | 4 +- frontend/src/hooks/usePageNavigation.ts | 27 +- frontend/src/hooks/useTransitionPlayback.ts | 10 +- frontend/src/hooks/useTransitionPreview.ts | 1 + frontend/src/lib/elementDefaults.ts | 2 + frontend/src/lib/navigationHelpers.ts | 101 +++++++ frontend/src/pages/_app.tsx | 110 ++++---- .../src/pages/access_logs/[access_logsId].tsx | 4 +- frontend/src/pages/constructor.tsx | 102 +++++-- frontend/src/pages/element-type-defaults.tsx | 4 +- .../src/pages/project-element-defaults.tsx | 9 +- frontend/src/pages/projects/[projectsId].tsx | 8 +- .../publish_events/publish_events-list.tsx | 4 +- .../stores/constructor/constructorSlice.ts | 3 +- frontend/src/stores/createEntitySlice.ts | 5 +- frontend/src/stores/openAiSlice.ts | 29 +- frontend/src/stores/roles/rolesSlice.ts | 9 +- frontend/src/types/constructor.ts | 24 +- frontend/src/types/presentation.ts | 8 + frontend/src/types/redux.ts | 1 - 55 files changed, 814 insertions(+), 434 deletions(-) diff --git a/frontend/src/components/Constructor/ConstructorMenu.tsx b/frontend/src/components/Constructor/ConstructorMenu.tsx index 2bdb6b1..49645bc 100644 --- a/frontend/src/components/Constructor/ConstructorMenu.tsx +++ b/frontend/src/components/Constructor/ConstructorMenu.tsx @@ -19,7 +19,11 @@ import { mdiExitToApp, } from '@mdi/js'; import MenuActionButton from './MenuActionButton'; -import type { Position, CanvasElementType, NavigationElementType } from './types'; +import type { + Position, + CanvasElementType, + NavigationElementType, +} from './types'; import type { EditorMenuItem } from '../../types/constructor'; interface ConstructorMenuProps { @@ -65,108 +69,108 @@ const ConstructorMenu = forwardRef( className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl' style={{ left: position.x, top: position.y }} > -
- Constructor Menu - -
+
+ Constructor Menu + +
- {isOpen && ( -
- onSelectMenuItem('background_image')} - /> - onSelectMenuItem('background_video')} - /> - onSelectMenuItem('background_audio')} - /> - onAddElement(allowedNavigationTypes[0])} - /> - onSelectMenuItem('create_transition')} - /> - onAddElement('gallery')} - /> - onAddElement('carousel')} - /> - onAddElement('tooltip')} - /> - onAddElement('description')} - /> - onAddElement('video_player')} - /> - onAddElement('audio_player')} - /> - + {isOpen && ( +
+ onSelectMenuItem('background_image')} + /> + onSelectMenuItem('background_video')} + /> + onSelectMenuItem('background_audio')} + /> + onAddElement(allowedNavigationTypes[0])} + /> + onSelectMenuItem('create_transition')} + /> + onAddElement('gallery')} + /> + onAddElement('carousel')} + /> + onAddElement('tooltip')} + /> + onAddElement('description')} + /> + onAddElement('video_player')} + /> + onAddElement('audio_player')} + /> + -
-
- - +
+ + +
+
-
-
- )} -
+ )} + ); }, ); diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 75a1a9f..5c06a50 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -322,6 +322,7 @@ export function ElementEditorPanel({ selectedElement.transitionReverseMode || 'auto_reverse' } reverseVideoUrl={selectedElement.reverseVideoUrl || ''} + navBackMode={selectedElement.navBackMode} allowedNavigationTypes={allowedNavigationTypes} iconAssetOptions={assetOptions.icon} transitionVideoOptions={assetOptions.transitionVideo} @@ -718,7 +719,11 @@ export function ElementEditorPanel({ color: selectedElement.color || '', }} onChange={(prop, value) => - handleCssPropertyChange(prop, value, updateSelectedElement) + handleCssPropertyChange( + prop, + value, + updateSelectedElement, + ) } /> diff --git a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx index 6845b8c..755c614 100644 --- a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx @@ -31,6 +31,7 @@ interface NavigationSettingsSectionCompactProps { transitionVideoUrl: string; transitionReverseMode: 'auto_reverse' | 'separate_video'; reverseVideoUrl: string; + navBackMode?: 'target_page' | 'history'; allowedNavigationTypes: NavigationElementType[]; iconAssetOptions: AssetOption[]; transitionVideoOptions: AssetOption[]; @@ -67,6 +68,7 @@ const NavigationSettingsSectionCompact: React.FC< transitionVideoUrl, transitionReverseMode, reverseVideoUrl, + navBackMode, allowedNavigationTypes, iconAssetOptions, transitionVideoOptions, @@ -184,126 +186,156 @@ const NavigationSettingsSectionCompact: React.FC< )} -
- - -
- -
- - - {selectedTransitionDurationNote && ( -

- {selectedTransitionDurationNote} -

- )} -
- -
- - -
- - {transitionReverseMode === 'separate_video' && ( + {/* Back Navigation Mode - only shown for back buttons */} + {currentKind === 'back' && (
+ {navBackMode === 'history' && ( +

+ Returns to the page user came from, using the original forward + transition in reverse. +

+ )}
)} -

- Transition duration is set automatically from the selected video. -

+ {/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */} + {(currentKind === 'forward' || navBackMode !== 'history') && ( + <> +
+ + +
- {onPreviewTransition && ( -
- onPreviewTransition('forward')} - /> - onPreviewTransition('back')} - /> -
+
+ + + {selectedTransitionDurationNote && ( +

+ {selectedTransitionDurationNote} +

+ )} +
+ +
+ + +
+ + {transitionReverseMode === 'separate_video' && ( +
+ + +
+ )} + +

+ Transition duration is set automatically from the selected video. +

+ + {onPreviewTransition && ( +
+ onPreviewTransition('forward')} + /> + onPreviewTransition('back')} + /> +
+ )} + )} ); diff --git a/frontend/src/components/Offline/OfflineToggle.tsx b/frontend/src/components/Offline/OfflineToggle.tsx index aae175e..f09d6bf 100644 --- a/frontend/src/components/Offline/OfflineToggle.tsx +++ b/frontend/src/components/Offline/OfflineToggle.tsx @@ -18,6 +18,7 @@ import BaseButton from '../BaseButton'; import { useOfflineMode } from '../../hooks/useOfflineMode'; import { useStorageQuota } from '../../hooks/useStorageQuota'; import type { ProjectOfflineStatus } from '../../types/offline'; +import { logger } from '../../lib/logger'; interface OfflineToggleProps { projectId: string | null; @@ -62,13 +63,13 @@ export function OfflineToggle({ // Show toast notification when download completes useEffect(() => { - console.log('[OfflineToggle] Status changed:', { + logger.debug('[OfflineToggle] Status changed:', { prev: prevStatusRef.current, current: status, }); // Only show toast when transitioning FROM downloading TO downloaded if (prevStatusRef.current === 'downloading' && status === 'downloaded') { - console.log('[OfflineToggle] Showing toast - download complete!'); + logger.debug('[OfflineToggle] Showing toast - download complete!'); toast.success('Presentation ready for offline mode!', { position: 'bottom-center', autoClose: 5000, diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index a39d53a..7f7ccf9 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -28,6 +28,7 @@ import LayoutGuest from '../layouts/Guest'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePageDataLoader } from '../hooks/usePageDataLoader'; import { useProjectAssets } from '../hooks/useProjectAssets'; +import { usePageNavigation } from '../hooks/usePageNavigation'; import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; @@ -66,8 +67,18 @@ export default function RuntimePresentation({ // Resolve project assets (favicon, og_image, logo) to presigned URLs const { faviconUrl, ogImageUrl } = useProjectAssets(project); - const [selectedPageId, setSelectedPageId] = useState(null); - const [pageHistory, setPageHistory] = useState([]); + // Page navigation with history tracking via shared hook + const { + currentPageId: selectedPageId, + pageHistory, + applyPageSelection, + getNavigationContext, + } = usePageNavigation({ + pages, + defaultPageId: initialPageId || undefined, + trackHistory: true, + }); + const [transitionPreview, setTransitionPreview] = useState<{ targetPageId: string; videoUrl: string; @@ -86,13 +97,7 @@ export default function RuntimePresentation({ const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(null); - // Set initial page when data loads - useEffect(() => { - if (initialPageId && !selectedPageId) { - setSelectedPageId(initialPageId); - setPageHistory([initialPageId]); - } - }, [initialPageId, selectedPageId]); + // Note: Initial page selection is handled by usePageNavigation hook via defaultPageId // Extract page links and preload elements from ui_schema_json // This enables the neighbor graph to find connected pages for preloading @@ -143,17 +148,18 @@ export default function RuntimePresentation({ reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', targetPageId: transitionPreview.targetPageId, displayName: 'Transition', + isBack: transitionPreview.isReverse, // Pass through for history management } : null, - onComplete: async (targetPageId) => { + onComplete: async (targetPageId, isBack) => { if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId); // Mark this page as initialized to prevent redundant effect calls lastInitializedPageIdRef.current = targetPageId; // Use shared hook to resolve blob URLs and switch page await pageSwitch.switchToPage(targetPage, () => { - setSelectedPageId(targetPageId); - setPageHistory((prev) => [...prev, targetPageId]); + // Use applyPageSelection for proper history management (pops on back) + applyPageSelection(targetPageId, isBack ?? false); }); setIsBackgroundReady(false); // Signal that transition is complete and waiting for Image onLoad @@ -300,12 +306,12 @@ export default function RuntimePresentation({ lastInitializedPageIdRef.current = targetPageId; await pageSwitch.switchToPage(targetPage, () => { - setSelectedPageId(targetPageId); - setPageHistory((prev) => [...prev, targetPageId]); + // Use applyPageSelection for proper history management (pops on back) + applyPageSelection(targetPageId, isBack); }); } }, - [pages, pageSwitch, resetFadeOut], + [pages, pageSwitch, resetFadeOut, applyPageSelection], ); const handleElementClick = useCallback( @@ -317,8 +323,11 @@ export default function RuntimePresentation({ return; } - // Use shared helper to resolve navigation target - const navTarget = resolveNavigationTarget(element, pages); + // Get navigation context from hook for history-based back navigation + const navContext = getNavigationContext(); + + // Use shared helper to resolve navigation target with history context + const navTarget = resolveNavigationTarget(element, pages, navContext); // Debug: log element navigation data logger.info('Element clicked', { @@ -328,6 +337,8 @@ export default function RuntimePresentation({ resolvedTargetPageId: navTarget?.pageId, transitionVideoUrl: element.transitionVideoUrl, hasTransition: Boolean(element.transitionVideoUrl), + navBackMode: element.navBackMode, + previousPageId: navContext.previousPageId, }); if (navTarget) { @@ -338,7 +349,7 @@ export default function RuntimePresentation({ ); } }, - [navigateToPage, pages, transitionPhase, isBuffering], + [navigateToPage, pages, transitionPhase, isBuffering, getNavigationContext], ); // Handler for gallery card clicks diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index 97814e5..f753199 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -31,7 +31,10 @@ export const SelectField = ({ setValue(option); }; - async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { + async function callApi( + inputValue: string, + loadedOptions: Array<{ value: string; label: string }>, + ) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 13e2f43..979aebe 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -46,7 +46,10 @@ export const SelectFieldMany = ({ ); }; - async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { + async function callApi( + inputValue: string, + loadedOptions: Array<{ value: string; label: string }>, + ) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx index 54d8705..d05d17e 100644 --- a/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx +++ b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx @@ -1,7 +1,10 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx index 4b99c21..e6d0d0d 100644 --- a/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx +++ b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx @@ -3,7 +3,10 @@ import { Line } from 'react-chartjs-2'; import chroma from 'chroma-js'; import { humanize } from '../../../../helpers/humanize'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; import { Chart as ChartJS, CategoryScale, diff --git a/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx index 3972fd1..fa0efa7 100644 --- a/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx +++ b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx @@ -1,7 +1,10 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; diff --git a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx index 95a0f74..9df0d4d 100644 --- a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx +++ b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx @@ -14,7 +14,10 @@ import { } from 'chart.js'; import chroma from 'chroma-js'; import { logger } from '../../../../lib/logger'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; ChartJS.register( CategoryScale, diff --git a/frontend/src/components/SmartWidget/components/FunnelChart.tsx b/frontend/src/components/SmartWidget/components/FunnelChart.tsx index e64ae51..b47e6eb 100644 --- a/frontend/src/components/SmartWidget/components/FunnelChart.tsx +++ b/frontend/src/components/SmartWidget/components/FunnelChart.tsx @@ -1,7 +1,10 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../helpers/humanize'; -import type { ChartComponentProps, ChartValueArray } from '../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx index 1063bb3..5aa5d43 100644 --- a/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx +++ b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx @@ -1,7 +1,10 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx index e50b5a0..cf7b552 100644 --- a/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx +++ b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx @@ -13,7 +13,10 @@ import { Tooltip, ChartData, } from 'chart.js'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; Chart.register( LineElement, diff --git a/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx index 609eb45..a5f17be 100644 --- a/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx +++ b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx @@ -1,13 +1,19 @@ import React from 'react'; import dynamic from 'next/dynamic'; import chroma from 'chroma-js'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; export const ApexPieChart = ({ widget }: ChartComponentProps) => { - const optionsForPieChart = (value: ValueType, chartColor?: string | string[]) => { + const optionsForPieChart = ( + value: ValueType, + chartColor?: string | string[], + ) => { const chartColors = Array.isArray(chartColor) ? chartColor : [chartColor || '#3751FF']; diff --git a/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx index ee9a613..9f30a51 100644 --- a/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx +++ b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx @@ -3,7 +3,10 @@ import { humanize } from '../../../../helpers/humanize'; import { Pie } from 'react-chartjs-2'; import chroma from 'chroma-js'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; -import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; +import type { + ChartComponentProps, + ChartValueArray, +} from '../../../../types/charts'; import { Chart as ChartJS, diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index 7d82de2..fceab0e 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -140,7 +140,9 @@ const TourFlowManager = () => { setPages(getRows(pagesResponse)); setTransitions([]); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -366,7 +368,9 @@ const TourFlowManager = () => { setNewPageSlug(''); await loadData(); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -403,7 +407,9 @@ const TourFlowManager = () => { setPages((prev) => prev.filter((item) => item.id !== id)); } } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || diff --git a/frontend/src/components/WidgetCreator/RoleSelect.tsx b/frontend/src/components/WidgetCreator/RoleSelect.tsx index 94704d7..1090452 100644 --- a/frontend/src/components/WidgetCreator/RoleSelect.tsx +++ b/frontend/src/components/WidgetCreator/RoleSelect.tsx @@ -37,7 +37,10 @@ export const RoleSelect = ({ setValue(option); }; - async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { + async function callApi( + inputValue: string, + loadedOptions: Array<{ value: string; label: string }>, + ) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/WidgetCreator/WidgetCreator.tsx b/frontend/src/components/WidgetCreator/WidgetCreator.tsx index 265897c..33587af 100644 --- a/frontend/src/components/WidgetCreator/WidgetCreator.tsx +++ b/frontend/src/components/WidgetCreator/WidgetCreator.tsx @@ -62,8 +62,13 @@ export const WidgetCreator = ({ userId: currentUser?.id, }; const result = await dispatch(aiPrompt(payload)); - const responcePayload = result.payload as { data?: { error?: { message?: string } } } | undefined; - const error = 'error' in result ? result.error as { message?: string } | undefined : undefined; + const responcePayload = result.payload as + | { data?: { error?: { message?: string } } } + | undefined; + const error = + 'error' in result + ? (result.error as { message?: string } | undefined) + : undefined; await getWidgets().then(); diff --git a/frontend/src/context/ConstructorContext.tsx b/frontend/src/context/ConstructorContext.tsx index 30a4e6d..b028bad 100644 --- a/frontend/src/context/ConstructorContext.tsx +++ b/frontend/src/context/ConstructorContext.tsx @@ -102,7 +102,9 @@ export interface ConstructorContextValue { setBackgroundImageUrl: (url: string) => void; setBackgroundVideoUrl: (url: string) => void; setBackgroundAudioUrl: (url: string) => void; - setBackgroundVideoSettings: (settings: Partial) => void; + setBackgroundVideoSettings: ( + settings: Partial, + ) => void; // Element state elements: CanvasElement[]; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 9f18507..9a37673 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -43,7 +43,10 @@ export type { } from './usePageBackground'; export { useConstructorData } from './useConstructorData'; export { useAssetOptions } from './useAssetOptions'; -export type { AssetOptionsResult, UseAssetOptionsOptions } from './useAssetOptions'; +export type { + AssetOptionsResult, + UseAssetOptionsOptions, +} from './useAssetOptions'; export { useTransitionCreation } from './useTransitionCreation'; export type { TransitionCreationState, diff --git a/frontend/src/hooks/queries/index.ts b/frontend/src/hooks/queries/index.ts index 9a171da..103806d 100644 --- a/frontend/src/hooks/queries/index.ts +++ b/frontend/src/hooks/queries/index.ts @@ -4,16 +4,56 @@ * Centralized exports for React Query hooks. */ -export { useProjectsQuery, useProjectQuery, useUpdateProjectMutation } from './useProjectQuery'; -export { usePagesQuery, usePageQuery, useUpdatePageMutation, useCreatePageMutation, useDeletePageMutation } from './usePagesQuery'; -export { useAssetsQuery, useAssetQuery, useUpdateAssetMutation, useDeleteAssetMutation } from './useAssetsQuery'; +export { + useProjectsQuery, + useProjectQuery, + useUpdateProjectMutation, +} from './useProjectQuery'; +export { + usePagesQuery, + usePageQuery, + useUpdatePageMutation, + useCreatePageMutation, + useDeletePageMutation, +} from './usePagesQuery'; +export { + useAssetsQuery, + useAssetQuery, + useUpdateAssetMutation, + useDeleteAssetMutation, +} from './useAssetsQuery'; export { useElementDefaultsQuery } from './useElementDefaultsQuery'; -export { useUsersQuery, useCurrentUserQuery, useUserQuery, useUpdateUserMutation, useCreateUserMutation, useDeleteUserMutation } from './useUsersQuery'; -export { useRolesQuery, useRoleQuery, useUpdateRoleMutation, useCreateRoleMutation, useDeleteRoleMutation } from './useRolesQuery'; +export { + useUsersQuery, + useCurrentUserQuery, + useUserQuery, + useUpdateUserMutation, + useCreateUserMutation, + useDeleteUserMutation, +} from './useUsersQuery'; +export { + useRolesQuery, + useRoleQuery, + useUpdateRoleMutation, + useCreateRoleMutation, + useDeleteRoleMutation, +} from './useRolesQuery'; export { usePermissionsQuery } from './usePermissionsQuery'; export { useAccessLogsQuery } from './useAccessLogsQuery'; export { useAssetVariantsQuery } from './useAssetVariantsQuery'; -export { useProjectMembershipsQuery, useCreateProjectMembershipMutation, useDeleteProjectMembershipMutation } from './useProjectMembershipsQuery'; -export { useProjectAudioTracksQuery, useProjectAudioTrackQuery, useCreateProjectAudioTrackMutation, useDeleteProjectAudioTrackMutation } from './useProjectAudioTracksQuery'; +export { + useProjectMembershipsQuery, + useCreateProjectMembershipMutation, + useDeleteProjectMembershipMutation, +} from './useProjectMembershipsQuery'; +export { + useProjectAudioTracksQuery, + useProjectAudioTrackQuery, + useCreateProjectAudioTrackMutation, + useDeleteProjectAudioTrackMutation, +} from './useProjectAudioTracksQuery'; export { usePublishEventsQuery } from './usePublishEventsQuery'; -export { usePwaCachesQuery, useDeletePwaCacheMutation } from './usePwaCachesQuery'; +export { + usePwaCachesQuery, + useDeletePwaCacheMutation, +} from './usePwaCachesQuery'; diff --git a/frontend/src/hooks/queries/useAccessLogsQuery.ts b/frontend/src/hooks/queries/useAccessLogsQuery.ts index 5ab5fa0..424ea8f 100644 --- a/frontend/src/hooks/queries/useAccessLogsQuery.ts +++ b/frontend/src/hooks/queries/useAccessLogsQuery.ts @@ -39,7 +39,9 @@ export function useAccessLogsQuery(params?: AccessLogListParams) { return useQuery({ queryKey: queryKeys.accessLogs.list(params), queryFn: async (): Promise => { - const response = await axios.get(`access_logs${query}`); + const response = await axios.get( + `access_logs${query}`, + ); return response.data.rows; }, staleTime: 1 * 60 * 1000, // Access logs change frequently diff --git a/frontend/src/hooks/queries/useAssetVariantsQuery.ts b/frontend/src/hooks/queries/useAssetVariantsQuery.ts index 2a15c86..9b6827e 100644 --- a/frontend/src/hooks/queries/useAssetVariantsQuery.ts +++ b/frontend/src/hooks/queries/useAssetVariantsQuery.ts @@ -31,7 +31,7 @@ export function useAssetVariantsQuery(assetId: string | undefined) { queryKey: queryKeys.assetVariants.list(assetId || ''), queryFn: async (): Promise => { const response = await axios.get( - `asset_variants?assetId=${assetId}` + `asset_variants?assetId=${assetId}`, ); return response.data.rows; }, diff --git a/frontend/src/hooks/queries/useElementDefaultsQuery.ts b/frontend/src/hooks/queries/useElementDefaultsQuery.ts index 59e9831..b3c2ec5 100644 --- a/frontend/src/hooks/queries/useElementDefaultsQuery.ts +++ b/frontend/src/hooks/queries/useElementDefaultsQuery.ts @@ -38,9 +38,7 @@ export function useElementDefaultsQuery(projectId: string | undefined) { // Process and normalize the defaults const normalizedDefaults = response.data.rows .map((row) => normalizeElementDefault(row)) - .filter( - (d): d is NormalizedElementDefault => d !== null, - ); + .filter((d): d is NormalizedElementDefault => d !== null); return buildElementDefaultsMap(normalizedDefaults); }, diff --git a/frontend/src/hooks/queries/usePagesQuery.ts b/frontend/src/hooks/queries/usePagesQuery.ts index cdc7d63..2247659 100644 --- a/frontend/src/hooks/queries/usePagesQuery.ts +++ b/frontend/src/hooks/queries/usePagesQuery.ts @@ -71,10 +71,7 @@ export function useUpdatePageMutation() { }, onSuccess: (data, variables) => { // Update the single page cache - queryClient.setQueryData( - queryKeys.tourPages.detail(variables.id), - data, - ); + queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data); // Invalidate list queries queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); }, diff --git a/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts b/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts index 64b93f9..331e50c 100644 --- a/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts +++ b/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts @@ -32,7 +32,7 @@ export function useProjectAudioTracksQuery(projectId: string | undefined) { queryKey: queryKeys.projectAudioTracks.list(projectId || ''), queryFn: async (): Promise => { const response = await axios.get( - `project_audio_tracks?projectId=${projectId}` + `project_audio_tracks?projectId=${projectId}`, ); return response.data.rows; }, @@ -48,7 +48,9 @@ export function useProjectAudioTrackQuery(trackId: string | undefined) { return useQuery({ queryKey: queryKeys.projectAudioTracks.detail(trackId || ''), queryFn: async (): Promise => { - const response = await axios.get(`project_audio_tracks/${trackId}`); + const response = await axios.get( + `project_audio_tracks/${trackId}`, + ); return response.data; }, enabled: !!trackId, @@ -63,8 +65,13 @@ export function useCreateProjectAudioTrackMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: Partial): Promise => { - const response = await axios.post('project_audio_tracks', { data }); + mutationFn: async ( + data: Partial, + ): Promise => { + const response = await axios.post( + 'project_audio_tracks', + { data }, + ); return response.data; }, onSuccess: (_, variables) => { diff --git a/frontend/src/hooks/queries/useProjectMembershipsQuery.ts b/frontend/src/hooks/queries/useProjectMembershipsQuery.ts index 5c27525..947b468 100644 --- a/frontend/src/hooks/queries/useProjectMembershipsQuery.ts +++ b/frontend/src/hooks/queries/useProjectMembershipsQuery.ts @@ -34,7 +34,7 @@ export function useProjectMembershipsQuery(projectId: string | undefined) { queryKey: queryKeys.projectMemberships.list(projectId || ''), queryFn: async (): Promise => { const response = await axios.get( - `project_memberships?projectId=${projectId}` + `project_memberships?projectId=${projectId}`, ); return response.data.rows; }, @@ -50,8 +50,13 @@ export function useCreateProjectMembershipMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: Partial): Promise => { - const response = await axios.post('project_memberships', { data }); + mutationFn: async ( + data: Partial, + ): Promise => { + const response = await axios.post( + 'project_memberships', + { data }, + ); return response.data; }, onSuccess: (_, variables) => { diff --git a/frontend/src/hooks/queries/useProjectQuery.ts b/frontend/src/hooks/queries/useProjectQuery.ts index 96696ad..c49967a 100644 --- a/frontend/src/hooks/queries/useProjectQuery.ts +++ b/frontend/src/hooks/queries/useProjectQuery.ts @@ -74,10 +74,7 @@ export function useUpdateProjectMutation() { }, onSuccess: (data, variables) => { // Update the cache with the new data - queryClient.setQueryData( - queryKeys.projects.detail(variables.id), - data, - ); + queryClient.setQueryData(queryKeys.projects.detail(variables.id), data); // Invalidate list queries to refetch queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); }, diff --git a/frontend/src/hooks/queries/usePublishEventsQuery.ts b/frontend/src/hooks/queries/usePublishEventsQuery.ts index 6c31a64..16e174a 100644 --- a/frontend/src/hooks/queries/usePublishEventsQuery.ts +++ b/frontend/src/hooks/queries/usePublishEventsQuery.ts @@ -44,7 +44,7 @@ export function usePublishEventsQuery(projectId: string | undefined) { queryKey: queryKeys.publishEvents.list(projectId || ''), queryFn: async (): Promise => { const response = await axios.get( - `publish_events?projectId=${projectId}&limit=50` + `publish_events?projectId=${projectId}&limit=50`, ); return response.data.rows; }, diff --git a/frontend/src/hooks/queries/usePwaCachesQuery.ts b/frontend/src/hooks/queries/usePwaCachesQuery.ts index 40e23f6..727b0b2 100644 --- a/frontend/src/hooks/queries/usePwaCachesQuery.ts +++ b/frontend/src/hooks/queries/usePwaCachesQuery.ts @@ -31,7 +31,7 @@ export function usePwaCachesQuery(projectId: string | undefined) { queryKey: queryKeys.pwaCaches.list(projectId || ''), queryFn: async (): Promise => { const response = await axios.get( - `pwa_caches?projectId=${projectId}` + `pwa_caches?projectId=${projectId}`, ); return response.data.rows; }, diff --git a/frontend/src/hooks/useAssetOptions.ts b/frontend/src/hooks/useAssetOptions.ts index 905fabf..b3644aa 100644 --- a/frontend/src/hooks/useAssetOptions.ts +++ b/frontend/src/hooks/useAssetOptions.ts @@ -6,7 +6,10 @@ */ import { useMemo } from 'react'; -import type { ConstructorAsset as ProjectAsset, AssetOption } from '../types/constructor'; +import type { + ConstructorAsset as ProjectAsset, + AssetOption, +} from '../types/constructor'; import { buildAssetOptions, buildBackgroundImageOptions, @@ -50,12 +53,11 @@ export interface UseAssetOptionsOptions { * * ``` */ -export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOptionsResult { +export function useAssetOptions({ + assets, +}: UseAssetOptionsOptions): AssetOptionsResult { // All image assets - const imageOptions = useMemo( - () => buildImageAssetOptions(assets), - [assets], - ); + const imageOptions = useMemo(() => buildImageAssetOptions(assets), [assets]); // Background image assets (filtered by type or naming convention) const backgroundImageOptions = useMemo( @@ -83,10 +85,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption ); // Audio assets - const audioOptions = useMemo( - () => buildAudioAssetOptions(assets), - [assets], - ); + const audioOptions = useMemo(() => buildAudioAssetOptions(assets), [assets]); // Transition video assets const transitionVideoOptions = useMemo( @@ -95,10 +94,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption ); // Icon assets - const iconOptions = useMemo( - () => buildIconAssetOptions(assets), - [assets], - ); + const iconOptions = useMemo(() => buildIconAssetOptions(assets), [assets]); return useMemo( () => ({ diff --git a/frontend/src/hooks/useConstructorData.ts b/frontend/src/hooks/useConstructorData.ts index d82dec9..92504a0 100644 --- a/frontend/src/hooks/useConstructorData.ts +++ b/frontend/src/hooks/useConstructorData.ts @@ -21,7 +21,9 @@ interface UseConstructorDataParams { } // Stable empty references to prevent infinite loops from identity changes -const EMPTY_ELEMENT_DEFAULTS: Partial>> = {}; +const EMPTY_ELEMENT_DEFAULTS: Partial< + Record> +> = {}; const EMPTY_PAGES: TourPage[] = []; const EMPTY_ASSETS: Asset[] = []; @@ -39,7 +41,9 @@ interface UseConstructorDataResult { assets: Asset[]; // Element Defaults - uiElementDefaultsByType: Partial>>; + uiElementDefaultsByType: Partial< + Record> + >; // Loading state isLoading: boolean; @@ -64,19 +68,25 @@ export function useConstructorData({ const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev'); // Fetch assets - const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, { limit: 500 }); + const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, { + limit: 500, + }); // Fetch element defaults - const elementDefaultsQuery = useElementDefaultsQuery(enabled ? projectId : undefined); + const elementDefaultsQuery = useElementDefaultsQuery( + enabled ? projectId : undefined, + ); // Extract page links and preload elements from pages const { pageLinks, allPagesPreloadElements } = useMemo(() => { if (!pagesQuery.data || pagesQuery.data.length === 0) { - return { pageLinks: [] as PreloadPageLink[], allPagesPreloadElements: [] as PreloadElement[] }; + return { + pageLinks: [] as PreloadPageLink[], + allPagesPreloadElements: [] as PreloadElement[], + }; } - const { pageLinks: links, preloadElements: elements } = extractPageLinksAndElements( - pagesQuery.data as TourPage[], - ); + const { pageLinks: links, preloadElements: elements } = + extractPageLinksAndElements(pagesQuery.data as TourPage[]); return { pageLinks: links, allPagesPreloadElements: elements }; }, [pagesQuery.data]); @@ -124,7 +134,8 @@ export function useConstructorData({ assets: (assetsQuery.data as Asset[]) || EMPTY_ASSETS, // Element Defaults - uiElementDefaultsByType: elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS, + uiElementDefaultsByType: + elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS, // Loading state isLoading, diff --git a/frontend/src/hooks/useConstructorPageActions.ts b/frontend/src/hooks/useConstructorPageActions.ts index bac3aac..d91e05e 100644 --- a/frontend/src/hooks/useConstructorPageActions.ts +++ b/frontend/src/hooks/useConstructorPageActions.ts @@ -180,7 +180,9 @@ export function useConstructorPageActions({ ); await onReload(activePageId); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -227,7 +229,9 @@ export function useConstructorPageActions({ 'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.', ); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -283,7 +287,9 @@ export function useConstructorPageActions({ 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 axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -341,7 +347,9 @@ export function useConstructorPageActions({ 'Transition video can be set directly on navigation elements.', ); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || diff --git a/frontend/src/hooks/useOfflineMode.ts b/frontend/src/hooks/useOfflineMode.ts index 03a6f4b..99ecd2b 100644 --- a/frontend/src/hooks/useOfflineMode.ts +++ b/frontend/src/hooks/useOfflineMode.ts @@ -319,7 +319,9 @@ export function useOfflineMode( setStatus('downloaded'); setProgress(100); await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); - logger.info('[useOfflineMode] All assets already cached', { projectId }); + logger.info('[useOfflineMode] All assets already cached', { + projectId, + }); return; } diff --git a/frontend/src/hooks/usePageBackground.ts b/frontend/src/hooks/usePageBackground.ts index 72dfb17..05906e2 100644 --- a/frontend/src/hooks/usePageBackground.ts +++ b/frontend/src/hooks/usePageBackground.ts @@ -57,9 +57,7 @@ export interface UsePageBackgroundResult { setAudioUrl: (url: string) => void; /** Update video settings */ - setVideoSettings: ( - settings: Partial, - ) => void; + setVideoSettings: (settings: Partial) => void; /** Reset to default state */ reset: () => void; diff --git a/frontend/src/hooks/usePageNavigation.ts b/frontend/src/hooks/usePageNavigation.ts index 73c435a..baa523a 100644 --- a/frontend/src/hooks/usePageNavigation.ts +++ b/frontend/src/hooks/usePageNavigation.ts @@ -1,4 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { NavigationContext } from '../lib/navigationHelpers'; + +/** + * Maximum history entries to prevent unbounded growth in long sessions. + * Matches typical browser behavior (50 entries). + */ +const MAX_HISTORY_LENGTH = 50; /** * Minimal page interface for navigation @@ -27,6 +34,11 @@ export interface UsePageNavigationResult { isBackNavigation: (targetPageId: string) => boolean; goBack: () => boolean; resetHistory: () => void; + /** + * Get navigation context for history-based back navigation. + * Provides currentPageSlug and previousPageId for resolveNavigationTarget(). + */ + getNavigationContext: () => NavigationContext; } /** @@ -105,7 +117,7 @@ export function usePageNavigation( const currentId = prev[prev.length - 1]; if (currentId === targetPageId) return prev; - // If going back and target matches previous, pop history + // If going back and target matches previous, pop history (browser-like behavior) if ( isBack && prev.length > 1 && @@ -114,7 +126,9 @@ export function usePageNavigation( return prev.slice(0, -1); } - return [...prev, targetPageId]; + // Add to history and trim to max length (keep most recent entries) + const newHistory = [...prev, targetPageId]; + return newHistory.slice(-MAX_HISTORY_LENGTH); }); } @@ -147,6 +161,14 @@ export function usePageNavigation( setPageHistory(currentPageId ? [currentPageId] : []); }, [currentPageId]); + // Get navigation context for history-based back navigation + const getNavigationContext = useCallback((): NavigationContext => { + return { + currentPageSlug: currentPage?.slug, + previousPageId, + }; + }, [currentPage?.slug, previousPageId]); + // Initialize to default page useEffect(() => { if (defaultPage?.id && !currentPageId) { @@ -168,5 +190,6 @@ export function usePageNavigation( isBackNavigation, goBack, resetHistory, + getNavigationContext, }; } diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index e95b484..08496ec 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -22,12 +22,15 @@ export interface TransitionConfig { durationSec?: number; targetPageId?: string; displayName?: string; + /** Whether this is a back navigation (for history management) */ + isBack?: boolean; } export interface UseTransitionPlaybackOptions { videoRef: RefObject; transition: TransitionConfig | null; - onComplete: (targetPageId?: string) => void; + /** Called when playback completes. isBack indicates if this was a back navigation. */ + onComplete: (targetPageId?: string, isBack?: boolean) => void; onError?: (reason: string) => void; timeouts?: { @@ -287,7 +290,10 @@ export function useTransitionPlayback( } setPhase('completed'); - onCompleteRef.current(currentTransition?.targetPageId); + onCompleteRef.current( + currentTransition?.targetPageId, + currentTransition?.isBack, + ); }, [clearTimers, videoRef], ); diff --git a/frontend/src/hooks/useTransitionPreview.ts b/frontend/src/hooks/useTransitionPreview.ts index 3773d09..b34e793 100644 --- a/frontend/src/hooks/useTransitionPreview.ts +++ b/frontend/src/hooks/useTransitionPreview.ts @@ -128,6 +128,7 @@ export function useTransitionPreview({ reverseStorageKey: element.reverseVideoUrl, durationSec: element.transitionDurationSec, title: `${element.navLabel || element.label || 'Transition'} · ${direction}`, + isBack: direction === 'back', // Track for history management }; setPreview(previewState); diff --git a/frontend/src/lib/elementDefaults.ts b/frontend/src/lib/elementDefaults.ts index ff44d05..87775ca 100644 --- a/frontend/src/lib/elementDefaults.ts +++ b/frontend/src/lib/elementDefaults.ts @@ -110,6 +110,7 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial< navDisabled: false, iconUrl: '', transitionReverseMode: 'auto_reverse', + navBackMode: 'target_page', }, tooltip: { iconUrl: '', @@ -489,6 +490,7 @@ export const buildElementSettings = ( element.transitionReverseMode, ); addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl); + addIfNotEmpty(settings, 'navBackMode', element.navBackMode); } // Tooltip type settings diff --git a/frontend/src/lib/navigationHelpers.ts b/frontend/src/lib/navigationHelpers.ts index a6140e1..b1e962c 100644 --- a/frontend/src/lib/navigationHelpers.ts +++ b/frontend/src/lib/navigationHelpers.ts @@ -11,19 +11,118 @@ import type { NavigationTarget, TransitionPhase, } from '../types/presentation'; +import { parseJsonObject } from './parseJson'; + +/** + * Context for resolving history-based back navigation + */ +export interface NavigationContext { + currentPageSlug?: string; + previousPageId?: string | null; +} + +/** + * UI schema structure for type-safe parsing + */ +interface UiSchemaStructure { + elements?: Array>; +} + +/** + * Find navigation element on sourcePage that points to targetPageSlug. + * Used for history-based back navigation to get the forward transition. + * + * @param sourcePage - The page to search for navigation elements + * @param targetPageSlug - The target page slug to find + * @returns The navigation element pointing to target page, or null if not found + */ +export const findIncomingNavigationElement = ( + sourcePage: { + ui_schema_json?: string | Record; + slug?: string; + }, + targetPageSlug: string, +): NavigableElement | null => { + // Parse ui_schema_json using shared utility + const uiSchema = parseJsonObject( + sourcePage.ui_schema_json, + {}, + ); + const elements = Array.isArray(uiSchema.elements) ? uiSchema.elements : []; + + // Find navigation element pointing to target page + const found = elements.find( + (el) => + isNavigationType(String(el.type || '')) && + el.targetPageSlug === targetPageSlug, + ); + + if (!found) return null; + + // Type assertion after validation + return found as unknown as NavigableElement; +}; + +/** + * Resolve history-based back navigation target. + * Finds the previous page from history and looks up the forward transition that was used to arrive. + * + * @param pages - Available pages + * @param currentPageSlug - Current page slug + * @param previousPageId - Previous page ID from history + * @returns Navigation target or null if previous page not found + */ +export const resolveHistoryBackTarget = ( + pages: RuntimePage[], + currentPageSlug: string, + previousPageId: string | null, +): NavigationTarget | null => { + if (!previousPageId) return null; + + const previousPage = pages.find((p) => p.id === previousPageId); + if (!previousPage) return null; + + // Look up the forward navigation element that brought user to current page + const incomingElement = findIncomingNavigationElement( + previousPage, + currentPageSlug, + ); + + return { + page: previousPage, + pageId: previousPage.id, + transitionVideoUrl: incomingElement?.transitionVideoUrl, + transitionReverseMode: incomingElement?.transitionReverseMode, + reverseVideoUrl: incomingElement?.reverseVideoUrl, + isBack: true, + }; +}; /** * Resolve target page from element navigation properties. * Supports both targetPageSlug (new) and targetPageId (legacy). + * Also supports history-based back navigation when navBackMode='history'. * * @param element - Element with navigation properties * @param pages - Available pages to search + * @param context - Optional context for history-based navigation * @returns The target page or undefined if not found */ export const resolveNavigationTarget = ( element: NavigableElement, pages: RuntimePage[], + context?: NavigationContext, ): NavigationTarget | null => { + // Handle history-based back navigation + if (isBackNavigation(element) && element.navBackMode === 'history') { + return resolveHistoryBackTarget( + pages, + context?.currentPageSlug || '', + context?.previousPageId || null, + ); + } + + // Standard target_page mode logic const targetPageSlug = element.targetPageSlug; const legacyTargetPageId = element.targetPageId; @@ -45,6 +144,8 @@ export const resolveNavigationTarget = ( page: targetPage, pageId: targetPage.id, transitionVideoUrl: element.transitionVideoUrl, + transitionReverseMode: element.transitionReverseMode, + reverseVideoUrl: element.reverseVideoUrl, isBack, }; }; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index f466015..2cf9c64 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -212,59 +212,65 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {getLayout( <> - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - , - )} + + + + + , + )} diff --git a/frontend/src/pages/access_logs/[access_logsId].tsx b/frontend/src/pages/access_logs/[access_logsId].tsx index 0ec6d17..05dddf1 100644 --- a/frontend/src/pages/access_logs/[access_logsId].tsx +++ b/frontend/src/pages/access_logs/[access_logsId].tsx @@ -67,9 +67,7 @@ const EditAccess_logs = () => { useEffect(() => { // access_logs is now always an array; get the first element for single entity view - const entity = Array.isArray(access_logs) - ? access_logs[0] - : access_logs; + const entity = Array.isArray(access_logs) ? access_logs[0] : access_logs; if (entity && typeof entity === 'object') { const newInitialVal = { ...initVals }; diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index b1d2b4c..9b57d54 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -22,6 +22,7 @@ import { getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePageSwitch } from '../hooks/usePageSwitch'; +import { usePageNavigation } from '../hooks/usePageNavigation'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { logger } from '../lib/logger'; @@ -94,7 +95,6 @@ import { // TourPage type is imported from '../types/entities' // NavigationElementType is imported from '../context/ConstructorContext' - type ConstructorPageProps = { mode?: 'constructor' | 'element_edit'; }; @@ -151,7 +151,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { isAuthReady, }); - const [activePageId, setActivePageId] = useState(''); + // Page navigation with history tracking via shared hook + const { + currentPageId: activePageId, + pageHistory, + applyPageSelection, + getNavigationContext, + setCurrentPageId: setActivePageId, + } = usePageNavigation({ + pages, + trackHistory: true, + }); // Consolidated page background state (replaces 8 separate useState hooks) const { @@ -341,8 +351,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { // Helper to switch pages without flash // Uses usePageSwitch hook to resolve blob URLs from preload cache // Also updates storage path state for editing/saving purposes + // isBack parameter indicates this is a back navigation (pops history instead of pushing) const switchToPage = useCallback( - async (page: TourPage | null) => { + async (page: TourPage | null, isBack = false) => { // Mark this page as initialized to prevent redundant effect calls if (page) { lastInitializedPageIdRef.current = page.id; @@ -363,12 +374,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { : null, () => { if (page) { - setActivePageId(page.id); + // Use applyPageSelection for proper history management (pops on back) + applyPageSelection(page.id, isBack); } }, ); }, - [pageSwitchToPage, updateBackgroundFromPage], + [pageSwitchToPage, updateBackgroundFromPage, applyPageSelection], ); const { isBuffering: isReverseBuffering } = useTransitionPlayback({ @@ -384,14 +396,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { durationSec: transitionPreview.durationSec, targetPageId: pendingNavigationPageId || undefined, displayName: transitionPreview.title, + isBack: transitionPreview.isBack, // Pass through for history management } : null, - onComplete: async (targetPageId) => { + onComplete: async (targetPageId, isBack) => { const video = transitionVideoRef.current; if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId) || null; // Use switchToPage which resolves blob URLs via usePageSwitch - await switchToPage(targetPage); + // Pass isBack flag for proper history management (pops on back) + await switchToPage(targetPage, isBack ?? false); clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); @@ -475,9 +489,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ], ); - const { getDuration, getDurationNote, durationBySource } = useMediaDurationProbe({ - targets: durationProbeTargets, - }); + const { getDuration, getDurationNote, durationBySource } = + useMediaDurationProbe({ + targets: durationProbeTargets, + }); const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl); const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl); @@ -533,21 +548,19 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { // Only set initial page when pages first load (not on every change) if (pages.length > 0 && prevPagesLengthRef.current === 0) { const defaultPageId = pageIdFromRoute || pages[0]?.id || ''; - setActivePageId(defaultPageId); + // Use applyPageSelection to set initial page and history + applyPageSelection(defaultPageId, false); setIsMenuOpen(false); setIsInitializing(false); } prevPagesLengthRef.current = pages.length; - }, [pages, pageIdFromRoute]); + }, [pages, pageIdFromRoute, applyPageSelection]); // Handle query errors useEffect(() => { if (isDataError && dataError) { const message = dataError.message || 'Failed to load constructor data.'; - logger.error( - 'Failed to load constructor data:', - dataError, - ); + logger.error('Failed to load constructor data:', dataError); setErrorMessage(message); } }, [isDataError, dataError]); @@ -677,11 +690,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ? item.galleryTitle : undefined, galleryInfoSpans: Array.isArray(item.galleryInfoSpans) - ? item.galleryInfoSpans.map((span: Partial) => ({ - id: String(span?.id || createLocalId()), - text: String(span?.text ?? ''), - iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined, - })) + ? item.galleryInfoSpans.map( + (span: Partial) => ({ + id: String(span?.id || createLocalId()), + text: String(span?.text ?? ''), + iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined, + }), + ) : undefined, galleryColumns: typeof item.galleryColumns === 'number' @@ -994,20 +1009,51 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { // Use shared navigation helpers const direction = getNavigationDirection(element); - const navTarget = resolveNavigationTarget(element, pages); + + // Get navigation context from hook for history-based back navigation + const navContext = getNavigationContext(); + + // Pass history context for history-based back navigation + const navTarget = resolveNavigationTarget(element, pages, navContext); if (!navTarget) { - setErrorMessage( - 'No target page configured for this navigation button.', - ); + // History mode back buttons need navigation history + if (element.navBackMode === 'history') { + if (!navContext.previousPageId) { + setErrorMessage( + 'No previous page in history. Navigate to another page first.', + ); + } else { + setErrorMessage( + 'Previous page not found. It may have been deleted.', + ); + } + } else { + setErrorMessage( + 'No target page configured for this navigation button.', + ); + } return; } + // For history mode, use transition from navTarget (the forward element that brought us here) + // For target_page mode, use the element's own transition settings + const transitionSource = + element.navBackMode === 'history' + ? { + type: element.type, + transitionVideoUrl: navTarget.transitionVideoUrl, + transitionReverseMode: navTarget.transitionReverseMode, + reverseVideoUrl: navTarget.reverseVideoUrl, + } + : element; + // Check if transition can be played using shared helper - if (!hasPlayableTransition(element, direction)) { + if (!hasPlayableTransition(transitionSource, direction)) { closeTransitionPreview(); // Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash) - switchToPage(navTarget.page).then(() => { + // Pass isBack flag for proper history management + switchToPage(navTarget.page, navTarget.isBack).then(() => { clearSelection(); setSelectedMenuItem('none'); setErrorMessage(''); @@ -1015,7 +1061,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return; } - openPreviewWithTarget(element, direction, navTarget.pageId); + openPreviewWithTarget(transitionSource, direction, navTarget.pageId); } return; } diff --git a/frontend/src/pages/element-type-defaults.tsx b/frontend/src/pages/element-type-defaults.tsx index e627cb1..74d558b 100644 --- a/frontend/src/pages/element-type-defaults.tsx +++ b/frontend/src/pages/element-type-defaults.tsx @@ -42,7 +42,9 @@ const ElementTypeDefaultsPage = () => { : []; setRows(nextRows); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || diff --git a/frontend/src/pages/project-element-defaults.tsx b/frontend/src/pages/project-element-defaults.tsx index 20eee47..b84e0c0 100644 --- a/frontend/src/pages/project-element-defaults.tsx +++ b/frontend/src/pages/project-element-defaults.tsx @@ -50,7 +50,10 @@ const ProjectElementDefaultsPage = () => { const response = await axios.get(`/projects/${projectId}`); setProject(response?.data || null); } catch (error: unknown) { - logger.error('Failed to load project:', error instanceof Error ? error : { error }); + logger.error( + 'Failed to load project:', + error instanceof Error ? error : { error }, + ); } }, [projectId]); @@ -71,7 +74,9 @@ const ProjectElementDefaultsPage = () => { : []; setRows(nextRows); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; const message = axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || diff --git a/frontend/src/pages/projects/[projectsId].tsx b/frontend/src/pages/projects/[projectsId].tsx index 3d59cf6..8529c95 100644 --- a/frontend/src/pages/projects/[projectsId].tsx +++ b/frontend/src/pages/projects/[projectsId].tsx @@ -59,7 +59,9 @@ const ProjectWorkspacePage = () => { const response = await axios.get(`/projects/${projectId}`); setProject(response?.data || null); } catch (error: unknown) { - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || @@ -123,7 +125,9 @@ const ProjectWorkspacePage = () => { ); const axiosError = error as { response?: { data?: string } }; const message = - axiosError?.response?.data || (error instanceof Error ? error.message : null) || 'Publish failed'; + axiosError?.response?.data || + (error instanceof Error ? error.message : null) || + 'Publish failed'; toast(typeof message === 'string' ? message : 'Publish failed', { type: 'error', position: 'bottom-center', diff --git a/frontend/src/pages/publish_events/publish_events-list.tsx b/frontend/src/pages/publish_events/publish_events-list.tsx index 12aef41..1bc7228 100644 --- a/frontend/src/pages/publish_events/publish_events-list.tsx +++ b/frontend/src/pages/publish_events/publish_events-list.tsx @@ -69,7 +69,9 @@ const PublishEventsHistoryPage = () => { 'Failed to load publish history:', error instanceof Error ? error : { error }, ); - const axiosError = error as { response?: { data?: { message?: string } } }; + const axiosError = error as { + response?: { data?: { message?: string } }; + }; setErrorMessage( axiosError?.response?.data?.message || (error instanceof Error ? error.message : null) || diff --git a/frontend/src/stores/constructor/constructorSlice.ts b/frontend/src/stores/constructor/constructorSlice.ts index f6bb092..b13e358 100644 --- a/frontend/src/stores/constructor/constructorSlice.ts +++ b/frontend/src/stores/constructor/constructorSlice.ts @@ -72,8 +72,7 @@ const constructorSlice = createSlice({ state, action: PayloadAction<{ projectId: string; pageId: string }>, ) => { - state.lastActivePageId[action.payload.projectId] = - action.payload.pageId; + state.lastActivePageId[action.payload.projectId] = action.payload.pageId; }, /** diff --git a/frontend/src/stores/createEntitySlice.ts b/frontend/src/stores/createEntitySlice.ts index 09eedca..4fafca5 100644 --- a/frontend/src/stores/createEntitySlice.ts +++ b/frontend/src/stores/createEntitySlice.ts @@ -14,10 +14,7 @@ import { rejectNotify, resetNotify, } from '../helpers/notifyStateHandler'; -import type { - EntitySliceConfig, - EntitySliceState, -} from '../types/redux'; +import type { EntitySliceConfig, EntitySliceState } from '../types/redux'; import type { PaginatedResponse, FetchParams, ApiError } from '../types/api'; import type { BaseEntity } from '../types/entities'; diff --git a/frontend/src/stores/openAiSlice.ts b/frontend/src/stores/openAiSlice.ts index 0fd90fa..d5fa568 100644 --- a/frontend/src/stores/openAiSlice.ts +++ b/frontend/src/stores/openAiSlice.ts @@ -34,20 +34,23 @@ const fulfilledNotify = ( state.notify.showNotification = true; }; -export const aiPrompt = createAsyncThunk( - 'openai/aiPrompt', - async (data, { rejectWithValue }) => { - try { - const response = await axios.post('/openai/create_widget', data); - return response.data; - } catch (error) { - if (!error.response) { - throw error; - } - return rejectWithValue(error.response.data); +export const aiPrompt = createAsyncThunk< + CreateWidgetResponse, + CreateWidgetRequest +>('openai/aiPrompt', async (data, { rejectWithValue }) => { + try { + const response = await axios.post( + '/openai/create_widget', + data, + ); + return response.data; + } catch (error) { + if (!error.response) { + throw error; } - }, -); + return rejectWithValue(error.response.data); + } +}); export const askGpt = createAsyncThunk( 'openai/askGpt', diff --git a/frontend/src/stores/roles/rolesSlice.ts b/frontend/src/stores/roles/rolesSlice.ts index 0a51081..284b01e 100644 --- a/frontend/src/stores/roles/rolesSlice.ts +++ b/frontend/src/stores/roles/rolesSlice.ts @@ -52,13 +52,18 @@ export const fetchWidgets = createAsyncThunk( ); // Initial state with widgets -const initialWidgetsState: { rolesWidgets: Array<{ id: string; [key: string]: unknown }> } = { +const initialWidgetsState: { + rolesWidgets: Array<{ id: string; [key: string]: unknown }>; +} = { rolesWidgets: [], }; // Combined reducer that extends base reducer with widget handling const combinedReducer = createReducer( - { ...baseReducer(undefined, { type: '' }), ...initialWidgetsState } as RolesSliceState, + { + ...baseReducer(undefined, { type: '' }), + ...initialWidgetsState, + } as RolesSliceState, (builder) => { // Handle fetchWidgets.fulfilled builder.addCase(fetchWidgets.fulfilled, (state, action) => { diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index f91bdfb..e25f347 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -226,6 +226,8 @@ export interface CanvasElement extends BaseCanvasElement { transitionVideoUrl?: string; transitionReverseMode?: 'auto_reverse' | 'separate_video'; reverseVideoUrl?: string; + /** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */ + navBackMode?: 'target_page' | 'history'; transitionDurationSec?: number; // Gallery Carousel Settings galleryCarouselPrevIconUrl?: string; @@ -571,16 +573,18 @@ export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = { /** * Create page background state from tour page data */ -export function createPageBackgroundFromPage(page: { - background_image_url?: string; - background_video_url?: string; - background_audio_url?: string; - background_video_autoplay?: boolean; - background_video_loop?: boolean; - background_video_muted?: boolean; - background_video_start_time?: number | null; - background_video_end_time?: number | null; -} | null): PageBackgroundState { +export function createPageBackgroundFromPage( + page: { + background_image_url?: string; + background_video_url?: string; + background_audio_url?: string; + background_video_autoplay?: boolean; + background_video_loop?: boolean; + background_video_muted?: boolean; + background_video_start_time?: number | null; + background_video_end_time?: number | null; + } | null, +): PageBackgroundState { if (!page) { return { ...DEFAULT_PAGE_BACKGROUND }; } diff --git a/frontend/src/types/presentation.ts b/frontend/src/types/presentation.ts index 496887a..4b3a169 100644 --- a/frontend/src/types/presentation.ts +++ b/frontend/src/types/presentation.ts @@ -25,6 +25,8 @@ export interface TransitionPreviewState { durationSec?: number; /** Display title for the preview */ title: string; + /** Whether this is a back navigation (for history management) */ + isBack?: boolean; } /** @@ -43,6 +45,8 @@ export interface NavigationTarget { page: RuntimePage; pageId: string; transitionVideoUrl?: string; + transitionReverseMode?: 'auto_reverse' | 'separate_video'; + reverseVideoUrl?: string; isBack: boolean; } @@ -76,8 +80,12 @@ export interface NavigableElement { targetPageSlug?: string; targetPageId?: string; transitionVideoUrl?: string; + transitionReverseMode?: 'auto_reverse' | 'separate_video'; + reverseVideoUrl?: string; navType?: 'forward' | 'back'; navDisabled?: boolean; + /** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */ + navBackMode?: 'target_page' | 'history'; } /** diff --git a/frontend/src/types/redux.ts b/frontend/src/types/redux.ts index 6d8e86f..da51b0f 100644 --- a/frontend/src/types/redux.ts +++ b/frontend/src/types/redux.ts @@ -20,7 +20,6 @@ export interface EntitySliceState { [entityName: string]: T[] | boolean | number | NotificationState | unknown; } - // Slice factory configuration export interface EntitySliceConfig { name: string;