added go back navigation with two modes (target page and previous route)

This commit is contained in:
Dmitri 2026-04-06 16:42:02 +04:00
parent aac20d29a3
commit f6d0aeafd7
55 changed files with 814 additions and 434 deletions

View File

@ -19,7 +19,11 @@ import {
mdiExitToApp, mdiExitToApp,
} from '@mdi/js'; } from '@mdi/js';
import MenuActionButton from './MenuActionButton'; import MenuActionButton from './MenuActionButton';
import type { Position, CanvasElementType, NavigationElementType } from './types'; import type {
Position,
CanvasElementType,
NavigationElementType,
} from './types';
import type { EditorMenuItem } from '../../types/constructor'; import type { EditorMenuItem } from '../../types/constructor';
interface ConstructorMenuProps { interface ConstructorMenuProps {
@ -65,108 +69,108 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl' className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
> >
<div <div
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg' className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
onMouseDown={onDragStart} onMouseDown={onDragStart}
> >
<span className='text-xs font-bold uppercase'>Constructor Menu</span> <span className='text-xs font-bold uppercase'>Constructor Menu</span>
<button type='button' onClick={onToggleOpen}> <button type='button' onClick={onToggleOpen}>
<BaseIcon path={mdiMenu} size={18} /> <BaseIcon path={mdiMenu} size={18} />
</button> </button>
</div> </div>
{isOpen && ( {isOpen && (
<div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'> <div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'>
<MenuActionButton <MenuActionButton
icon={mdiImageMultiple} icon={mdiImageMultiple}
label='Background Image' label='Background Image'
onClick={() => onSelectMenuItem('background_image')} onClick={() => onSelectMenuItem('background_image')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiViewCarousel} icon={mdiViewCarousel}
label='Background Video' label='Background Video'
onClick={() => onSelectMenuItem('background_video')} onClick={() => onSelectMenuItem('background_video')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiTooltipText} icon={mdiTooltipText}
label='Background Audio' label='Background Audio'
onClick={() => onSelectMenuItem('background_audio')} onClick={() => onSelectMenuItem('background_audio')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiSwapHorizontal} icon={mdiSwapHorizontal}
label='Add Navigation Button' label='Add Navigation Button'
onClick={() => onAddElement(allowedNavigationTypes[0])} onClick={() => onAddElement(allowedNavigationTypes[0])}
/> />
<MenuActionButton <MenuActionButton
icon={mdiSwapHorizontal} icon={mdiSwapHorizontal}
label='Add Transition' label='Add Transition'
onClick={() => onSelectMenuItem('create_transition')} onClick={() => onSelectMenuItem('create_transition')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiImageMultiple} icon={mdiImageMultiple}
label='Add Gallery' label='Add Gallery'
onClick={() => onAddElement('gallery')} onClick={() => onAddElement('gallery')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiViewCarousel} icon={mdiViewCarousel}
label='Add Carousel' label='Add Carousel'
onClick={() => onAddElement('carousel')} onClick={() => onAddElement('carousel')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiTooltipText} icon={mdiTooltipText}
label='Add Tooltip' label='Add Tooltip'
onClick={() => onAddElement('tooltip')} onClick={() => onAddElement('tooltip')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiText} icon={mdiText}
label='Add Description' label='Add Description'
onClick={() => onAddElement('description')} onClick={() => onAddElement('description')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiViewCarousel} icon={mdiViewCarousel}
label='Add Video Player' label='Add Video Player'
onClick={() => onAddElement('video_player')} onClick={() => onAddElement('video_player')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiTooltipText} icon={mdiTooltipText}
label='Add Audio Player' label='Add Audio Player'
onClick={() => onAddElement('audio_player')} onClick={() => onAddElement('audio_player')}
/> />
<MenuActionButton <MenuActionButton
icon={mdiPlus} icon={mdiPlus}
label={isCreatingPage ? 'Creating Page...' : 'Create New Page'} label={isCreatingPage ? 'Creating Page...' : 'Create New Page'}
onClick={onCreatePage} onClick={onCreatePage}
disabled={isCreatingPage} disabled={isCreatingPage}
/> />
<div className='pt-2 border-t border-gray-200 space-y-1'> <div className='pt-2 border-t border-gray-200 space-y-1'>
<div className='flex gap-1'> <div className='flex gap-1'>
<BaseButton <BaseButton
small small
color='info' color='info'
label={isSaving ? 'Saving...' : 'Save'} label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave} icon={mdiContentSave}
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
/> />
<BaseButton <BaseButton
small small
color='success' color='success'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'} label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
onClick={onSaveToStage} onClick={onSaveToStage}
disabled={isSavingToStage} disabled={isSavingToStage}
/>
</div>
<MenuActionButton
icon={mdiExitToApp}
label='Exit'
onClick={onExit}
className='!text-red-700'
/> />
</div> </div>
<MenuActionButton
icon={mdiExitToApp}
label='Exit'
onClick={onExit}
className='!text-red-700'
/>
</div> </div>
</div> )}
)} </div>
</div>
); );
}, },
); );

View File

@ -322,6 +322,7 @@ export function ElementEditorPanel({
selectedElement.transitionReverseMode || 'auto_reverse' selectedElement.transitionReverseMode || 'auto_reverse'
} }
reverseVideoUrl={selectedElement.reverseVideoUrl || ''} reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
navBackMode={selectedElement.navBackMode}
allowedNavigationTypes={allowedNavigationTypes} allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={assetOptions.icon} iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo} transitionVideoOptions={assetOptions.transitionVideo}
@ -718,7 +719,11 @@ export function ElementEditorPanel({
color: selectedElement.color || '', color: selectedElement.color || '',
}} }}
onChange={(prop, value) => onChange={(prop, value) =>
handleCssPropertyChange(prop, value, updateSelectedElement) handleCssPropertyChange(
prop,
value,
updateSelectedElement,
)
} }
/> />
</> </>

View File

@ -31,6 +31,7 @@ interface NavigationSettingsSectionCompactProps {
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;
navBackMode?: 'target_page' | 'history';
allowedNavigationTypes: NavigationElementType[]; allowedNavigationTypes: NavigationElementType[];
iconAssetOptions: AssetOption[]; iconAssetOptions: AssetOption[];
transitionVideoOptions: AssetOption[]; transitionVideoOptions: AssetOption[];
@ -67,6 +68,7 @@ const NavigationSettingsSectionCompact: React.FC<
transitionVideoUrl, transitionVideoUrl,
transitionReverseMode, transitionReverseMode,
reverseVideoUrl, reverseVideoUrl,
navBackMode,
allowedNavigationTypes, allowedNavigationTypes,
iconAssetOptions, iconAssetOptions,
transitionVideoOptions, transitionVideoOptions,
@ -184,126 +186,156 @@ const NavigationSettingsSectionCompact: React.FC<
)} )}
</div> </div>
<div> {/* Back Navigation Mode - only shown for back buttons */}
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> {currentKind === 'back' && (
Target page
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={targetPageSlug}
onChange={(event) => {
onChange('targetPageSlug', event.target.value);
onChange('targetPageId', ''); // Clear legacy ID
}}
>
<option value=''>Not selected</option>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionVideoUrl}
onChange={(event) => {
onChange('transitionVideoUrl', event.target.value);
}}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
transitionVideoUrl,
`Current video · ${transitionVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedTransitionDurationNote && (
<p className='mt-1 text-[11px] text-gray-500'>
{selectedTransitionDurationNote}
</p>
)}
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionReverseMode}
onChange={(event) =>
onChange(
'transitionReverseMode',
event.target.value === 'separate_video'
? 'separate_video'
: 'auto_reverse',
)
}
>
<option value='auto_reverse'>Auto reverse transition video</option>
<option value='separate_video'>
Use separate back-transition video
</option>
</select>
</div>
{transitionReverseMode === 'separate_video' && (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition video asset Back Navigation Mode
</label> </label>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={reverseVideoUrl} value={navBackMode || 'target_page'}
onChange={(event) => onChange={(e) => onChange('navBackMode', e.target.value)}
onChange('reverseVideoUrl', event.target.value)
}
> >
<option value=''>Not selected</option> <option value='target_page'>Fixed target page</option>
{addFallbackAssetOption( <option value='history'>Previous page (browser-like)</option>
transitionVideoOptions,
reverseVideoUrl,
`Current back video · ${reverseVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select> </select>
{navBackMode === 'history' && (
<p className='mt-1 text-[11px] text-gray-500'>
Returns to the page user came from, using the original forward
transition in reverse.
</p>
)}
</div> </div>
)} )}
<p className='text-[11px] text-gray-500'> {/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */}
Transition duration is set automatically from the selected video. {(currentKind === 'forward' || navBackMode !== 'history') && (
</p> <>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Target page
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={targetPageSlug}
onChange={(event) => {
onChange('targetPageSlug', event.target.value);
onChange('targetPageId', ''); // Clear legacy ID
}}
>
<option value=''>Not selected</option>
{pages
.filter((page) => page.id !== activePageId)
.map((page, index) => (
<option key={page.id} value={page.slug || ''}>
{page.name || `Page ${index + 1}`}
</option>
))}
</select>
</div>
{onPreviewTransition && ( <div>
<div className='flex gap-2 pt-1'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
<BaseButton Transition video asset
small </label>
color='lightDark' <select
label='Preview Forward' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
onClick={() => onPreviewTransition('forward')} value={transitionVideoUrl}
/> onChange={(event) => {
<BaseButton onChange('transitionVideoUrl', event.target.value);
small }}
color='lightDark' >
label='Preview Back' <option value=''>Not selected</option>
onClick={() => onPreviewTransition('back')} {addFallbackAssetOption(
/> transitionVideoOptions,
</div> transitionVideoUrl,
`Current video · ${transitionVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedTransitionDurationNote && (
<p className='mt-1 text-[11px] text-gray-500'>
{selectedTransitionDurationNote}
</p>
)}
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionReverseMode}
onChange={(event) =>
onChange(
'transitionReverseMode',
event.target.value === 'separate_video'
? 'separate_video'
: 'auto_reverse',
)
}
>
<option value='auto_reverse'>
Auto reverse transition video
</option>
<option value='separate_video'>
Use separate back-transition video
</option>
</select>
</div>
{transitionReverseMode === 'separate_video' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back transition video asset
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={reverseVideoUrl}
onChange={(event) =>
onChange('reverseVideoUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{addFallbackAssetOption(
transitionVideoOptions,
reverseVideoUrl,
`Current back video · ${reverseVideoUrl}`,
).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
<p className='text-[11px] text-gray-500'>
Transition duration is set automatically from the selected video.
</p>
{onPreviewTransition && (
<div className='flex gap-2 pt-1'>
<BaseButton
small
color='lightDark'
label='Preview Forward'
onClick={() => onPreviewTransition('forward')}
/>
<BaseButton
small
color='lightDark'
label='Preview Back'
onClick={() => onPreviewTransition('back')}
/>
</div>
)}
</>
)} )}
</div> </div>
); );

View File

@ -18,6 +18,7 @@ import BaseButton from '../BaseButton';
import { useOfflineMode } from '../../hooks/useOfflineMode'; import { useOfflineMode } from '../../hooks/useOfflineMode';
import { useStorageQuota } from '../../hooks/useStorageQuota'; import { useStorageQuota } from '../../hooks/useStorageQuota';
import type { ProjectOfflineStatus } from '../../types/offline'; import type { ProjectOfflineStatus } from '../../types/offline';
import { logger } from '../../lib/logger';
interface OfflineToggleProps { interface OfflineToggleProps {
projectId: string | null; projectId: string | null;
@ -62,13 +63,13 @@ export function OfflineToggle({
// Show toast notification when download completes // Show toast notification when download completes
useEffect(() => { useEffect(() => {
console.log('[OfflineToggle] Status changed:', { logger.debug('[OfflineToggle] Status changed:', {
prev: prevStatusRef.current, prev: prevStatusRef.current,
current: status, current: status,
}); });
// Only show toast when transitioning FROM downloading TO downloaded // Only show toast when transitioning FROM downloading TO downloaded
if (prevStatusRef.current === 'downloading' && status === '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!', { toast.success('Presentation ready for offline mode!', {
position: 'bottom-center', position: 'bottom-center',
autoClose: 5000, autoClose: 5000,

View File

@ -28,6 +28,7 @@ import LayoutGuest from '../layouts/Guest';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader'; import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { useProjectAssets } from '../hooks/useProjectAssets'; import { useProjectAssets } from '../hooks/useProjectAssets';
import { usePageNavigation } from '../hooks/usePageNavigation';
import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
@ -66,8 +67,18 @@ export default function RuntimePresentation({
// Resolve project assets (favicon, og_image, logo) to presigned URLs // Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project); const { faviconUrl, ogImageUrl } = useProjectAssets(project);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null); // Page navigation with history tracking via shared hook
const [pageHistory, setPageHistory] = useState<string[]>([]); const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId || undefined,
trackHistory: true,
});
const [transitionPreview, setTransitionPreview] = useState<{ const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string; targetPageId: string;
videoUrl: string; videoUrl: string;
@ -86,13 +97,7 @@ export default function RuntimePresentation({
const transitionVideoRef = useRef<HTMLVideoElement>(null); const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null); const lastInitializedPageIdRef = useRef<string | null>(null);
// Set initial page when data loads // Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
useEffect(() => {
if (initialPageId && !selectedPageId) {
setSelectedPageId(initialPageId);
setPageHistory([initialPageId]);
}
}, [initialPageId, selectedPageId]);
// Extract page links and preload elements from ui_schema_json // Extract page links and preload elements from ui_schema_json
// This enables the neighbor graph to find connected pages for preloading // This enables the neighbor graph to find connected pages for preloading
@ -143,17 +148,18 @@ export default function RuntimePresentation({
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
targetPageId: transitionPreview.targetPageId, targetPageId: transitionPreview.targetPageId,
displayName: 'Transition', displayName: 'Transition',
isBack: transitionPreview.isReverse, // Pass through for history management
} }
: null, : null,
onComplete: async (targetPageId) => { onComplete: async (targetPageId, isBack) => {
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
// Use shared hook to resolve blob URLs and switch page // Use shared hook to resolve blob URLs and switch page
await pageSwitch.switchToPage(targetPage, () => { await pageSwitch.switchToPage(targetPage, () => {
setSelectedPageId(targetPageId); // Use applyPageSelection for proper history management (pops on back)
setPageHistory((prev) => [...prev, targetPageId]); applyPageSelection(targetPageId, isBack ?? false);
}); });
setIsBackgroundReady(false); setIsBackgroundReady(false);
// Signal that transition is complete and waiting for Image onLoad // Signal that transition is complete and waiting for Image onLoad
@ -300,12 +306,12 @@ export default function RuntimePresentation({
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
await pageSwitch.switchToPage(targetPage, () => { await pageSwitch.switchToPage(targetPage, () => {
setSelectedPageId(targetPageId); // Use applyPageSelection for proper history management (pops on back)
setPageHistory((prev) => [...prev, targetPageId]); applyPageSelection(targetPageId, isBack);
}); });
} }
}, },
[pages, pageSwitch, resetFadeOut], [pages, pageSwitch, resetFadeOut, applyPageSelection],
); );
const handleElementClick = useCallback( const handleElementClick = useCallback(
@ -317,8 +323,11 @@ export default function RuntimePresentation({
return; return;
} }
// Use shared helper to resolve navigation target // Get navigation context from hook for history-based back navigation
const navTarget = resolveNavigationTarget(element, pages); const navContext = getNavigationContext();
// Use shared helper to resolve navigation target with history context
const navTarget = resolveNavigationTarget(element, pages, navContext);
// Debug: log element navigation data // Debug: log element navigation data
logger.info('Element clicked', { logger.info('Element clicked', {
@ -328,6 +337,8 @@ export default function RuntimePresentation({
resolvedTargetPageId: navTarget?.pageId, resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl, transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl), hasTransition: Boolean(element.transitionVideoUrl),
navBackMode: element.navBackMode,
previousPageId: navContext.previousPageId,
}); });
if (navTarget) { if (navTarget) {
@ -338,7 +349,7 @@ export default function RuntimePresentation({
); );
} }
}, },
[navigateToPage, pages, transitionPhase, isBuffering], [navigateToPage, pages, transitionPhase, isBuffering, getNavigationContext],
); );
// Handler for gallery card clicks // Handler for gallery card clicks

View File

@ -31,7 +31,10 @@ export const SelectField = ({
setValue(option); 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 path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {

View File

@ -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 path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize'; 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 }); const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[]; type ValueType = { [key: string]: string | number }[];

View File

@ -3,7 +3,10 @@ import { Line } from 'react-chartjs-2';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { humanize } from '../../../../helpers/humanize'; import { humanize } from '../../../../helpers/humanize';
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import type {
ChartComponentProps,
ChartValueArray,
} from '../../../../types/charts';
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize'; 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 }); const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[]; type ValueType = { [key: string]: string | number }[];

View File

@ -14,7 +14,10 @@ import {
} from 'chart.js'; } from 'chart.js';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { logger } from '../../../../lib/logger'; import { logger } from '../../../../lib/logger';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import type {
ChartComponentProps,
ChartValueArray,
} from '../../../../types/charts';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { humanize } from '../../../helpers/humanize'; 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 }); const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[]; type ValueType = { [key: string]: string | number }[];

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize'; 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 }); const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[]; type ValueType = { [key: string]: string | number }[];

View File

@ -13,7 +13,10 @@ import {
Tooltip, Tooltip,
ChartData, ChartData,
} from 'chart.js'; } from 'chart.js';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import type {
ChartComponentProps,
ChartValueArray,
} from '../../../../types/charts';
Chart.register( Chart.register(
LineElement, LineElement,

View File

@ -1,13 +1,19 @@
import React from 'react'; import React from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import chroma from 'chroma-js'; 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 }); const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[]; type ValueType = { [key: string]: string | number }[];
export const ApexPieChart = ({ widget }: ChartComponentProps) => { export const ApexPieChart = ({ widget }: ChartComponentProps) => {
const optionsForPieChart = (value: ValueType, chartColor?: string | string[]) => { const optionsForPieChart = (
value: ValueType,
chartColor?: string | string[],
) => {
const chartColors = Array.isArray(chartColor) const chartColors = Array.isArray(chartColor)
? chartColor ? chartColor
: [chartColor || '#3751FF']; : [chartColor || '#3751FF'];

View File

@ -3,7 +3,10 @@ import { humanize } from '../../../../helpers/humanize';
import { Pie } from 'react-chartjs-2'; import { Pie } from 'react-chartjs-2';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import type {
ChartComponentProps,
ChartValueArray,
} from '../../../../types/charts';
import { import {
Chart as ChartJS, Chart as ChartJS,

View File

@ -140,7 +140,9 @@ const TourFlowManager = () => {
setPages(getRows(pagesResponse)); setPages(getRows(pagesResponse));
setTransitions([]); setTransitions([]);
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
setErrorMessage( setErrorMessage(
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
@ -366,7 +368,9 @@ const TourFlowManager = () => {
setNewPageSlug(''); setNewPageSlug('');
await loadData(); await loadData();
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
@ -403,7 +407,9 @@ const TourFlowManager = () => {
setPages((prev) => prev.filter((item) => item.id !== id)); setPages((prev) => prev.filter((item) => item.id !== id));
} }
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
setErrorMessage( setErrorMessage(
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||

View File

@ -37,7 +37,10 @@ export const RoleSelect = ({
setValue(option); 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 path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {

View File

@ -62,8 +62,13 @@ export const WidgetCreator = ({
userId: currentUser?.id, userId: currentUser?.id,
}; };
const result = await dispatch(aiPrompt(payload)); const result = await dispatch(aiPrompt(payload));
const responcePayload = result.payload as { data?: { error?: { message?: string } } } | undefined; const responcePayload = result.payload as
const error = 'error' in result ? result.error as { message?: string } | undefined : undefined; | { data?: { error?: { message?: string } } }
| undefined;
const error =
'error' in result
? (result.error as { message?: string } | undefined)
: undefined;
await getWidgets().then(); await getWidgets().then();

View File

@ -102,7 +102,9 @@ export interface ConstructorContextValue {
setBackgroundImageUrl: (url: string) => void; setBackgroundImageUrl: (url: string) => void;
setBackgroundVideoUrl: (url: string) => void; setBackgroundVideoUrl: (url: string) => void;
setBackgroundAudioUrl: (url: string) => void; setBackgroundAudioUrl: (url: string) => void;
setBackgroundVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void; setBackgroundVideoSettings: (
settings: Partial<PageBackgroundVideoSettings>,
) => void;
// Element state // Element state
elements: CanvasElement[]; elements: CanvasElement[];

View File

@ -43,7 +43,10 @@ export type {
} from './usePageBackground'; } from './usePageBackground';
export { useConstructorData } from './useConstructorData'; export { useConstructorData } from './useConstructorData';
export { useAssetOptions } from './useAssetOptions'; export { useAssetOptions } from './useAssetOptions';
export type { AssetOptionsResult, UseAssetOptionsOptions } from './useAssetOptions'; export type {
AssetOptionsResult,
UseAssetOptionsOptions,
} from './useAssetOptions';
export { useTransitionCreation } from './useTransitionCreation'; export { useTransitionCreation } from './useTransitionCreation';
export type { export type {
TransitionCreationState, TransitionCreationState,

View File

@ -4,16 +4,56 @@
* Centralized exports for React Query hooks. * Centralized exports for React Query hooks.
*/ */
export { useProjectsQuery, useProjectQuery, useUpdateProjectMutation } from './useProjectQuery'; export {
export { usePagesQuery, usePageQuery, useUpdatePageMutation, useCreatePageMutation, useDeletePageMutation } from './usePagesQuery'; useProjectsQuery,
export { useAssetsQuery, useAssetQuery, useUpdateAssetMutation, useDeleteAssetMutation } from './useAssetsQuery'; useProjectQuery,
useUpdateProjectMutation,
} from './useProjectQuery';
export {
usePagesQuery,
usePageQuery,
useUpdatePageMutation,
useCreatePageMutation,
useDeletePageMutation,
} from './usePagesQuery';
export {
useAssetsQuery,
useAssetQuery,
useUpdateAssetMutation,
useDeleteAssetMutation,
} from './useAssetsQuery';
export { useElementDefaultsQuery } from './useElementDefaultsQuery'; export { useElementDefaultsQuery } from './useElementDefaultsQuery';
export { useUsersQuery, useCurrentUserQuery, useUserQuery, useUpdateUserMutation, useCreateUserMutation, useDeleteUserMutation } from './useUsersQuery'; export {
export { useRolesQuery, useRoleQuery, useUpdateRoleMutation, useCreateRoleMutation, useDeleteRoleMutation } from './useRolesQuery'; useUsersQuery,
useCurrentUserQuery,
useUserQuery,
useUpdateUserMutation,
useCreateUserMutation,
useDeleteUserMutation,
} from './useUsersQuery';
export {
useRolesQuery,
useRoleQuery,
useUpdateRoleMutation,
useCreateRoleMutation,
useDeleteRoleMutation,
} from './useRolesQuery';
export { usePermissionsQuery } from './usePermissionsQuery'; export { usePermissionsQuery } from './usePermissionsQuery';
export { useAccessLogsQuery } from './useAccessLogsQuery'; export { useAccessLogsQuery } from './useAccessLogsQuery';
export { useAssetVariantsQuery } from './useAssetVariantsQuery'; export { useAssetVariantsQuery } from './useAssetVariantsQuery';
export { useProjectMembershipsQuery, useCreateProjectMembershipMutation, useDeleteProjectMembershipMutation } from './useProjectMembershipsQuery'; export {
export { useProjectAudioTracksQuery, useProjectAudioTrackQuery, useCreateProjectAudioTrackMutation, useDeleteProjectAudioTrackMutation } from './useProjectAudioTracksQuery'; useProjectMembershipsQuery,
useCreateProjectMembershipMutation,
useDeleteProjectMembershipMutation,
} from './useProjectMembershipsQuery';
export {
useProjectAudioTracksQuery,
useProjectAudioTrackQuery,
useCreateProjectAudioTrackMutation,
useDeleteProjectAudioTrackMutation,
} from './useProjectAudioTracksQuery';
export { usePublishEventsQuery } from './usePublishEventsQuery'; export { usePublishEventsQuery } from './usePublishEventsQuery';
export { usePwaCachesQuery, useDeletePwaCacheMutation } from './usePwaCachesQuery'; export {
usePwaCachesQuery,
useDeletePwaCacheMutation,
} from './usePwaCachesQuery';

View File

@ -39,7 +39,9 @@ export function useAccessLogsQuery(params?: AccessLogListParams) {
return useQuery({ return useQuery({
queryKey: queryKeys.accessLogs.list(params), queryKey: queryKeys.accessLogs.list(params),
queryFn: async (): Promise<AccessLog[]> => { queryFn: async (): Promise<AccessLog[]> => {
const response = await axios.get<AccessLogListResponse>(`access_logs${query}`); const response = await axios.get<AccessLogListResponse>(
`access_logs${query}`,
);
return response.data.rows; return response.data.rows;
}, },
staleTime: 1 * 60 * 1000, // Access logs change frequently staleTime: 1 * 60 * 1000, // Access logs change frequently

View File

@ -31,7 +31,7 @@ export function useAssetVariantsQuery(assetId: string | undefined) {
queryKey: queryKeys.assetVariants.list(assetId || ''), queryKey: queryKeys.assetVariants.list(assetId || ''),
queryFn: async (): Promise<AssetVariant[]> => { queryFn: async (): Promise<AssetVariant[]> => {
const response = await axios.get<AssetVariantListResponse>( const response = await axios.get<AssetVariantListResponse>(
`asset_variants?assetId=${assetId}` `asset_variants?assetId=${assetId}`,
); );
return response.data.rows; return response.data.rows;
}, },

View File

@ -38,9 +38,7 @@ export function useElementDefaultsQuery(projectId: string | undefined) {
// Process and normalize the defaults // Process and normalize the defaults
const normalizedDefaults = response.data.rows const normalizedDefaults = response.data.rows
.map((row) => normalizeElementDefault(row)) .map((row) => normalizeElementDefault(row))
.filter( .filter((d): d is NormalizedElementDefault => d !== null);
(d): d is NormalizedElementDefault => d !== null,
);
return buildElementDefaultsMap(normalizedDefaults); return buildElementDefaultsMap(normalizedDefaults);
}, },

View File

@ -71,10 +71,7 @@ export function useUpdatePageMutation() {
}, },
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
// Update the single page cache // Update the single page cache
queryClient.setQueryData( queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data);
queryKeys.tourPages.detail(variables.id),
data,
);
// Invalidate list queries // Invalidate list queries
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
}, },

View File

@ -32,7 +32,7 @@ export function useProjectAudioTracksQuery(projectId: string | undefined) {
queryKey: queryKeys.projectAudioTracks.list(projectId || ''), queryKey: queryKeys.projectAudioTracks.list(projectId || ''),
queryFn: async (): Promise<ProjectAudioTrack[]> => { queryFn: async (): Promise<ProjectAudioTrack[]> => {
const response = await axios.get<ProjectAudioTrackListResponse>( const response = await axios.get<ProjectAudioTrackListResponse>(
`project_audio_tracks?projectId=${projectId}` `project_audio_tracks?projectId=${projectId}`,
); );
return response.data.rows; return response.data.rows;
}, },
@ -48,7 +48,9 @@ export function useProjectAudioTrackQuery(trackId: string | undefined) {
return useQuery({ return useQuery({
queryKey: queryKeys.projectAudioTracks.detail(trackId || ''), queryKey: queryKeys.projectAudioTracks.detail(trackId || ''),
queryFn: async (): Promise<ProjectAudioTrack> => { queryFn: async (): Promise<ProjectAudioTrack> => {
const response = await axios.get<ProjectAudioTrack>(`project_audio_tracks/${trackId}`); const response = await axios.get<ProjectAudioTrack>(
`project_audio_tracks/${trackId}`,
);
return response.data; return response.data;
}, },
enabled: !!trackId, enabled: !!trackId,
@ -63,8 +65,13 @@ export function useCreateProjectAudioTrackMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (data: Partial<ProjectAudioTrack>): Promise<ProjectAudioTrack> => { mutationFn: async (
const response = await axios.post<ProjectAudioTrack>('project_audio_tracks', { data }); data: Partial<ProjectAudioTrack>,
): Promise<ProjectAudioTrack> => {
const response = await axios.post<ProjectAudioTrack>(
'project_audio_tracks',
{ data },
);
return response.data; return response.data;
}, },
onSuccess: (_, variables) => { onSuccess: (_, variables) => {

View File

@ -34,7 +34,7 @@ export function useProjectMembershipsQuery(projectId: string | undefined) {
queryKey: queryKeys.projectMemberships.list(projectId || ''), queryKey: queryKeys.projectMemberships.list(projectId || ''),
queryFn: async (): Promise<ProjectMembership[]> => { queryFn: async (): Promise<ProjectMembership[]> => {
const response = await axios.get<ProjectMembershipListResponse>( const response = await axios.get<ProjectMembershipListResponse>(
`project_memberships?projectId=${projectId}` `project_memberships?projectId=${projectId}`,
); );
return response.data.rows; return response.data.rows;
}, },
@ -50,8 +50,13 @@ export function useCreateProjectMembershipMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (data: Partial<ProjectMembership>): Promise<ProjectMembership> => { mutationFn: async (
const response = await axios.post<ProjectMembership>('project_memberships', { data }); data: Partial<ProjectMembership>,
): Promise<ProjectMembership> => {
const response = await axios.post<ProjectMembership>(
'project_memberships',
{ data },
);
return response.data; return response.data;
}, },
onSuccess: (_, variables) => { onSuccess: (_, variables) => {

View File

@ -74,10 +74,7 @@ export function useUpdateProjectMutation() {
}, },
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
// Update the cache with the new data // Update the cache with the new data
queryClient.setQueryData( queryClient.setQueryData(queryKeys.projects.detail(variables.id), data);
queryKeys.projects.detail(variables.id),
data,
);
// Invalidate list queries to refetch // Invalidate list queries to refetch
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
}, },

View File

@ -44,7 +44,7 @@ export function usePublishEventsQuery(projectId: string | undefined) {
queryKey: queryKeys.publishEvents.list(projectId || ''), queryKey: queryKeys.publishEvents.list(projectId || ''),
queryFn: async (): Promise<PublishEvent[]> => { queryFn: async (): Promise<PublishEvent[]> => {
const response = await axios.get<PublishEventListResponse>( const response = await axios.get<PublishEventListResponse>(
`publish_events?projectId=${projectId}&limit=50` `publish_events?projectId=${projectId}&limit=50`,
); );
return response.data.rows; return response.data.rows;
}, },

View File

@ -31,7 +31,7 @@ export function usePwaCachesQuery(projectId: string | undefined) {
queryKey: queryKeys.pwaCaches.list(projectId || ''), queryKey: queryKeys.pwaCaches.list(projectId || ''),
queryFn: async (): Promise<PwaCache[]> => { queryFn: async (): Promise<PwaCache[]> => {
const response = await axios.get<PwaCacheListResponse>( const response = await axios.get<PwaCacheListResponse>(
`pwa_caches?projectId=${projectId}` `pwa_caches?projectId=${projectId}`,
); );
return response.data.rows; return response.data.rows;
}, },

View File

@ -6,7 +6,10 @@
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ConstructorAsset as ProjectAsset, AssetOption } from '../types/constructor'; import type {
ConstructorAsset as ProjectAsset,
AssetOption,
} from '../types/constructor';
import { import {
buildAssetOptions, buildAssetOptions,
buildBackgroundImageOptions, buildBackgroundImageOptions,
@ -50,12 +53,11 @@ export interface UseAssetOptionsOptions {
* <AssetSelect options={image} value={selectedImage} onChange={setImage} /> * <AssetSelect options={image} value={selectedImage} onChange={setImage} />
* ``` * ```
*/ */
export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOptionsResult { export function useAssetOptions({
assets,
}: UseAssetOptionsOptions): AssetOptionsResult {
// All image assets // All image assets
const imageOptions = useMemo( const imageOptions = useMemo(() => buildImageAssetOptions(assets), [assets]);
() => buildImageAssetOptions(assets),
[assets],
);
// Background image assets (filtered by type or naming convention) // Background image assets (filtered by type or naming convention)
const backgroundImageOptions = useMemo( const backgroundImageOptions = useMemo(
@ -83,10 +85,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption
); );
// Audio assets // Audio assets
const audioOptions = useMemo( const audioOptions = useMemo(() => buildAudioAssetOptions(assets), [assets]);
() => buildAudioAssetOptions(assets),
[assets],
);
// Transition video assets // Transition video assets
const transitionVideoOptions = useMemo( const transitionVideoOptions = useMemo(
@ -95,10 +94,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption
); );
// Icon assets // Icon assets
const iconOptions = useMemo( const iconOptions = useMemo(() => buildIconAssetOptions(assets), [assets]);
() => buildIconAssetOptions(assets),
[assets],
);
return useMemo( return useMemo(
() => ({ () => ({

View File

@ -21,7 +21,9 @@ interface UseConstructorDataParams {
} }
// Stable empty references to prevent infinite loops from identity changes // Stable empty references to prevent infinite loops from identity changes
const EMPTY_ELEMENT_DEFAULTS: Partial<Record<CanvasElementType, Partial<CanvasElement>>> = {}; const EMPTY_ELEMENT_DEFAULTS: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
> = {};
const EMPTY_PAGES: TourPage[] = []; const EMPTY_PAGES: TourPage[] = [];
const EMPTY_ASSETS: Asset[] = []; const EMPTY_ASSETS: Asset[] = [];
@ -39,7 +41,9 @@ interface UseConstructorDataResult {
assets: Asset[]; assets: Asset[];
// Element Defaults // Element Defaults
uiElementDefaultsByType: Partial<Record<CanvasElementType, Partial<CanvasElement>>>; uiElementDefaultsByType: Partial<
Record<CanvasElementType, Partial<CanvasElement>>
>;
// Loading state // Loading state
isLoading: boolean; isLoading: boolean;
@ -64,19 +68,25 @@ export function useConstructorData({
const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev'); const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev');
// Fetch assets // Fetch assets
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, { limit: 500 }); const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, {
limit: 500,
});
// Fetch element defaults // Fetch element defaults
const elementDefaultsQuery = useElementDefaultsQuery(enabled ? projectId : undefined); const elementDefaultsQuery = useElementDefaultsQuery(
enabled ? projectId : undefined,
);
// Extract page links and preload elements from pages // Extract page links and preload elements from pages
const { pageLinks, allPagesPreloadElements } = useMemo(() => { const { pageLinks, allPagesPreloadElements } = useMemo(() => {
if (!pagesQuery.data || pagesQuery.data.length === 0) { 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( const { pageLinks: links, preloadElements: elements } =
pagesQuery.data as TourPage[], extractPageLinksAndElements(pagesQuery.data as TourPage[]);
);
return { pageLinks: links, allPagesPreloadElements: elements }; return { pageLinks: links, allPagesPreloadElements: elements };
}, [pagesQuery.data]); }, [pagesQuery.data]);
@ -124,7 +134,8 @@ export function useConstructorData({
assets: (assetsQuery.data as Asset[]) || EMPTY_ASSETS, assets: (assetsQuery.data as Asset[]) || EMPTY_ASSETS,
// Element Defaults // Element Defaults
uiElementDefaultsByType: elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS, uiElementDefaultsByType:
elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS,
// Loading state // Loading state
isLoading, isLoading,

View File

@ -180,7 +180,9 @@ export function useConstructorPageActions({
); );
await onReload(activePageId); await onReload(activePageId);
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (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.', 'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
); );
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
@ -283,7 +287,9 @@ export function useConstructorPageActions({
onSetMenuOpen?.(true); onSetMenuOpen?.(true);
onSuccess?.('New page created. You can now configure it in constructor.'); onSuccess?.('New page created. You can now configure it in constructor.');
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
@ -341,7 +347,9 @@ export function useConstructorPageActions({
'Transition video can be set directly on navigation elements.', 'Transition video can be set directly on navigation elements.',
); );
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||

View File

@ -319,7 +319,9 @@ export function useOfflineMode(
setStatus('downloaded'); setStatus('downloaded');
setProgress(100); setProgress(100);
await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); await OfflineDbManager.updateProjectStatus(projectId, 'downloaded');
logger.info('[useOfflineMode] All assets already cached', { projectId }); logger.info('[useOfflineMode] All assets already cached', {
projectId,
});
return; return;
} }

View File

@ -57,9 +57,7 @@ export interface UsePageBackgroundResult {
setAudioUrl: (url: string) => void; setAudioUrl: (url: string) => void;
/** Update video settings */ /** Update video settings */
setVideoSettings: ( setVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
settings: Partial<PageBackgroundVideoSettings>,
) => void;
/** Reset to default state */ /** Reset to default state */
reset: () => void; reset: () => void;

View File

@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 * Minimal page interface for navigation
@ -27,6 +34,11 @@ export interface UsePageNavigationResult<TPage extends NavigablePage> {
isBackNavigation: (targetPageId: string) => boolean; isBackNavigation: (targetPageId: string) => boolean;
goBack: () => boolean; goBack: () => boolean;
resetHistory: () => void; 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<TPage extends NavigablePage>(
const currentId = prev[prev.length - 1]; const currentId = prev[prev.length - 1];
if (currentId === targetPageId) return prev; 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 ( if (
isBack && isBack &&
prev.length > 1 && prev.length > 1 &&
@ -114,7 +126,9 @@ export function usePageNavigation<TPage extends NavigablePage>(
return prev.slice(0, -1); 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<TPage extends NavigablePage>(
setPageHistory(currentPageId ? [currentPageId] : []); setPageHistory(currentPageId ? [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 // Initialize to default page
useEffect(() => { useEffect(() => {
if (defaultPage?.id && !currentPageId) { if (defaultPage?.id && !currentPageId) {
@ -168,5 +190,6 @@ export function usePageNavigation<TPage extends NavigablePage>(
isBackNavigation, isBackNavigation,
goBack, goBack,
resetHistory, resetHistory,
getNavigationContext,
}; };
} }

View File

@ -22,12 +22,15 @@ export interface TransitionConfig {
durationSec?: number; durationSec?: number;
targetPageId?: string; targetPageId?: string;
displayName?: string; displayName?: string;
/** Whether this is a back navigation (for history management) */
isBack?: boolean;
} }
export interface UseTransitionPlaybackOptions { export interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null; 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; onError?: (reason: string) => void;
timeouts?: { timeouts?: {
@ -287,7 +290,10 @@ export function useTransitionPlayback(
} }
setPhase('completed'); setPhase('completed');
onCompleteRef.current(currentTransition?.targetPageId); onCompleteRef.current(
currentTransition?.targetPageId,
currentTransition?.isBack,
);
}, },
[clearTimers, videoRef], [clearTimers, videoRef],
); );

View File

@ -128,6 +128,7 @@ export function useTransitionPreview({
reverseStorageKey: element.reverseVideoUrl, reverseStorageKey: element.reverseVideoUrl,
durationSec: element.transitionDurationSec, durationSec: element.transitionDurationSec,
title: `${element.navLabel || element.label || 'Transition'} · ${direction}`, title: `${element.navLabel || element.label || 'Transition'} · ${direction}`,
isBack: direction === 'back', // Track for history management
}; };
setPreview(previewState); setPreview(previewState);

View File

@ -110,6 +110,7 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
navDisabled: false, navDisabled: false,
iconUrl: '', iconUrl: '',
transitionReverseMode: 'auto_reverse', transitionReverseMode: 'auto_reverse',
navBackMode: 'target_page',
}, },
tooltip: { tooltip: {
iconUrl: '', iconUrl: '',
@ -489,6 +490,7 @@ export const buildElementSettings = (
element.transitionReverseMode, element.transitionReverseMode,
); );
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl); addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
addIfNotEmpty(settings, 'navBackMode', element.navBackMode);
} }
// Tooltip type settings // Tooltip type settings

View File

@ -11,19 +11,118 @@ import type {
NavigationTarget, NavigationTarget,
TransitionPhase, TransitionPhase,
} from '../types/presentation'; } 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<Record<string, unknown>>;
}
/**
* 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<string, unknown>;
slug?: string;
},
targetPageSlug: string,
): NavigableElement | null => {
// Parse ui_schema_json using shared utility
const uiSchema = parseJsonObject<UiSchemaStructure>(
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. * Resolve target page from element navigation properties.
* Supports both targetPageSlug (new) and targetPageId (legacy). * Supports both targetPageSlug (new) and targetPageId (legacy).
* Also supports history-based back navigation when navBackMode='history'.
* *
* @param element - Element with navigation properties * @param element - Element with navigation properties
* @param pages - Available pages to search * @param pages - Available pages to search
* @param context - Optional context for history-based navigation
* @returns The target page or undefined if not found * @returns The target page or undefined if not found
*/ */
export const resolveNavigationTarget = ( export const resolveNavigationTarget = (
element: NavigableElement, element: NavigableElement,
pages: RuntimePage[], pages: RuntimePage[],
context?: NavigationContext,
): NavigationTarget | null => { ): 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 targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId; const legacyTargetPageId = element.targetPageId;
@ -45,6 +144,8 @@ export const resolveNavigationTarget = (
page: targetPage, page: targetPage,
pageId: targetPage.id, pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl, transitionVideoUrl: element.transitionVideoUrl,
transitionReverseMode: element.transitionReverseMode,
reverseVideoUrl: element.reverseVideoUrl,
isBack, isBack,
}; };
}; };

View File

@ -212,59 +212,65 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
{getLayout( {getLayout(
<> <>
<Head> <Head>
<meta name='description' content={description} /> <meta name='description' content={description} />
<meta property='og:url' content={url} /> <meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' /> <meta
<meta key='og:title' property='og:title' content={title} /> property='og:site_name'
<meta content='https://flatlogic.com/'
key='og:description' />
property='og:description' <meta key='og:title' property='og:title' content={title} />
content={description} <meta
/> key='og:description'
<meta key='og:image' property='og:image' content={image} /> property='og:description'
<meta property='og:image:type' content='image/png' /> content={description}
<meta property='og:image:width' content={imageWidth} /> />
<meta property='og:image:height' content={imageHeight} /> <meta key='og:image' property='og:image' content={image} />
<meta property='twitter:card' content='summary_large_image' /> <meta property='og:image:type' content='image/png' />
<meta <meta property='og:image:width' content={imageWidth} />
key='twitter:title' <meta property='og:image:height' content={imageHeight} />
property='twitter:title' <meta property='twitter:card' content='summary_large_image' />
content={title} <meta
/> key='twitter:title'
<meta property='twitter:title'
key='twitter:description' content={title}
property='twitter:description' />
content={description} <meta
/> key='twitter:description'
<meta property='twitter:description'
key='twitter:image:src' content={description}
property='twitter:image:src' />
content={image} <meta
/> key='twitter:image:src'
<meta property='twitter:image:width' content={imageWidth} /> property='twitter:image:src'
<meta property='twitter:image:height' content={imageHeight} /> content={image}
<link key='favicon' rel='icon' href='/favicon.svg' /> />
<link rel='manifest' href='/manifest.json' /> <meta property='twitter:image:width' content={imageWidth} />
<meta name='theme-color' content='#3B82F6' /> <meta property='twitter:image:height' content={imageHeight} />
<meta name='mobile-web-app-capable' content='yes' /> <link key='favicon' rel='icon' href='/favicon.svg' />
<meta <link rel='manifest' href='/manifest.json' />
name='apple-mobile-web-app-status-bar-style' <meta name='theme-color' content='#3B82F6' />
content='default' <meta name='mobile-web-app-capable' content='yes' />
/> <meta
<meta name='apple-mobile-web-app-title' content='Tour Builder' /> name='apple-mobile-web-app-status-bar-style'
</Head> content='default'
/>
<meta
name='apple-mobile-web-app-title'
content='Tour Builder'
/>
</Head>
<ErrorBoundary> <ErrorBoundary>
<Component {...pageProps} /> <Component {...pageProps} />
</ErrorBoundary> </ErrorBoundary>
<IntroGuide <IntroGuide
steps={steps} steps={steps}
stepsName={stepName} stepsName={stepName}
stepsEnabled={stepsEnabled} stepsEnabled={stepsEnabled}
onExit={handleExit} onExit={handleExit}
/> />
</>, </>,
)} )}
</DownloadProvider> </DownloadProvider>
</Provider> </Provider>
</QueryClientProvider> </QueryClientProvider>

View File

@ -67,9 +67,7 @@ const EditAccess_logs = () => {
useEffect(() => { useEffect(() => {
// access_logs is now always an array; get the first element for single entity view // access_logs is now always an array; get the first element for single entity view
const entity = Array.isArray(access_logs) const entity = Array.isArray(access_logs) ? access_logs[0] : access_logs;
? access_logs[0]
: access_logs;
if (entity && typeof entity === 'object') { if (entity && typeof entity === 'object') {
const newInitialVal = { ...initVals }; const newInitialVal = { ...initVals };

View File

@ -22,6 +22,7 @@ import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageSwitch } from '../hooks/usePageSwitch';
import { usePageNavigation } from '../hooks/usePageNavigation';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
@ -94,7 +95,6 @@ import {
// 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'
type ConstructorPageProps = { type ConstructorPageProps = {
mode?: 'constructor' | 'element_edit'; mode?: 'constructor' | 'element_edit';
}; };
@ -151,7 +151,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isAuthReady, 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) // Consolidated page background state (replaces 8 separate useState hooks)
const { const {
@ -341,8 +351,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Helper to switch pages without flash // Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache // Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes // 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( const switchToPage = useCallback(
async (page: TourPage | null) => { async (page: TourPage | null, isBack = false) => {
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
if (page) { if (page) {
lastInitializedPageIdRef.current = page.id; lastInitializedPageIdRef.current = page.id;
@ -363,12 +374,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: null, : null,
() => { () => {
if (page) { 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({ const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@ -384,14 +396,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
durationSec: transitionPreview.durationSec, durationSec: transitionPreview.durationSec,
targetPageId: pendingNavigationPageId || undefined, targetPageId: pendingNavigationPageId || undefined,
displayName: transitionPreview.title, displayName: transitionPreview.title,
isBack: transitionPreview.isBack, // Pass through for history management
} }
: null, : null,
onComplete: async (targetPageId) => { onComplete: async (targetPageId, isBack) => {
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId) || null; const targetPage = pages.find((p) => p.id === targetPageId) || null;
// Use switchToPage which resolves blob URLs via usePageSwitch // 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(); clearSelection();
setSelectedMenuItem('none'); setSelectedMenuItem('none');
setErrorMessage(''); setErrorMessage('');
@ -475,9 +489,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
], ],
); );
const { getDuration, getDurationNote, durationBySource } = useMediaDurationProbe({ const { getDuration, getDurationNote, durationBySource } =
targets: durationProbeTargets, useMediaDurationProbe({
}); targets: durationProbeTargets,
});
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl); const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl); 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) // Only set initial page when pages first load (not on every change)
if (pages.length > 0 && prevPagesLengthRef.current === 0) { if (pages.length > 0 && prevPagesLengthRef.current === 0) {
const defaultPageId = pageIdFromRoute || pages[0]?.id || ''; const defaultPageId = pageIdFromRoute || pages[0]?.id || '';
setActivePageId(defaultPageId); // Use applyPageSelection to set initial page and history
applyPageSelection(defaultPageId, false);
setIsMenuOpen(false); setIsMenuOpen(false);
setIsInitializing(false); setIsInitializing(false);
} }
prevPagesLengthRef.current = pages.length; prevPagesLengthRef.current = pages.length;
}, [pages, pageIdFromRoute]); }, [pages, pageIdFromRoute, applyPageSelection]);
// Handle query errors // Handle query errors
useEffect(() => { useEffect(() => {
if (isDataError && dataError) { if (isDataError && dataError) {
const message = dataError.message || 'Failed to load constructor data.'; const message = dataError.message || 'Failed to load constructor data.';
logger.error( logger.error('Failed to load constructor data:', dataError);
'Failed to load constructor data:',
dataError,
);
setErrorMessage(message); setErrorMessage(message);
} }
}, [isDataError, dataError]); }, [isDataError, dataError]);
@ -677,11 +690,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? item.galleryTitle ? item.galleryTitle
: undefined, : undefined,
galleryInfoSpans: Array.isArray(item.galleryInfoSpans) galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
? item.galleryInfoSpans.map((span: Partial<GalleryInfoSpan>) => ({ ? item.galleryInfoSpans.map(
id: String(span?.id || createLocalId()), (span: Partial<GalleryInfoSpan>) => ({
text: String(span?.text ?? ''), id: String(span?.id || createLocalId()),
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined, text: String(span?.text ?? ''),
})) iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
}),
)
: undefined, : undefined,
galleryColumns: galleryColumns:
typeof item.galleryColumns === 'number' typeof item.galleryColumns === 'number'
@ -994,20 +1009,51 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Use shared navigation helpers // Use shared navigation helpers
const direction = getNavigationDirection(element); 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) { if (!navTarget) {
setErrorMessage( // History mode back buttons need navigation history
'No target page configured for this navigation button.', 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; 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 // Check if transition can be played using shared helper
if (!hasPlayableTransition(element, direction)) { if (!hasPlayableTransition(transitionSource, direction)) {
closeTransitionPreview(); closeTransitionPreview();
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash) // 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(); clearSelection();
setSelectedMenuItem('none'); setSelectedMenuItem('none');
setErrorMessage(''); setErrorMessage('');
@ -1015,7 +1061,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return; return;
} }
openPreviewWithTarget(element, direction, navTarget.pageId); openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
} }
return; return;
} }

View File

@ -42,7 +42,9 @@ const ElementTypeDefaultsPage = () => {
: []; : [];
setRows(nextRows); setRows(nextRows);
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||

View File

@ -50,7 +50,10 @@ const ProjectElementDefaultsPage = () => {
const response = await axios.get(`/projects/${projectId}`); const response = await axios.get(`/projects/${projectId}`);
setProject(response?.data || null); setProject(response?.data || null);
} catch (error: unknown) { } 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]); }, [projectId]);
@ -71,7 +74,9 @@ const ProjectElementDefaultsPage = () => {
: []; : [];
setRows(nextRows); setRows(nextRows);
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
const message = const message =
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||

View File

@ -59,7 +59,9 @@ const ProjectWorkspacePage = () => {
const response = await axios.get(`/projects/${projectId}`); const response = await axios.get(`/projects/${projectId}`);
setProject(response?.data || null); setProject(response?.data || null);
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
setErrorMessage( setErrorMessage(
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
@ -123,7 +125,9 @@ const ProjectWorkspacePage = () => {
); );
const axiosError = error as { response?: { data?: string } }; const axiosError = error as { response?: { data?: string } };
const message = 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', { toast(typeof message === 'string' ? message : 'Publish failed', {
type: 'error', type: 'error',
position: 'bottom-center', position: 'bottom-center',

View File

@ -69,7 +69,9 @@ const PublishEventsHistoryPage = () => {
'Failed to load publish history:', 'Failed to load publish history:',
error instanceof Error ? error : { error }, error instanceof Error ? error : { error },
); );
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as {
response?: { data?: { message?: string } };
};
setErrorMessage( setErrorMessage(
axiosError?.response?.data?.message || axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||

View File

@ -72,8 +72,7 @@ const constructorSlice = createSlice({
state, state,
action: PayloadAction<{ projectId: string; pageId: string }>, action: PayloadAction<{ projectId: string; pageId: string }>,
) => { ) => {
state.lastActivePageId[action.payload.projectId] = state.lastActivePageId[action.payload.projectId] = action.payload.pageId;
action.payload.pageId;
}, },
/** /**

View File

@ -14,10 +14,7 @@ import {
rejectNotify, rejectNotify,
resetNotify, resetNotify,
} from '../helpers/notifyStateHandler'; } from '../helpers/notifyStateHandler';
import type { import type { EntitySliceConfig, EntitySliceState } from '../types/redux';
EntitySliceConfig,
EntitySliceState,
} from '../types/redux';
import type { PaginatedResponse, FetchParams, ApiError } from '../types/api'; import type { PaginatedResponse, FetchParams, ApiError } from '../types/api';
import type { BaseEntity } from '../types/entities'; import type { BaseEntity } from '../types/entities';

View File

@ -34,20 +34,23 @@ const fulfilledNotify = (
state.notify.showNotification = true; state.notify.showNotification = true;
}; };
export const aiPrompt = createAsyncThunk<CreateWidgetResponse, CreateWidgetRequest>( export const aiPrompt = createAsyncThunk<
'openai/aiPrompt', CreateWidgetResponse,
async (data, { rejectWithValue }) => { CreateWidgetRequest
try { >('openai/aiPrompt', async (data, { rejectWithValue }) => {
const response = await axios.post<CreateWidgetResponse>('/openai/create_widget', data); try {
return response.data; const response = await axios.post<CreateWidgetResponse>(
} catch (error) { '/openai/create_widget',
if (!error.response) { data,
throw error; );
} return response.data;
return rejectWithValue(error.response.data); } catch (error) {
if (!error.response) {
throw error;
} }
}, return rejectWithValue(error.response.data);
); }
});
export const askGpt = createAsyncThunk( export const askGpt = createAsyncThunk(
'openai/askGpt', 'openai/askGpt',

View File

@ -52,13 +52,18 @@ export const fetchWidgets = createAsyncThunk(
); );
// Initial state with widgets // Initial state with widgets
const initialWidgetsState: { rolesWidgets: Array<{ id: string; [key: string]: unknown }> } = { const initialWidgetsState: {
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
} = {
rolesWidgets: [], rolesWidgets: [],
}; };
// Combined reducer that extends base reducer with widget handling // Combined reducer that extends base reducer with widget handling
const combinedReducer = createReducer( const combinedReducer = createReducer(
{ ...baseReducer(undefined, { type: '' }), ...initialWidgetsState } as RolesSliceState, {
...baseReducer(undefined, { type: '' }),
...initialWidgetsState,
} as RolesSliceState,
(builder) => { (builder) => {
// Handle fetchWidgets.fulfilled // Handle fetchWidgets.fulfilled
builder.addCase(fetchWidgets.fulfilled, (state, action) => { builder.addCase(fetchWidgets.fulfilled, (state, action) => {

View File

@ -226,6 +226,8 @@ export interface CanvasElement extends BaseCanvasElement {
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video'; transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string; reverseVideoUrl?: string;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
transitionDurationSec?: number; transitionDurationSec?: number;
// Gallery Carousel Settings // Gallery Carousel Settings
galleryCarouselPrevIconUrl?: string; galleryCarouselPrevIconUrl?: string;
@ -571,16 +573,18 @@ export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
/** /**
* Create page background state from tour page data * Create page background state from tour page data
*/ */
export function createPageBackgroundFromPage(page: { export function createPageBackgroundFromPage(
background_image_url?: string; page: {
background_video_url?: string; background_image_url?: string;
background_audio_url?: string; background_video_url?: string;
background_video_autoplay?: boolean; background_audio_url?: string;
background_video_loop?: boolean; background_video_autoplay?: boolean;
background_video_muted?: boolean; background_video_loop?: boolean;
background_video_start_time?: number | null; background_video_muted?: boolean;
background_video_end_time?: number | null; background_video_start_time?: number | null;
} | null): PageBackgroundState { background_video_end_time?: number | null;
} | null,
): PageBackgroundState {
if (!page) { if (!page) {
return { ...DEFAULT_PAGE_BACKGROUND }; return { ...DEFAULT_PAGE_BACKGROUND };
} }

View File

@ -25,6 +25,8 @@ export interface TransitionPreviewState {
durationSec?: number; durationSec?: number;
/** Display title for the preview */ /** Display title for the preview */
title: string; title: string;
/** Whether this is a back navigation (for history management) */
isBack?: boolean;
} }
/** /**
@ -43,6 +45,8 @@ export interface NavigationTarget {
page: RuntimePage; page: RuntimePage;
pageId: string; pageId: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
isBack: boolean; isBack: boolean;
} }
@ -76,8 +80,12 @@ export interface NavigableElement {
targetPageSlug?: string; targetPageSlug?: string;
targetPageId?: string; targetPageId?: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
navType?: 'forward' | 'back'; navType?: 'forward' | 'back';
navDisabled?: boolean; navDisabled?: boolean;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
} }
/** /**

View File

@ -20,7 +20,6 @@ export interface EntitySliceState<T> {
[entityName: string]: T[] | boolean | number | NotificationState | unknown; [entityName: string]: T[] | boolean | number | NotificationState | unknown;
} }
// Slice factory configuration // Slice factory configuration
export interface EntitySliceConfig { export interface EntitySliceConfig {
name: string; name: string;