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,
|
||||
} from '@mdi/js';
|
||||
import MenuActionButton from './MenuActionButton';
|
||||
import type { Position, CanvasElementType, NavigationElementType } from './types';
|
||||
import type {
|
||||
Position,
|
||||
CanvasElementType,
|
||||
NavigationElementType,
|
||||
} from './types';
|
||||
import type { EditorMenuItem } from '../../types/constructor';
|
||||
|
||||
interface ConstructorMenuProps {
|
||||
@ -65,108 +69,108 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
|
||||
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<span className='text-xs font-bold uppercase'>Constructor Menu</span>
|
||||
<button type='button' onClick={onToggleOpen}>
|
||||
<BaseIcon path={mdiMenu} size={18} />
|
||||
</button>
|
||||
</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'
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
<span className='text-xs font-bold uppercase'>Constructor Menu</span>
|
||||
<button type='button' onClick={onToggleOpen}>
|
||||
<BaseIcon path={mdiMenu} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Background Image'
|
||||
onClick={() => onSelectMenuItem('background_image')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Background Video'
|
||||
onClick={() => onSelectMenuItem('background_video')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Background Audio'
|
||||
onClick={() => onSelectMenuItem('background_audio')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Add Navigation Button'
|
||||
onClick={() => onAddElement(allowedNavigationTypes[0])}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Add Transition'
|
||||
onClick={() => onSelectMenuItem('create_transition')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Add Gallery'
|
||||
onClick={() => onAddElement('gallery')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Add Carousel'
|
||||
onClick={() => onAddElement('carousel')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Add Tooltip'
|
||||
onClick={() => onAddElement('tooltip')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiText}
|
||||
label='Add Description'
|
||||
onClick={() => onAddElement('description')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Add Video Player'
|
||||
onClick={() => onAddElement('video_player')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Add Audio Player'
|
||||
onClick={() => onAddElement('audio_player')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiPlus}
|
||||
label={isCreatingPage ? 'Creating Page...' : 'Create New Page'}
|
||||
onClick={onCreatePage}
|
||||
disabled={isCreatingPage}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Background Image'
|
||||
onClick={() => onSelectMenuItem('background_image')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Background Video'
|
||||
onClick={() => onSelectMenuItem('background_video')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Background Audio'
|
||||
onClick={() => onSelectMenuItem('background_audio')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Add Navigation Button'
|
||||
onClick={() => onAddElement(allowedNavigationTypes[0])}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiSwapHorizontal}
|
||||
label='Add Transition'
|
||||
onClick={() => onSelectMenuItem('create_transition')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiImageMultiple}
|
||||
label='Add Gallery'
|
||||
onClick={() => onAddElement('gallery')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Add Carousel'
|
||||
onClick={() => onAddElement('carousel')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Add Tooltip'
|
||||
onClick={() => onAddElement('tooltip')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiText}
|
||||
label='Add Description'
|
||||
onClick={() => onAddElement('description')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiViewCarousel}
|
||||
label='Add Video Player'
|
||||
onClick={() => onAddElement('video_player')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiTooltipText}
|
||||
label='Add Audio Player'
|
||||
onClick={() => onAddElement('audio_player')}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiPlus}
|
||||
label={isCreatingPage ? 'Creating Page...' : 'Create New Page'}
|
||||
onClick={onCreatePage}
|
||||
disabled={isCreatingPage}
|
||||
/>
|
||||
|
||||
<div className='pt-2 border-t border-gray-200 space-y-1'>
|
||||
<div className='flex gap-1'>
|
||||
<BaseButton
|
||||
small
|
||||
color='info'
|
||||
label={isSaving ? 'Saving...' : 'Save'}
|
||||
icon={mdiContentSave}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<BaseButton
|
||||
small
|
||||
color='success'
|
||||
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
|
||||
onClick={onSaveToStage}
|
||||
disabled={isSavingToStage}
|
||||
<div className='pt-2 border-t border-gray-200 space-y-1'>
|
||||
<div className='flex gap-1'>
|
||||
<BaseButton
|
||||
small
|
||||
color='info'
|
||||
label={isSaving ? 'Saving...' : 'Save'}
|
||||
icon={mdiContentSave}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<BaseButton
|
||||
small
|
||||
color='success'
|
||||
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
|
||||
onClick={onSaveToStage}
|
||||
disabled={isSavingToStage}
|
||||
/>
|
||||
</div>
|
||||
<MenuActionButton
|
||||
icon={mdiExitToApp}
|
||||
label='Exit'
|
||||
onClick={onExit}
|
||||
className='!text-red-700'
|
||||
/>
|
||||
</div>
|
||||
<MenuActionButton
|
||||
icon={mdiExitToApp}
|
||||
label='Exit'
|
||||
onClick={onExit}
|
||||
className='!text-red-700'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -322,6 +322,7 @@ export function ElementEditorPanel({
|
||||
selectedElement.transitionReverseMode || 'auto_reverse'
|
||||
}
|
||||
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
||||
navBackMode={selectedElement.navBackMode}
|
||||
allowedNavigationTypes={allowedNavigationTypes}
|
||||
iconAssetOptions={assetOptions.icon}
|
||||
transitionVideoOptions={assetOptions.transitionVideo}
|
||||
@ -718,7 +719,11 @@ export function ElementEditorPanel({
|
||||
color: selectedElement.color || '',
|
||||
}}
|
||||
onChange={(prop, value) =>
|
||||
handleCssPropertyChange(prop, value, updateSelectedElement)
|
||||
handleCssPropertyChange(
|
||||
prop,
|
||||
value,
|
||||
updateSelectedElement,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -31,6 +31,7 @@ interface NavigationSettingsSectionCompactProps {
|
||||
transitionVideoUrl: string;
|
||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl: string;
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
allowedNavigationTypes: NavigationElementType[];
|
||||
iconAssetOptions: AssetOption[];
|
||||
transitionVideoOptions: AssetOption[];
|
||||
@ -67,6 +68,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
transitionVideoUrl,
|
||||
transitionReverseMode,
|
||||
reverseVideoUrl,
|
||||
navBackMode,
|
||||
allowedNavigationTypes,
|
||||
iconAssetOptions,
|
||||
transitionVideoOptions,
|
||||
@ -184,126 +186,156 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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' && (
|
||||
{/* Back Navigation Mode - only shown for back buttons */}
|
||||
{currentKind === 'back' && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Back transition video asset
|
||||
Back Navigation Mode
|
||||
</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)
|
||||
}
|
||||
value={navBackMode || 'target_page'}
|
||||
onChange={(e) => onChange('navBackMode', e.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>
|
||||
))}
|
||||
<option value='target_page'>Fixed target page</option>
|
||||
<option value='history'>Previous page (browser-like)</option>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<p className='text-[11px] text-gray-500'>
|
||||
Transition duration is set automatically from the selected video.
|
||||
</p>
|
||||
{/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */}
|
||||
{(currentKind === 'forward' || navBackMode !== 'history') && (
|
||||
<>
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -18,6 +18,7 @@ import BaseButton from '../BaseButton';
|
||||
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||||
import type { ProjectOfflineStatus } from '../../types/offline';
|
||||
import { logger } from '../../lib/logger';
|
||||
|
||||
interface OfflineToggleProps {
|
||||
projectId: string | null;
|
||||
@ -62,13 +63,13 @@ export function OfflineToggle({
|
||||
|
||||
// Show toast notification when download completes
|
||||
useEffect(() => {
|
||||
console.log('[OfflineToggle] Status changed:', {
|
||||
logger.debug('[OfflineToggle] Status changed:', {
|
||||
prev: prevStatusRef.current,
|
||||
current: status,
|
||||
});
|
||||
// Only show toast when transitioning FROM downloading TO downloaded
|
||||
if (prevStatusRef.current === 'downloading' && status === 'downloaded') {
|
||||
console.log('[OfflineToggle] Showing toast - download complete!');
|
||||
logger.debug('[OfflineToggle] Showing toast - download complete!');
|
||||
toast.success('Presentation ready for offline mode!', {
|
||||
position: 'bottom-center',
|
||||
autoClose: 5000,
|
||||
|
||||
@ -28,6 +28,7 @@ import LayoutGuest from '../layouts/Guest';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||
import { useProjectAssets } from '../hooks/useProjectAssets';
|
||||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
@ -66,8 +67,18 @@ export default function RuntimePresentation({
|
||||
// Resolve project assets (favicon, og_image, logo) to presigned URLs
|
||||
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
||||
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
// Page navigation with history tracking via shared hook
|
||||
const {
|
||||
currentPageId: selectedPageId,
|
||||
pageHistory,
|
||||
applyPageSelection,
|
||||
getNavigationContext,
|
||||
} = usePageNavigation({
|
||||
pages,
|
||||
defaultPageId: initialPageId || undefined,
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
const [transitionPreview, setTransitionPreview] = useState<{
|
||||
targetPageId: string;
|
||||
videoUrl: string;
|
||||
@ -86,13 +97,7 @@ export default function RuntimePresentation({
|
||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||
|
||||
// Set initial page when data loads
|
||||
useEffect(() => {
|
||||
if (initialPageId && !selectedPageId) {
|
||||
setSelectedPageId(initialPageId);
|
||||
setPageHistory([initialPageId]);
|
||||
}
|
||||
}, [initialPageId, selectedPageId]);
|
||||
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
|
||||
|
||||
// Extract page links and preload elements from ui_schema_json
|
||||
// This enables the neighbor graph to find connected pages for preloading
|
||||
@ -143,17 +148,18 @@ export default function RuntimePresentation({
|
||||
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
|
||||
targetPageId: transitionPreview.targetPageId,
|
||||
displayName: 'Transition',
|
||||
isBack: transitionPreview.isReverse, // Pass through for history management
|
||||
}
|
||||
: null,
|
||||
onComplete: async (targetPageId) => {
|
||||
onComplete: async (targetPageId, isBack) => {
|
||||
if (targetPageId) {
|
||||
const targetPage = pages.find((p) => p.id === targetPageId);
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
lastInitializedPageIdRef.current = targetPageId;
|
||||
// Use shared hook to resolve blob URLs and switch page
|
||||
await pageSwitch.switchToPage(targetPage, () => {
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => [...prev, targetPageId]);
|
||||
// Use applyPageSelection for proper history management (pops on back)
|
||||
applyPageSelection(targetPageId, isBack ?? false);
|
||||
});
|
||||
setIsBackgroundReady(false);
|
||||
// Signal that transition is complete and waiting for Image onLoad
|
||||
@ -300,12 +306,12 @@ export default function RuntimePresentation({
|
||||
lastInitializedPageIdRef.current = targetPageId;
|
||||
|
||||
await pageSwitch.switchToPage(targetPage, () => {
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => [...prev, targetPageId]);
|
||||
// Use applyPageSelection for proper history management (pops on back)
|
||||
applyPageSelection(targetPageId, isBack);
|
||||
});
|
||||
}
|
||||
},
|
||||
[pages, pageSwitch, resetFadeOut],
|
||||
[pages, pageSwitch, resetFadeOut, applyPageSelection],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
@ -317,8 +323,11 @@ export default function RuntimePresentation({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use shared helper to resolve navigation target
|
||||
const navTarget = resolveNavigationTarget(element, pages);
|
||||
// Get navigation context from hook for history-based back navigation
|
||||
const navContext = getNavigationContext();
|
||||
|
||||
// Use shared helper to resolve navigation target with history context
|
||||
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
||||
|
||||
// Debug: log element navigation data
|
||||
logger.info('Element clicked', {
|
||||
@ -328,6 +337,8 @@ export default function RuntimePresentation({
|
||||
resolvedTargetPageId: navTarget?.pageId,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
hasTransition: Boolean(element.transitionVideoUrl),
|
||||
navBackMode: element.navBackMode,
|
||||
previousPageId: navContext.previousPageId,
|
||||
});
|
||||
|
||||
if (navTarget) {
|
||||
@ -338,7 +349,7 @@ export default function RuntimePresentation({
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigateToPage, pages, transitionPhase, isBuffering],
|
||||
[navigateToPage, pages, transitionPhase, isBuffering, getNavigationContext],
|
||||
);
|
||||
|
||||
// Handler for gallery card clicks
|
||||
|
||||
@ -31,7 +31,10 @@ export const SelectField = ({
|
||||
setValue(option);
|
||||
};
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
|
||||
async function callApi(
|
||||
inputValue: string,
|
||||
loadedOptions: Array<{ value: string; label: string }>,
|
||||
) {
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
|
||||
@ -46,7 +46,10 @@ export const SelectFieldMany = ({
|
||||
);
|
||||
};
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
|
||||
async function callApi(
|
||||
inputValue: string,
|
||||
loadedOptions: Array<{ value: string; label: string }>,
|
||||
) {
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { humanize } from '../../../../helpers/humanize';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||
type ValueType = { [key: string]: string | number }[];
|
||||
|
||||
@ -3,7 +3,10 @@ import { Line } from 'react-chartjs-2';
|
||||
import chroma from 'chroma-js';
|
||||
import { humanize } from '../../../../helpers/humanize';
|
||||
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { humanize } from '../../../../helpers/humanize';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||
type ValueType = { [key: string]: string | number }[];
|
||||
|
||||
@ -14,7 +14,10 @@ import {
|
||||
} from 'chart.js';
|
||||
import chroma from 'chroma-js';
|
||||
import { logger } from '../../../../lib/logger';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { humanize } from '../../../helpers/humanize';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../types/charts';
|
||||
|
||||
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||
type ValueType = { [key: string]: string | number }[];
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { humanize } from '../../../../helpers/humanize';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||
type ValueType = { [key: string]: string | number }[];
|
||||
|
||||
@ -13,7 +13,10 @@ import {
|
||||
Tooltip,
|
||||
ChartData,
|
||||
} from 'chart.js';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
Chart.register(
|
||||
LineElement,
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import chroma from 'chroma-js';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||
type ValueType = { [key: string]: string | number }[];
|
||||
|
||||
export const ApexPieChart = ({ widget }: ChartComponentProps) => {
|
||||
const optionsForPieChart = (value: ValueType, chartColor?: string | string[]) => {
|
||||
const optionsForPieChart = (
|
||||
value: ValueType,
|
||||
chartColor?: string | string[],
|
||||
) => {
|
||||
const chartColors = Array.isArray(chartColor)
|
||||
? chartColor
|
||||
: [chartColor || '#3751FF'];
|
||||
|
||||
@ -3,7 +3,10 @@ import { humanize } from '../../../../helpers/humanize';
|
||||
import { Pie } from 'react-chartjs-2';
|
||||
import chroma from 'chroma-js';
|
||||
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
|
||||
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
|
||||
import type {
|
||||
ChartComponentProps,
|
||||
ChartValueArray,
|
||||
} from '../../../../types/charts';
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
|
||||
@ -140,7 +140,9 @@ const TourFlowManager = () => {
|
||||
setPages(getRows(pagesResponse));
|
||||
setTransitions([]);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
setErrorMessage(
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -366,7 +368,9 @@ const TourFlowManager = () => {
|
||||
setNewPageSlug('');
|
||||
await loadData();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -403,7 +407,9 @@ const TourFlowManager = () => {
|
||||
setPages((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
setErrorMessage(
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
|
||||
@ -37,7 +37,10 @@ export const RoleSelect = ({
|
||||
setValue(option);
|
||||
};
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
|
||||
async function callApi(
|
||||
inputValue: string,
|
||||
loadedOptions: Array<{ value: string; label: string }>,
|
||||
) {
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
|
||||
@ -62,8 +62,13 @@ export const WidgetCreator = ({
|
||||
userId: currentUser?.id,
|
||||
};
|
||||
const result = await dispatch(aiPrompt(payload));
|
||||
const responcePayload = result.payload as { data?: { error?: { message?: string } } } | undefined;
|
||||
const error = 'error' in result ? result.error as { message?: string } | undefined : undefined;
|
||||
const responcePayload = result.payload as
|
||||
| { data?: { error?: { message?: string } } }
|
||||
| undefined;
|
||||
const error =
|
||||
'error' in result
|
||||
? (result.error as { message?: string } | undefined)
|
||||
: undefined;
|
||||
|
||||
await getWidgets().then();
|
||||
|
||||
|
||||
@ -102,7 +102,9 @@ export interface ConstructorContextValue {
|
||||
setBackgroundImageUrl: (url: string) => void;
|
||||
setBackgroundVideoUrl: (url: string) => void;
|
||||
setBackgroundAudioUrl: (url: string) => void;
|
||||
setBackgroundVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
|
||||
setBackgroundVideoSettings: (
|
||||
settings: Partial<PageBackgroundVideoSettings>,
|
||||
) => void;
|
||||
|
||||
// Element state
|
||||
elements: CanvasElement[];
|
||||
|
||||
@ -43,7 +43,10 @@ export type {
|
||||
} from './usePageBackground';
|
||||
export { useConstructorData } from './useConstructorData';
|
||||
export { useAssetOptions } from './useAssetOptions';
|
||||
export type { AssetOptionsResult, UseAssetOptionsOptions } from './useAssetOptions';
|
||||
export type {
|
||||
AssetOptionsResult,
|
||||
UseAssetOptionsOptions,
|
||||
} from './useAssetOptions';
|
||||
export { useTransitionCreation } from './useTransitionCreation';
|
||||
export type {
|
||||
TransitionCreationState,
|
||||
|
||||
@ -4,16 +4,56 @@
|
||||
* Centralized exports for React Query hooks.
|
||||
*/
|
||||
|
||||
export { useProjectsQuery, useProjectQuery, useUpdateProjectMutation } from './useProjectQuery';
|
||||
export { usePagesQuery, usePageQuery, useUpdatePageMutation, useCreatePageMutation, useDeletePageMutation } from './usePagesQuery';
|
||||
export { useAssetsQuery, useAssetQuery, useUpdateAssetMutation, useDeleteAssetMutation } from './useAssetsQuery';
|
||||
export {
|
||||
useProjectsQuery,
|
||||
useProjectQuery,
|
||||
useUpdateProjectMutation,
|
||||
} from './useProjectQuery';
|
||||
export {
|
||||
usePagesQuery,
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
useCreatePageMutation,
|
||||
useDeletePageMutation,
|
||||
} from './usePagesQuery';
|
||||
export {
|
||||
useAssetsQuery,
|
||||
useAssetQuery,
|
||||
useUpdateAssetMutation,
|
||||
useDeleteAssetMutation,
|
||||
} from './useAssetsQuery';
|
||||
export { useElementDefaultsQuery } from './useElementDefaultsQuery';
|
||||
export { useUsersQuery, useCurrentUserQuery, useUserQuery, useUpdateUserMutation, useCreateUserMutation, useDeleteUserMutation } from './useUsersQuery';
|
||||
export { useRolesQuery, useRoleQuery, useUpdateRoleMutation, useCreateRoleMutation, useDeleteRoleMutation } from './useRolesQuery';
|
||||
export {
|
||||
useUsersQuery,
|
||||
useCurrentUserQuery,
|
||||
useUserQuery,
|
||||
useUpdateUserMutation,
|
||||
useCreateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
} from './useUsersQuery';
|
||||
export {
|
||||
useRolesQuery,
|
||||
useRoleQuery,
|
||||
useUpdateRoleMutation,
|
||||
useCreateRoleMutation,
|
||||
useDeleteRoleMutation,
|
||||
} from './useRolesQuery';
|
||||
export { usePermissionsQuery } from './usePermissionsQuery';
|
||||
export { useAccessLogsQuery } from './useAccessLogsQuery';
|
||||
export { useAssetVariantsQuery } from './useAssetVariantsQuery';
|
||||
export { useProjectMembershipsQuery, useCreateProjectMembershipMutation, useDeleteProjectMembershipMutation } from './useProjectMembershipsQuery';
|
||||
export { useProjectAudioTracksQuery, useProjectAudioTrackQuery, useCreateProjectAudioTrackMutation, useDeleteProjectAudioTrackMutation } from './useProjectAudioTracksQuery';
|
||||
export {
|
||||
useProjectMembershipsQuery,
|
||||
useCreateProjectMembershipMutation,
|
||||
useDeleteProjectMembershipMutation,
|
||||
} from './useProjectMembershipsQuery';
|
||||
export {
|
||||
useProjectAudioTracksQuery,
|
||||
useProjectAudioTrackQuery,
|
||||
useCreateProjectAudioTrackMutation,
|
||||
useDeleteProjectAudioTrackMutation,
|
||||
} from './useProjectAudioTracksQuery';
|
||||
export { usePublishEventsQuery } from './usePublishEventsQuery';
|
||||
export { usePwaCachesQuery, useDeletePwaCacheMutation } from './usePwaCachesQuery';
|
||||
export {
|
||||
usePwaCachesQuery,
|
||||
useDeletePwaCacheMutation,
|
||||
} from './usePwaCachesQuery';
|
||||
|
||||
@ -39,7 +39,9 @@ export function useAccessLogsQuery(params?: AccessLogListParams) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accessLogs.list(params),
|
||||
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;
|
||||
},
|
||||
staleTime: 1 * 60 * 1000, // Access logs change frequently
|
||||
|
||||
@ -31,7 +31,7 @@ export function useAssetVariantsQuery(assetId: string | undefined) {
|
||||
queryKey: queryKeys.assetVariants.list(assetId || ''),
|
||||
queryFn: async (): Promise<AssetVariant[]> => {
|
||||
const response = await axios.get<AssetVariantListResponse>(
|
||||
`asset_variants?assetId=${assetId}`
|
||||
`asset_variants?assetId=${assetId}`,
|
||||
);
|
||||
return response.data.rows;
|
||||
},
|
||||
|
||||
@ -38,9 +38,7 @@ export function useElementDefaultsQuery(projectId: string | undefined) {
|
||||
// Process and normalize the defaults
|
||||
const normalizedDefaults = response.data.rows
|
||||
.map((row) => normalizeElementDefault(row))
|
||||
.filter(
|
||||
(d): d is NormalizedElementDefault => d !== null,
|
||||
);
|
||||
.filter((d): d is NormalizedElementDefault => d !== null);
|
||||
|
||||
return buildElementDefaultsMap(normalizedDefaults);
|
||||
},
|
||||
|
||||
@ -71,10 +71,7 @@ export function useUpdatePageMutation() {
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the single page cache
|
||||
queryClient.setQueryData(
|
||||
queryKeys.tourPages.detail(variables.id),
|
||||
data,
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data);
|
||||
// Invalidate list queries
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
|
||||
},
|
||||
|
||||
@ -32,7 +32,7 @@ export function useProjectAudioTracksQuery(projectId: string | undefined) {
|
||||
queryKey: queryKeys.projectAudioTracks.list(projectId || ''),
|
||||
queryFn: async (): Promise<ProjectAudioTrack[]> => {
|
||||
const response = await axios.get<ProjectAudioTrackListResponse>(
|
||||
`project_audio_tracks?projectId=${projectId}`
|
||||
`project_audio_tracks?projectId=${projectId}`,
|
||||
);
|
||||
return response.data.rows;
|
||||
},
|
||||
@ -48,7 +48,9 @@ export function useProjectAudioTrackQuery(trackId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.projectAudioTracks.detail(trackId || ''),
|
||||
queryFn: async (): Promise<ProjectAudioTrack> => {
|
||||
const response = await axios.get<ProjectAudioTrack>(`project_audio_tracks/${trackId}`);
|
||||
const response = await axios.get<ProjectAudioTrack>(
|
||||
`project_audio_tracks/${trackId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!trackId,
|
||||
@ -63,8 +65,13 @@ export function useCreateProjectAudioTrackMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<ProjectAudioTrack>): Promise<ProjectAudioTrack> => {
|
||||
const response = await axios.post<ProjectAudioTrack>('project_audio_tracks', { data });
|
||||
mutationFn: async (
|
||||
data: Partial<ProjectAudioTrack>,
|
||||
): Promise<ProjectAudioTrack> => {
|
||||
const response = await axios.post<ProjectAudioTrack>(
|
||||
'project_audio_tracks',
|
||||
{ data },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
|
||||
@ -34,7 +34,7 @@ export function useProjectMembershipsQuery(projectId: string | undefined) {
|
||||
queryKey: queryKeys.projectMemberships.list(projectId || ''),
|
||||
queryFn: async (): Promise<ProjectMembership[]> => {
|
||||
const response = await axios.get<ProjectMembershipListResponse>(
|
||||
`project_memberships?projectId=${projectId}`
|
||||
`project_memberships?projectId=${projectId}`,
|
||||
);
|
||||
return response.data.rows;
|
||||
},
|
||||
@ -50,8 +50,13 @@ export function useCreateProjectMembershipMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<ProjectMembership>): Promise<ProjectMembership> => {
|
||||
const response = await axios.post<ProjectMembership>('project_memberships', { data });
|
||||
mutationFn: async (
|
||||
data: Partial<ProjectMembership>,
|
||||
): Promise<ProjectMembership> => {
|
||||
const response = await axios.post<ProjectMembership>(
|
||||
'project_memberships',
|
||||
{ data },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
|
||||
@ -74,10 +74,7 @@ export function useUpdateProjectMutation() {
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the cache with the new data
|
||||
queryClient.setQueryData(
|
||||
queryKeys.projects.detail(variables.id),
|
||||
data,
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.projects.detail(variables.id), data);
|
||||
// Invalidate list queries to refetch
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
|
||||
@ -44,7 +44,7 @@ export function usePublishEventsQuery(projectId: string | undefined) {
|
||||
queryKey: queryKeys.publishEvents.list(projectId || ''),
|
||||
queryFn: async (): Promise<PublishEvent[]> => {
|
||||
const response = await axios.get<PublishEventListResponse>(
|
||||
`publish_events?projectId=${projectId}&limit=50`
|
||||
`publish_events?projectId=${projectId}&limit=50`,
|
||||
);
|
||||
return response.data.rows;
|
||||
},
|
||||
|
||||
@ -31,7 +31,7 @@ export function usePwaCachesQuery(projectId: string | undefined) {
|
||||
queryKey: queryKeys.pwaCaches.list(projectId || ''),
|
||||
queryFn: async (): Promise<PwaCache[]> => {
|
||||
const response = await axios.get<PwaCacheListResponse>(
|
||||
`pwa_caches?projectId=${projectId}`
|
||||
`pwa_caches?projectId=${projectId}`,
|
||||
);
|
||||
return response.data.rows;
|
||||
},
|
||||
|
||||
@ -6,7 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { ConstructorAsset as ProjectAsset, AssetOption } from '../types/constructor';
|
||||
import type {
|
||||
ConstructorAsset as ProjectAsset,
|
||||
AssetOption,
|
||||
} from '../types/constructor';
|
||||
import {
|
||||
buildAssetOptions,
|
||||
buildBackgroundImageOptions,
|
||||
@ -50,12 +53,11 @@ export interface UseAssetOptionsOptions {
|
||||
* <AssetSelect options={image} value={selectedImage} onChange={setImage} />
|
||||
* ```
|
||||
*/
|
||||
export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOptionsResult {
|
||||
export function useAssetOptions({
|
||||
assets,
|
||||
}: UseAssetOptionsOptions): AssetOptionsResult {
|
||||
// All image assets
|
||||
const imageOptions = useMemo(
|
||||
() => buildImageAssetOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const imageOptions = useMemo(() => buildImageAssetOptions(assets), [assets]);
|
||||
|
||||
// Background image assets (filtered by type or naming convention)
|
||||
const backgroundImageOptions = useMemo(
|
||||
@ -83,10 +85,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption
|
||||
);
|
||||
|
||||
// Audio assets
|
||||
const audioOptions = useMemo(
|
||||
() => buildAudioAssetOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const audioOptions = useMemo(() => buildAudioAssetOptions(assets), [assets]);
|
||||
|
||||
// Transition video assets
|
||||
const transitionVideoOptions = useMemo(
|
||||
@ -95,10 +94,7 @@ export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOption
|
||||
);
|
||||
|
||||
// Icon assets
|
||||
const iconOptions = useMemo(
|
||||
() => buildIconAssetOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const iconOptions = useMemo(() => buildIconAssetOptions(assets), [assets]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@ -21,7 +21,9 @@ interface UseConstructorDataParams {
|
||||
}
|
||||
|
||||
// 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_ASSETS: Asset[] = [];
|
||||
|
||||
@ -39,7 +41,9 @@ interface UseConstructorDataResult {
|
||||
assets: Asset[];
|
||||
|
||||
// Element Defaults
|
||||
uiElementDefaultsByType: Partial<Record<CanvasElementType, Partial<CanvasElement>>>;
|
||||
uiElementDefaultsByType: Partial<
|
||||
Record<CanvasElementType, Partial<CanvasElement>>
|
||||
>;
|
||||
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
@ -64,19 +68,25 @@ export function useConstructorData({
|
||||
const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev');
|
||||
|
||||
// Fetch assets
|
||||
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, { limit: 500 });
|
||||
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, {
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
// Fetch element defaults
|
||||
const elementDefaultsQuery = useElementDefaultsQuery(enabled ? projectId : undefined);
|
||||
const elementDefaultsQuery = useElementDefaultsQuery(
|
||||
enabled ? projectId : undefined,
|
||||
);
|
||||
|
||||
// Extract page links and preload elements from pages
|
||||
const { pageLinks, allPagesPreloadElements } = useMemo(() => {
|
||||
if (!pagesQuery.data || pagesQuery.data.length === 0) {
|
||||
return { pageLinks: [] as PreloadPageLink[], allPagesPreloadElements: [] as PreloadElement[] };
|
||||
return {
|
||||
pageLinks: [] as PreloadPageLink[],
|
||||
allPagesPreloadElements: [] as PreloadElement[],
|
||||
};
|
||||
}
|
||||
const { pageLinks: links, preloadElements: elements } = extractPageLinksAndElements(
|
||||
pagesQuery.data as TourPage[],
|
||||
);
|
||||
const { pageLinks: links, preloadElements: elements } =
|
||||
extractPageLinksAndElements(pagesQuery.data as TourPage[]);
|
||||
return { pageLinks: links, allPagesPreloadElements: elements };
|
||||
}, [pagesQuery.data]);
|
||||
|
||||
@ -124,7 +134,8 @@ export function useConstructorData({
|
||||
assets: (assetsQuery.data as Asset[]) || EMPTY_ASSETS,
|
||||
|
||||
// Element Defaults
|
||||
uiElementDefaultsByType: elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS,
|
||||
uiElementDefaultsByType:
|
||||
elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS,
|
||||
|
||||
// Loading state
|
||||
isLoading,
|
||||
|
||||
@ -180,7 +180,9 @@ export function useConstructorPageActions({
|
||||
);
|
||||
await onReload(activePageId);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -227,7 +229,9 @@ export function useConstructorPageActions({
|
||||
'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -283,7 +287,9 @@ export function useConstructorPageActions({
|
||||
onSetMenuOpen?.(true);
|
||||
onSuccess?.('New page created. You can now configure it in constructor.');
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -341,7 +347,9 @@ export function useConstructorPageActions({
|
||||
'Transition video can be set directly on navigation elements.',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
|
||||
@ -319,7 +319,9 @@ export function useOfflineMode(
|
||||
setStatus('downloaded');
|
||||
setProgress(100);
|
||||
await OfflineDbManager.updateProjectStatus(projectId, 'downloaded');
|
||||
logger.info('[useOfflineMode] All assets already cached', { projectId });
|
||||
logger.info('[useOfflineMode] All assets already cached', {
|
||||
projectId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -57,9 +57,7 @@ export interface UsePageBackgroundResult {
|
||||
setAudioUrl: (url: string) => void;
|
||||
|
||||
/** Update video settings */
|
||||
setVideoSettings: (
|
||||
settings: Partial<PageBackgroundVideoSettings>,
|
||||
) => void;
|
||||
setVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
|
||||
|
||||
/** Reset to default state */
|
||||
reset: () => void;
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { NavigationContext } from '../lib/navigationHelpers';
|
||||
|
||||
/**
|
||||
* Maximum history entries to prevent unbounded growth in long sessions.
|
||||
* Matches typical browser behavior (50 entries).
|
||||
*/
|
||||
const MAX_HISTORY_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Minimal page interface for navigation
|
||||
@ -27,6 +34,11 @@ export interface UsePageNavigationResult<TPage extends NavigablePage> {
|
||||
isBackNavigation: (targetPageId: string) => boolean;
|
||||
goBack: () => boolean;
|
||||
resetHistory: () => void;
|
||||
/**
|
||||
* Get navigation context for history-based back navigation.
|
||||
* Provides currentPageSlug and previousPageId for resolveNavigationTarget().
|
||||
*/
|
||||
getNavigationContext: () => NavigationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -105,7 +117,7 @@ export function usePageNavigation<TPage extends NavigablePage>(
|
||||
const currentId = prev[prev.length - 1];
|
||||
if (currentId === targetPageId) return prev;
|
||||
|
||||
// If going back and target matches previous, pop history
|
||||
// If going back and target matches previous, pop history (browser-like behavior)
|
||||
if (
|
||||
isBack &&
|
||||
prev.length > 1 &&
|
||||
@ -114,7 +126,9 @@ export function usePageNavigation<TPage extends NavigablePage>(
|
||||
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] : []);
|
||||
}, [currentPageId]);
|
||||
|
||||
// Get navigation context for history-based back navigation
|
||||
const getNavigationContext = useCallback((): NavigationContext => {
|
||||
return {
|
||||
currentPageSlug: currentPage?.slug,
|
||||
previousPageId,
|
||||
};
|
||||
}, [currentPage?.slug, previousPageId]);
|
||||
|
||||
// Initialize to default page
|
||||
useEffect(() => {
|
||||
if (defaultPage?.id && !currentPageId) {
|
||||
@ -168,5 +190,6 @@ export function usePageNavigation<TPage extends NavigablePage>(
|
||||
isBackNavigation,
|
||||
goBack,
|
||||
resetHistory,
|
||||
getNavigationContext,
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,12 +22,15 @@ export interface TransitionConfig {
|
||||
durationSec?: number;
|
||||
targetPageId?: string;
|
||||
displayName?: string;
|
||||
/** Whether this is a back navigation (for history management) */
|
||||
isBack?: boolean;
|
||||
}
|
||||
|
||||
export interface UseTransitionPlaybackOptions {
|
||||
videoRef: RefObject<HTMLVideoElement | 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;
|
||||
|
||||
timeouts?: {
|
||||
@ -287,7 +290,10 @@ export function useTransitionPlayback(
|
||||
}
|
||||
|
||||
setPhase('completed');
|
||||
onCompleteRef.current(currentTransition?.targetPageId);
|
||||
onCompleteRef.current(
|
||||
currentTransition?.targetPageId,
|
||||
currentTransition?.isBack,
|
||||
);
|
||||
},
|
||||
[clearTimers, videoRef],
|
||||
);
|
||||
|
||||
@ -128,6 +128,7 @@ export function useTransitionPreview({
|
||||
reverseStorageKey: element.reverseVideoUrl,
|
||||
durationSec: element.transitionDurationSec,
|
||||
title: `${element.navLabel || element.label || 'Transition'} · ${direction}`,
|
||||
isBack: direction === 'back', // Track for history management
|
||||
};
|
||||
|
||||
setPreview(previewState);
|
||||
|
||||
@ -110,6 +110,7 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
|
||||
navDisabled: false,
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
navBackMode: 'target_page',
|
||||
},
|
||||
tooltip: {
|
||||
iconUrl: '',
|
||||
@ -489,6 +490,7 @@ export const buildElementSettings = (
|
||||
element.transitionReverseMode,
|
||||
);
|
||||
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
|
||||
addIfNotEmpty(settings, 'navBackMode', element.navBackMode);
|
||||
}
|
||||
|
||||
// Tooltip type settings
|
||||
|
||||
@ -11,19 +11,118 @@ import type {
|
||||
NavigationTarget,
|
||||
TransitionPhase,
|
||||
} from '../types/presentation';
|
||||
import { parseJsonObject } from './parseJson';
|
||||
|
||||
/**
|
||||
* Context for resolving history-based back navigation
|
||||
*/
|
||||
export interface NavigationContext {
|
||||
currentPageSlug?: string;
|
||||
previousPageId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI schema structure for type-safe parsing
|
||||
*/
|
||||
interface UiSchemaStructure {
|
||||
elements?: Array<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.
|
||||
* Supports both targetPageSlug (new) and targetPageId (legacy).
|
||||
* Also supports history-based back navigation when navBackMode='history'.
|
||||
*
|
||||
* @param element - Element with navigation properties
|
||||
* @param pages - Available pages to search
|
||||
* @param context - Optional context for history-based navigation
|
||||
* @returns The target page or undefined if not found
|
||||
*/
|
||||
export const resolveNavigationTarget = (
|
||||
element: NavigableElement,
|
||||
pages: RuntimePage[],
|
||||
context?: NavigationContext,
|
||||
): NavigationTarget | null => {
|
||||
// Handle history-based back navigation
|
||||
if (isBackNavigation(element) && element.navBackMode === 'history') {
|
||||
return resolveHistoryBackTarget(
|
||||
pages,
|
||||
context?.currentPageSlug || '',
|
||||
context?.previousPageId || null,
|
||||
);
|
||||
}
|
||||
|
||||
// Standard target_page mode logic
|
||||
const targetPageSlug = element.targetPageSlug;
|
||||
const legacyTargetPageId = element.targetPageId;
|
||||
|
||||
@ -45,6 +144,8 @@ export const resolveNavigationTarget = (
|
||||
page: targetPage,
|
||||
pageId: targetPage.id,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
transitionReverseMode: element.transitionReverseMode,
|
||||
reverseVideoUrl: element.reverseVideoUrl,
|
||||
isBack,
|
||||
};
|
||||
};
|
||||
|
||||
@ -212,59 +212,65 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
{getLayout(
|
||||
<>
|
||||
<Head>
|
||||
<meta name='description' content={description} />
|
||||
<meta property='og:url' content={url} />
|
||||
<meta property='og:site_name' content='https://flatlogic.com/' />
|
||||
<meta key='og:title' property='og:title' content={title} />
|
||||
<meta
|
||||
key='og:description'
|
||||
property='og:description'
|
||||
content={description}
|
||||
/>
|
||||
<meta key='og:image' property='og:image' content={image} />
|
||||
<meta property='og:image:type' content='image/png' />
|
||||
<meta property='og:image:width' content={imageWidth} />
|
||||
<meta property='og:image:height' content={imageHeight} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<meta
|
||||
key='twitter:title'
|
||||
property='twitter:title'
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
key='twitter:description'
|
||||
property='twitter:description'
|
||||
content={description}
|
||||
/>
|
||||
<meta
|
||||
key='twitter:image:src'
|
||||
property='twitter:image:src'
|
||||
content={image}
|
||||
/>
|
||||
<meta property='twitter:image:width' content={imageWidth} />
|
||||
<meta property='twitter:image:height' content={imageHeight} />
|
||||
<link key='favicon' rel='icon' href='/favicon.svg' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<meta name='theme-color' content='#3B82F6' />
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta
|
||||
name='apple-mobile-web-app-status-bar-style'
|
||||
content='default'
|
||||
/>
|
||||
<meta name='apple-mobile-web-app-title' content='Tour Builder' />
|
||||
</Head>
|
||||
<meta name='description' content={description} />
|
||||
<meta property='og:url' content={url} />
|
||||
<meta
|
||||
property='og:site_name'
|
||||
content='https://flatlogic.com/'
|
||||
/>
|
||||
<meta key='og:title' property='og:title' content={title} />
|
||||
<meta
|
||||
key='og:description'
|
||||
property='og:description'
|
||||
content={description}
|
||||
/>
|
||||
<meta key='og:image' property='og:image' content={image} />
|
||||
<meta property='og:image:type' content='image/png' />
|
||||
<meta property='og:image:width' content={imageWidth} />
|
||||
<meta property='og:image:height' content={imageHeight} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<meta
|
||||
key='twitter:title'
|
||||
property='twitter:title'
|
||||
content={title}
|
||||
/>
|
||||
<meta
|
||||
key='twitter:description'
|
||||
property='twitter:description'
|
||||
content={description}
|
||||
/>
|
||||
<meta
|
||||
key='twitter:image:src'
|
||||
property='twitter:image:src'
|
||||
content={image}
|
||||
/>
|
||||
<meta property='twitter:image:width' content={imageWidth} />
|
||||
<meta property='twitter:image:height' content={imageHeight} />
|
||||
<link key='favicon' rel='icon' href='/favicon.svg' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<meta name='theme-color' content='#3B82F6' />
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta
|
||||
name='apple-mobile-web-app-status-bar-style'
|
||||
content='default'
|
||||
/>
|
||||
<meta
|
||||
name='apple-mobile-web-app-title'
|
||||
content='Tour Builder'
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Component {...pageProps} />
|
||||
</ErrorBoundary>
|
||||
<IntroGuide
|
||||
steps={steps}
|
||||
stepsName={stepName}
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
</>,
|
||||
)}
|
||||
<ErrorBoundary>
|
||||
<Component {...pageProps} />
|
||||
</ErrorBoundary>
|
||||
<IntroGuide
|
||||
steps={steps}
|
||||
stepsName={stepName}
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
</>,
|
||||
)}
|
||||
</DownloadProvider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@ -67,9 +67,7 @@ const EditAccess_logs = () => {
|
||||
|
||||
useEffect(() => {
|
||||
// access_logs is now always an array; get the first element for single entity view
|
||||
const entity = Array.isArray(access_logs)
|
||||
? access_logs[0]
|
||||
: access_logs;
|
||||
const entity = Array.isArray(access_logs) ? access_logs[0] : access_logs;
|
||||
|
||||
if (entity && typeof entity === 'object') {
|
||||
const newInitialVal = { ...initVals };
|
||||
|
||||
@ -22,6 +22,7 @@ import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { logger } from '../lib/logger';
|
||||
@ -94,7 +95,6 @@ import {
|
||||
// TourPage type is imported from '../types/entities'
|
||||
// NavigationElementType is imported from '../context/ConstructorContext'
|
||||
|
||||
|
||||
type ConstructorPageProps = {
|
||||
mode?: 'constructor' | 'element_edit';
|
||||
};
|
||||
@ -151,7 +151,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
isAuthReady,
|
||||
});
|
||||
|
||||
const [activePageId, setActivePageId] = useState('');
|
||||
// Page navigation with history tracking via shared hook
|
||||
const {
|
||||
currentPageId: activePageId,
|
||||
pageHistory,
|
||||
applyPageSelection,
|
||||
getNavigationContext,
|
||||
setCurrentPageId: setActivePageId,
|
||||
} = usePageNavigation({
|
||||
pages,
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
// Consolidated page background state (replaces 8 separate useState hooks)
|
||||
const {
|
||||
@ -341,8 +351,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Helper to switch pages without flash
|
||||
// Uses usePageSwitch hook to resolve blob URLs from preload cache
|
||||
// Also updates storage path state for editing/saving purposes
|
||||
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
||||
const switchToPage = useCallback(
|
||||
async (page: TourPage | null) => {
|
||||
async (page: TourPage | null, isBack = false) => {
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
if (page) {
|
||||
lastInitializedPageIdRef.current = page.id;
|
||||
@ -363,12 +374,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
: null,
|
||||
() => {
|
||||
if (page) {
|
||||
setActivePageId(page.id);
|
||||
// Use applyPageSelection for proper history management (pops on back)
|
||||
applyPageSelection(page.id, isBack);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[pageSwitchToPage, updateBackgroundFromPage],
|
||||
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
|
||||
);
|
||||
|
||||
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
||||
@ -384,14 +396,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
durationSec: transitionPreview.durationSec,
|
||||
targetPageId: pendingNavigationPageId || undefined,
|
||||
displayName: transitionPreview.title,
|
||||
isBack: transitionPreview.isBack, // Pass through for history management
|
||||
}
|
||||
: null,
|
||||
onComplete: async (targetPageId) => {
|
||||
onComplete: async (targetPageId, isBack) => {
|
||||
const video = transitionVideoRef.current;
|
||||
if (targetPageId) {
|
||||
const targetPage = pages.find((p) => p.id === targetPageId) || null;
|
||||
// Use switchToPage which resolves blob URLs via usePageSwitch
|
||||
await switchToPage(targetPage);
|
||||
// Pass isBack flag for proper history management (pops on back)
|
||||
await switchToPage(targetPage, isBack ?? false);
|
||||
clearSelection();
|
||||
setSelectedMenuItem('none');
|
||||
setErrorMessage('');
|
||||
@ -475,9 +489,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
],
|
||||
);
|
||||
|
||||
const { getDuration, getDurationNote, durationBySource } = useMediaDurationProbe({
|
||||
targets: durationProbeTargets,
|
||||
});
|
||||
const { getDuration, getDurationNote, durationBySource } =
|
||||
useMediaDurationProbe({
|
||||
targets: durationProbeTargets,
|
||||
});
|
||||
|
||||
const backgroundVideoDurationNote = getDurationNote(backgroundVideoUrl);
|
||||
const backgroundAudioDurationNote = getDurationNote(backgroundAudioUrl);
|
||||
@ -533,21 +548,19 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Only set initial page when pages first load (not on every change)
|
||||
if (pages.length > 0 && prevPagesLengthRef.current === 0) {
|
||||
const defaultPageId = pageIdFromRoute || pages[0]?.id || '';
|
||||
setActivePageId(defaultPageId);
|
||||
// Use applyPageSelection to set initial page and history
|
||||
applyPageSelection(defaultPageId, false);
|
||||
setIsMenuOpen(false);
|
||||
setIsInitializing(false);
|
||||
}
|
||||
prevPagesLengthRef.current = pages.length;
|
||||
}, [pages, pageIdFromRoute]);
|
||||
}, [pages, pageIdFromRoute, applyPageSelection]);
|
||||
|
||||
// Handle query errors
|
||||
useEffect(() => {
|
||||
if (isDataError && dataError) {
|
||||
const message = dataError.message || 'Failed to load constructor data.';
|
||||
logger.error(
|
||||
'Failed to load constructor data:',
|
||||
dataError,
|
||||
);
|
||||
logger.error('Failed to load constructor data:', dataError);
|
||||
setErrorMessage(message);
|
||||
}
|
||||
}, [isDataError, dataError]);
|
||||
@ -677,11 +690,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
? item.galleryTitle
|
||||
: undefined,
|
||||
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
|
||||
? item.galleryInfoSpans.map((span: Partial<GalleryInfoSpan>) => ({
|
||||
id: String(span?.id || createLocalId()),
|
||||
text: String(span?.text ?? ''),
|
||||
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
|
||||
}))
|
||||
? item.galleryInfoSpans.map(
|
||||
(span: Partial<GalleryInfoSpan>) => ({
|
||||
id: String(span?.id || createLocalId()),
|
||||
text: String(span?.text ?? ''),
|
||||
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
galleryColumns:
|
||||
typeof item.galleryColumns === 'number'
|
||||
@ -994,20 +1009,51 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
// Use shared navigation helpers
|
||||
const direction = getNavigationDirection(element);
|
||||
const navTarget = resolveNavigationTarget(element, pages);
|
||||
|
||||
// Get navigation context from hook for history-based back navigation
|
||||
const navContext = getNavigationContext();
|
||||
|
||||
// Pass history context for history-based back navigation
|
||||
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
||||
|
||||
if (!navTarget) {
|
||||
setErrorMessage(
|
||||
'No target page configured for this navigation button.',
|
||||
);
|
||||
// History mode back buttons need navigation history
|
||||
if (element.navBackMode === 'history') {
|
||||
if (!navContext.previousPageId) {
|
||||
setErrorMessage(
|
||||
'No previous page in history. Navigate to another page first.',
|
||||
);
|
||||
} else {
|
||||
setErrorMessage(
|
||||
'Previous page not found. It may have been deleted.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(
|
||||
'No target page configured for this navigation button.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For history mode, use transition from navTarget (the forward element that brought us here)
|
||||
// For target_page mode, use the element's own transition settings
|
||||
const transitionSource =
|
||||
element.navBackMode === 'history'
|
||||
? {
|
||||
type: element.type,
|
||||
transitionVideoUrl: navTarget.transitionVideoUrl,
|
||||
transitionReverseMode: navTarget.transitionReverseMode,
|
||||
reverseVideoUrl: navTarget.reverseVideoUrl,
|
||||
}
|
||||
: element;
|
||||
|
||||
// Check if transition can be played using shared helper
|
||||
if (!hasPlayableTransition(element, direction)) {
|
||||
if (!hasPlayableTransition(transitionSource, direction)) {
|
||||
closeTransitionPreview();
|
||||
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
||||
switchToPage(navTarget.page).then(() => {
|
||||
// Pass isBack flag for proper history management
|
||||
switchToPage(navTarget.page, navTarget.isBack).then(() => {
|
||||
clearSelection();
|
||||
setSelectedMenuItem('none');
|
||||
setErrorMessage('');
|
||||
@ -1015,7 +1061,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
openPreviewWithTarget(element, direction, navTarget.pageId);
|
||||
openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -42,7 +42,9 @@ const ElementTypeDefaultsPage = () => {
|
||||
: [];
|
||||
setRows(nextRows);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
|
||||
@ -50,7 +50,10 @@ const ProjectElementDefaultsPage = () => {
|
||||
const response = await axios.get(`/projects/${projectId}`);
|
||||
setProject(response?.data || null);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to load project:', error instanceof Error ? error : { error });
|
||||
logger.error(
|
||||
'Failed to load project:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@ -71,7 +74,9 @@ const ProjectElementDefaultsPage = () => {
|
||||
: [];
|
||||
setRows(nextRows);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
|
||||
@ -59,7 +59,9 @@ const ProjectWorkspacePage = () => {
|
||||
const response = await axios.get(`/projects/${projectId}`);
|
||||
setProject(response?.data || null);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
setErrorMessage(
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
@ -123,7 +125,9 @@ const ProjectWorkspacePage = () => {
|
||||
);
|
||||
const axiosError = error as { response?: { data?: string } };
|
||||
const message =
|
||||
axiosError?.response?.data || (error instanceof Error ? error.message : null) || 'Publish failed';
|
||||
axiosError?.response?.data ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
'Publish failed';
|
||||
toast(typeof message === 'string' ? message : 'Publish failed', {
|
||||
type: 'error',
|
||||
position: 'bottom-center',
|
||||
|
||||
@ -69,7 +69,9 @@ const PublishEventsHistoryPage = () => {
|
||||
'Failed to load publish history:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string } };
|
||||
};
|
||||
setErrorMessage(
|
||||
axiosError?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
|
||||
@ -72,8 +72,7 @@ const constructorSlice = createSlice({
|
||||
state,
|
||||
action: PayloadAction<{ projectId: string; pageId: string }>,
|
||||
) => {
|
||||
state.lastActivePageId[action.payload.projectId] =
|
||||
action.payload.pageId;
|
||||
state.lastActivePageId[action.payload.projectId] = action.payload.pageId;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -14,10 +14,7 @@ import {
|
||||
rejectNotify,
|
||||
resetNotify,
|
||||
} from '../helpers/notifyStateHandler';
|
||||
import type {
|
||||
EntitySliceConfig,
|
||||
EntitySliceState,
|
||||
} from '../types/redux';
|
||||
import type { EntitySliceConfig, EntitySliceState } from '../types/redux';
|
||||
import type { PaginatedResponse, FetchParams, ApiError } from '../types/api';
|
||||
import type { BaseEntity } from '../types/entities';
|
||||
|
||||
|
||||
@ -34,20 +34,23 @@ const fulfilledNotify = (
|
||||
state.notify.showNotification = true;
|
||||
};
|
||||
|
||||
export const aiPrompt = createAsyncThunk<CreateWidgetResponse, CreateWidgetRequest>(
|
||||
'openai/aiPrompt',
|
||||
async (data, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post<CreateWidgetResponse>('/openai/create_widget', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
export const aiPrompt = createAsyncThunk<
|
||||
CreateWidgetResponse,
|
||||
CreateWidgetRequest
|
||||
>('openai/aiPrompt', async (data, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post<CreateWidgetResponse>(
|
||||
'/openai/create_widget',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
export const askGpt = createAsyncThunk(
|
||||
'openai/askGpt',
|
||||
|
||||
@ -52,13 +52,18 @@ export const fetchWidgets = createAsyncThunk(
|
||||
);
|
||||
|
||||
// Initial state with widgets
|
||||
const initialWidgetsState: { rolesWidgets: Array<{ id: string; [key: string]: unknown }> } = {
|
||||
const initialWidgetsState: {
|
||||
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
|
||||
} = {
|
||||
rolesWidgets: [],
|
||||
};
|
||||
|
||||
// Combined reducer that extends base reducer with widget handling
|
||||
const combinedReducer = createReducer(
|
||||
{ ...baseReducer(undefined, { type: '' }), ...initialWidgetsState } as RolesSliceState,
|
||||
{
|
||||
...baseReducer(undefined, { type: '' }),
|
||||
...initialWidgetsState,
|
||||
} as RolesSliceState,
|
||||
(builder) => {
|
||||
// Handle fetchWidgets.fulfilled
|
||||
builder.addCase(fetchWidgets.fulfilled, (state, action) => {
|
||||
|
||||
@ -226,6 +226,8 @@ export interface CanvasElement extends BaseCanvasElement {
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl?: string;
|
||||
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
transitionDurationSec?: number;
|
||||
// Gallery Carousel Settings
|
||||
galleryCarouselPrevIconUrl?: string;
|
||||
@ -571,16 +573,18 @@ export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
|
||||
/**
|
||||
* Create page background state from tour page data
|
||||
*/
|
||||
export function createPageBackgroundFromPage(page: {
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
} | null): PageBackgroundState {
|
||||
export function createPageBackgroundFromPage(
|
||||
page: {
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
} | null,
|
||||
): PageBackgroundState {
|
||||
if (!page) {
|
||||
return { ...DEFAULT_PAGE_BACKGROUND };
|
||||
}
|
||||
|
||||
@ -25,6 +25,8 @@ export interface TransitionPreviewState {
|
||||
durationSec?: number;
|
||||
/** Display title for the preview */
|
||||
title: string;
|
||||
/** Whether this is a back navigation (for history management) */
|
||||
isBack?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,6 +45,8 @@ export interface NavigationTarget {
|
||||
page: RuntimePage;
|
||||
pageId: string;
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl?: string;
|
||||
isBack: boolean;
|
||||
}
|
||||
|
||||
@ -76,8 +80,12 @@ export interface NavigableElement {
|
||||
targetPageSlug?: string;
|
||||
targetPageId?: string;
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl?: string;
|
||||
navType?: 'forward' | 'back';
|
||||
navDisabled?: boolean;
|
||||
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -20,7 +20,6 @@ export interface EntitySliceState<T> {
|
||||
[entityName: string]: T[] | boolean | number | NotificationState | unknown;
|
||||
}
|
||||
|
||||
|
||||
// Slice factory configuration
|
||||
export interface EntitySliceConfig {
|
||||
name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user