added go back navigation with two modes (target page and previous route)
This commit is contained in:
parent
aac20d29a3
commit
f6d0aeafd7
@ -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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 }[];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 }[];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 }[];
|
||||||
|
|||||||
@ -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 }[];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) ||
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) ||
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) ||
|
||||||
|
|||||||
@ -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) ||
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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) ||
|
||||||
|
|||||||
@ -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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user