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

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

View File

@ -19,7 +19,11 @@ import {
mdiExitToApp,
} 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>
);
},
);

View File

@ -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,
)
}
/>
</>

View File

@ -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>
);

View File

@ -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,

View File

@ -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

View File

@ -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 {

View File

@ -46,7 +46,10 @@ export const SelectFieldMany = ({
);
};
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
async function callApi(
inputValue: string,
loadedOptions: Array<{ value: string; label: string }>,
) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {

View File

@ -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 }[];

View File

@ -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,

View File

@ -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 }[];

View File

@ -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,

View File

@ -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 }[];

View File

@ -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 }[];

View File

@ -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,

View File

@ -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'];

View File

@ -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,

View File

@ -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) ||

View File

@ -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 {

View File

@ -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();

View File

@ -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[];

View File

@ -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,

View File

@ -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';

View File

@ -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

View File

@ -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;
},

View File

@ -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);
},

View File

@ -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 });
},

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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 });
},

View File

@ -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;
},

View File

@ -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;
},

View File

@ -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(
() => ({

View File

@ -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,

View File

@ -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) ||

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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],
);

View File

@ -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);

View File

@ -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

View File

@ -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,
};
};

View File

@ -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>

View File

@ -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 };

View File

@ -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;
}

View File

@ -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) ||

View File

@ -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) ||

View File

@ -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',

View File

@ -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) ||

View File

@ -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;
},
/**

View File

@ -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';

View File

@ -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',

View File

@ -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) => {

View File

@ -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 };
}

View File

@ -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';
}
/**

View File

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