improved preloading logic
This commit is contained in:
parent
b925094555
commit
eac21c84b3
@ -211,6 +211,18 @@ class PWAManifestService {
|
||||
[page.id],
|
||||
);
|
||||
}
|
||||
if (page.background_audio_url) {
|
||||
addAsset(
|
||||
`page-audio-${page.id}`,
|
||||
page.background_audio_url,
|
||||
`page-${page.slug}-audio.mp3`,
|
||||
'original',
|
||||
'audio',
|
||||
'audio/mpeg',
|
||||
0,
|
||||
[page.id],
|
||||
);
|
||||
}
|
||||
|
||||
// Extract URLs from ui_schema_json elements
|
||||
try {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import React, {
|
||||
@ -25,28 +24,44 @@ import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { logger } from '../lib/logger';
|
||||
// buildElementStyle is now used in RuntimeElement component
|
||||
import type { RuntimeProject, RuntimePage } from '../types/runtime';
|
||||
import {
|
||||
resolveNavigationTarget,
|
||||
isTransitionBlocking,
|
||||
} from '../lib/navigationHelpers';
|
||||
import type { TransitionPhase } from '../types/presentation';
|
||||
|
||||
interface RuntimePresentationProps {
|
||||
projectSlug: string;
|
||||
environment: 'stage' | 'production';
|
||||
}
|
||||
|
||||
const getRows = (response: any) =>
|
||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
||||
|
||||
export default function RuntimePresentation({
|
||||
projectSlug,
|
||||
environment,
|
||||
}: RuntimePresentationProps) {
|
||||
const [project, setProject] = useState<RuntimeProject | null>(null);
|
||||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
||||
// Use shared hook for loading project and pages data
|
||||
const {
|
||||
project,
|
||||
pages,
|
||||
isLoading,
|
||||
error,
|
||||
initialPageId,
|
||||
} = usePageDataLoader({
|
||||
projectSlug,
|
||||
environment,
|
||||
apiHeaders: {
|
||||
'X-Runtime-Project-Slug': projectSlug,
|
||||
'X-Runtime-Environment': environment,
|
||||
},
|
||||
});
|
||||
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
const [transitionPreview, setTransitionPreview] = useState<{
|
||||
@ -55,27 +70,21 @@ export default function RuntimePresentation({
|
||||
storageKey: string;
|
||||
isReverse: boolean;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||
useState(false);
|
||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
||||
|
||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||
|
||||
// API request config with custom headers for project/environment
|
||||
const apiConfig = useMemo(
|
||||
() => ({
|
||||
headers: {
|
||||
'X-Runtime-Project-Slug': projectSlug,
|
||||
'X-Runtime-Environment': environment,
|
||||
},
|
||||
}),
|
||||
[projectSlug, environment],
|
||||
);
|
||||
// Set initial page when data loads
|
||||
useEffect(() => {
|
||||
if (initialPageId && !selectedPageId) {
|
||||
setSelectedPageId(initialPageId);
|
||||
setPageHistory([initialPageId]);
|
||||
}
|
||||
}, [initialPageId, selectedPageId]);
|
||||
|
||||
// Extract page links and preload elements from ui_schema_json
|
||||
// This enables the neighbor graph to find connected pages for preloading
|
||||
@ -163,6 +172,20 @@ export default function RuntimePresentation({
|
||||
},
|
||||
});
|
||||
|
||||
// Use shared background transition hook for fade-out effects
|
||||
const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeOut: {
|
||||
pendingTransitionComplete,
|
||||
isBackgroundReady,
|
||||
transitionVideoRef,
|
||||
onTransitionCleanup: useCallback(() => {
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
}, []),
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
@ -190,126 +213,6 @@ export default function RuntimePresentation({
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// Fade out and remove transition overlay when background is ready
|
||||
useEffect(() => {
|
||||
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
|
||||
// Start fade-out animation
|
||||
setIsOverlayFadingOut(true);
|
||||
|
||||
// After fade completes (300ms), remove the overlay
|
||||
const fadeTimer = setTimeout(() => {
|
||||
const video = transitionVideoRef.current;
|
||||
video?.removeAttribute('src');
|
||||
video?.load();
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
setIsOverlayFadingOut(false);
|
||||
// Clear previous background from shared hook
|
||||
pageSwitch.clearPreviousBackground();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(fadeTimer);
|
||||
}
|
||||
}, [
|
||||
pendingTransitionComplete,
|
||||
isBackgroundReady,
|
||||
isOverlayFadingOut,
|
||||
pageSwitch.clearPreviousBackground,
|
||||
]);
|
||||
|
||||
// Clear previous background overlay when new background is ready (direct navigation)
|
||||
useEffect(() => {
|
||||
if (
|
||||
pageSwitch.isSwitching &&
|
||||
pageSwitch.isNewBgReady &&
|
||||
pageSwitch.previousBgImageUrl
|
||||
) {
|
||||
// New background is ready - clear the previous background overlay
|
||||
pageSwitch.clearPreviousBackground();
|
||||
}
|
||||
}, [
|
||||
pageSwitch.isSwitching,
|
||||
pageSwitch.isNewBgReady,
|
||||
pageSwitch.previousBgImageUrl,
|
||||
pageSwitch.clearPreviousBackground,
|
||||
]);
|
||||
|
||||
// Load presentation data
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const loadPresentation = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
// Fetch project by slug
|
||||
const projectsResponse = await axios.get('/projects', {
|
||||
...apiConfig,
|
||||
params: { slug: projectSlug },
|
||||
});
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
const projectRows = getRows(projectsResponse);
|
||||
const foundProject = projectRows.find(
|
||||
(p: RuntimeProject) => p.slug === projectSlug,
|
||||
);
|
||||
|
||||
if (!foundProject) {
|
||||
setError(`Project "${projectSlug}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setProject(foundProject);
|
||||
|
||||
// Fetch pages for this project
|
||||
// (Elements and navigation are extracted from ui_schema_json)
|
||||
const pagesResponse = await axios.get('/tour_pages', {
|
||||
...apiConfig,
|
||||
params: { project: foundProject.id },
|
||||
});
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
const pageRows = getRows(pagesResponse);
|
||||
|
||||
// Filter by environment and sort by sort_order
|
||||
// STRICT: Only show pages matching the exact environment
|
||||
// Production = production only, Stage = stage only, Dev = dev only
|
||||
const envFilteredPages = pageRows
|
||||
.filter((p: any) => p.environment === environment)
|
||||
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
|
||||
setPages(envFilteredPages);
|
||||
|
||||
// Set initial page (first page by sort_order)
|
||||
if (envFilteredPages.length > 0) {
|
||||
const firstPage = envFilteredPages[0];
|
||||
setSelectedPageId(firstPage.id);
|
||||
setPageHistory([firstPage.id]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCancelled) return;
|
||||
const message =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to load presentation.';
|
||||
setError(message);
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPresentation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [projectSlug, environment, apiConfig]);
|
||||
|
||||
const selectedPage = useMemo(
|
||||
() => pages.find((p) => p.id === selectedPageId) || null,
|
||||
[pages, selectedPageId],
|
||||
@ -371,8 +274,8 @@ export default function RuntimePresentation({
|
||||
|
||||
if (transitionVideoUrl) {
|
||||
// Reset states from previous transition before starting new one
|
||||
// This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets
|
||||
setIsOverlayFadingOut(false);
|
||||
// This prevents the fade-out effect from re-triggering
|
||||
resetFadeOut();
|
||||
setPendingTransitionComplete(false);
|
||||
// Play transition using useTransitionPlayback hook
|
||||
setTransitionPreview({
|
||||
@ -394,50 +297,35 @@ export default function RuntimePresentation({
|
||||
});
|
||||
}
|
||||
},
|
||||
[pages, pageSwitch],
|
||||
[pages, pageSwitch, resetFadeOut],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
(element: any) => {
|
||||
// Disable navigation while transition is actively playing or buffering
|
||||
// Only block during active phases, not during fade-out (completed phase)
|
||||
const isActivelyPlaying =
|
||||
transitionPhase === 'preparing' ||
|
||||
transitionPhase === 'playing' ||
|
||||
transitionPhase === 'reversing';
|
||||
if (isActivelyPlaying || isBuffering) {
|
||||
// Block navigation while transition is actively playing or buffering
|
||||
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Support both targetPageSlug (new) and targetPageId (legacy)
|
||||
const targetPageSlug = element.targetPageSlug;
|
||||
const legacyTargetPageId = element.targetPageId;
|
||||
|
||||
// Resolve slug to page ID, or use legacy targetPageId
|
||||
let targetPageId: string | undefined;
|
||||
if (targetPageSlug) {
|
||||
const targetPage = pages.find((p) => p.slug === targetPageSlug);
|
||||
targetPageId = targetPage?.id;
|
||||
} else if (legacyTargetPageId) {
|
||||
targetPageId = legacyTargetPageId;
|
||||
}
|
||||
// Use shared helper to resolve navigation target
|
||||
const navTarget = resolveNavigationTarget(element, pages);
|
||||
|
||||
// Debug: log element navigation data
|
||||
logger.info('Element clicked', {
|
||||
elementType: element.type,
|
||||
targetPageSlug,
|
||||
legacyTargetPageId,
|
||||
resolvedTargetPageId: targetPageId,
|
||||
targetPageSlug: element.targetPageSlug,
|
||||
legacyTargetPageId: element.targetPageId,
|
||||
resolvedTargetPageId: navTarget?.pageId,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
hasTransition: Boolean(element.transitionVideoUrl),
|
||||
});
|
||||
|
||||
if (targetPageId) {
|
||||
const isBack =
|
||||
element.navType === 'back' || element.type === 'navigation_prev';
|
||||
// Get transition video URL from element itself
|
||||
const transitionVideoUrl = element.transitionVideoUrl;
|
||||
navigateToPage(targetPageId, transitionVideoUrl, isBack);
|
||||
if (navTarget) {
|
||||
navigateToPage(
|
||||
navTarget.pageId,
|
||||
navTarget.transitionVideoUrl,
|
||||
navTarget.isBack,
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigateToPage, pages, transitionPhase, isBuffering],
|
||||
|
||||
@ -20,3 +20,14 @@ export type {
|
||||
UsePageNavigationOptions,
|
||||
UsePageNavigationResult,
|
||||
} from './usePageNavigation';
|
||||
export { useBackgroundTransition } from './useBackgroundTransition';
|
||||
export type {
|
||||
FadeOutConfig,
|
||||
UseBackgroundTransitionOptions,
|
||||
UseBackgroundTransitionResult,
|
||||
} from './useBackgroundTransition';
|
||||
export { usePageDataLoader } from './usePageDataLoader';
|
||||
export type {
|
||||
UsePageDataLoaderOptions,
|
||||
UsePageDataLoaderResult,
|
||||
} from './usePageDataLoader';
|
||||
|
||||
164
frontend/src/hooks/useBackgroundTransition.ts
Normal file
164
frontend/src/hooks/useBackgroundTransition.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* useBackgroundTransition Hook
|
||||
*
|
||||
* Manages background transition effects when switching between pages.
|
||||
* Handles the fade-out animation of the transition video overlay and
|
||||
* coordinates with the page switch hook to clear previous backgrounds.
|
||||
*
|
||||
* This hook consolidates the background transition logic used by both
|
||||
* RuntimePresentation and constructor.tsx.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Full mode (RuntimePresentation): Fade-out animation + direct navigation clearing
|
||||
* 2. Simple mode (constructor): Direct navigation clearing only
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Fade-out configuration (optional - for RuntimePresentation)
|
||||
*/
|
||||
export interface FadeOutConfig {
|
||||
/** Whether a transition video has finished playing and is waiting for bg ready */
|
||||
pendingTransitionComplete: boolean;
|
||||
/** Whether the new background image is ready to display */
|
||||
isBackgroundReady: boolean;
|
||||
/** Ref to the transition video element for cleanup */
|
||||
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
/** Callback to clear transition state after overlay is removed */
|
||||
onTransitionCleanup: () => void;
|
||||
}
|
||||
|
||||
export interface UseBackgroundTransitionOptions {
|
||||
/** Page switch hook instance for clearing previous background */
|
||||
pageSwitch: {
|
||||
clearPreviousBackground: () => void;
|
||||
isSwitching: boolean;
|
||||
isNewBgReady: boolean;
|
||||
previousBgImageUrl: string;
|
||||
};
|
||||
/** Optional fade-out configuration (for RuntimePresentation) */
|
||||
fadeOut?: FadeOutConfig;
|
||||
}
|
||||
|
||||
export interface UseBackgroundTransitionResult {
|
||||
/** Whether the overlay is currently fading out */
|
||||
isOverlayFadingOut: boolean;
|
||||
/** Reset the fade-out state (call before starting a new transition) */
|
||||
resetFadeOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration of the fade-out animation in milliseconds
|
||||
*/
|
||||
const FADE_DURATION_MS = 300;
|
||||
|
||||
/**
|
||||
* Hook for managing background transition effects.
|
||||
*
|
||||
* @example
|
||||
* // Full mode with fade-out (RuntimePresentation)
|
||||
* const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
|
||||
* pageSwitch,
|
||||
* fadeOut: {
|
||||
* pendingTransitionComplete,
|
||||
* isBackgroundReady,
|
||||
* transitionVideoRef,
|
||||
* onTransitionCleanup: () => {
|
||||
* setTransitionPreview(null);
|
||||
* setPendingTransitionComplete(false);
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Simple mode - direct navigation only (constructor)
|
||||
* useBackgroundTransition({ pageSwitch });
|
||||
*/
|
||||
export function useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeOut,
|
||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
||||
|
||||
/**
|
||||
* Reset fade-out state before starting a new transition.
|
||||
* This prevents the fade-out effect from re-triggering when state resets.
|
||||
*/
|
||||
const resetFadeOut = useCallback(() => {
|
||||
setIsOverlayFadingOut(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Effect: Fade out and remove transition overlay when background is ready.
|
||||
* Only runs when fadeOut config is provided.
|
||||
*
|
||||
* Sequence:
|
||||
* 1. Transition video finishes playing (pendingTransitionComplete = true)
|
||||
* 2. New background image loads (isBackgroundReady = true)
|
||||
* 3. Start fade-out animation (isOverlayFadingOut = true)
|
||||
* 4. After fade completes, clean up video and clear transition state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!fadeOut) return;
|
||||
|
||||
const {
|
||||
pendingTransitionComplete,
|
||||
isBackgroundReady,
|
||||
transitionVideoRef,
|
||||
onTransitionCleanup,
|
||||
} = fadeOut;
|
||||
|
||||
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
|
||||
// Start fade-out animation
|
||||
setIsOverlayFadingOut(true);
|
||||
|
||||
// After fade completes, remove the overlay
|
||||
const fadeTimer = setTimeout(() => {
|
||||
const video = transitionVideoRef.current;
|
||||
if (video) {
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
|
||||
// Clear previous background from shared hook
|
||||
pageSwitch.clearPreviousBackground();
|
||||
|
||||
// Notify caller to clear transition state
|
||||
onTransitionCleanup();
|
||||
|
||||
// Reset fade-out state
|
||||
setIsOverlayFadingOut(false);
|
||||
}, FADE_DURATION_MS);
|
||||
|
||||
return () => clearTimeout(fadeTimer);
|
||||
}
|
||||
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
|
||||
|
||||
/**
|
||||
* Effect: Clear previous background overlay when new background is ready (direct navigation).
|
||||
*
|
||||
* This handles the case when navigating without a transition video.
|
||||
* The previous background stays visible until the new one is ready to paint.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
pageSwitch.isSwitching &&
|
||||
pageSwitch.isNewBgReady &&
|
||||
pageSwitch.previousBgImageUrl
|
||||
) {
|
||||
// New background is ready - clear the previous background overlay
|
||||
pageSwitch.clearPreviousBackground();
|
||||
}
|
||||
}, [
|
||||
pageSwitch.isSwitching,
|
||||
pageSwitch.isNewBgReady,
|
||||
pageSwitch.previousBgImageUrl,
|
||||
pageSwitch.clearPreviousBackground,
|
||||
]);
|
||||
|
||||
return {
|
||||
isOverlayFadingOut,
|
||||
resetFadeOut,
|
||||
};
|
||||
}
|
||||
253
frontend/src/hooks/usePageDataLoader.ts
Normal file
253
frontend/src/hooks/usePageDataLoader.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* usePageDataLoader Hook
|
||||
*
|
||||
* Unified hook for loading project and page data in presentation components.
|
||||
* Used by both RuntimePresentation (public) and constructor.tsx (authenticated).
|
||||
*
|
||||
* Features:
|
||||
* - Loads project by slug or ID
|
||||
* - Loads pages filtered by environment
|
||||
* - Sorts pages by sort_order
|
||||
* - Handles loading and error states
|
||||
* - Supports reloading with page preservation
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { logger } from '../lib/logger';
|
||||
import type { RuntimeProject, RuntimePage } from '../types/runtime';
|
||||
|
||||
/**
|
||||
* Configuration for the page data loader
|
||||
*/
|
||||
export interface UsePageDataLoaderOptions {
|
||||
/** Project ID for authenticated mode (constructor) */
|
||||
projectId?: string;
|
||||
/** Project slug for public mode (runtime) */
|
||||
projectSlug?: string;
|
||||
/** Environment to filter pages by */
|
||||
environment: 'dev' | 'stage' | 'production';
|
||||
/** Whether the data loading should be enabled */
|
||||
enabled?: boolean;
|
||||
/** Custom API headers (e.g., for runtime environment context) */
|
||||
apiHeaders?: Record<string, string>;
|
||||
/** Initial page ID from route (for constructor) */
|
||||
initialPageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the page data loader
|
||||
*/
|
||||
export interface UsePageDataLoaderResult {
|
||||
/** Loaded project data */
|
||||
project: RuntimeProject | null;
|
||||
/** Loaded and filtered pages */
|
||||
pages: RuntimePage[];
|
||||
/** Whether data is currently loading */
|
||||
isLoading: boolean;
|
||||
/** Error message if loading failed */
|
||||
error: string;
|
||||
/** Reload the data (optionally preserving current page selection) */
|
||||
reload: (preservePageId?: string) => Promise<void>;
|
||||
/** Initially selected page ID */
|
||||
initialPageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract rows from API response
|
||||
*/
|
||||
const getRows = (response: unknown): unknown[] => {
|
||||
const data = response as { data?: { rows?: unknown[] } };
|
||||
return Array.isArray(data?.data?.rows) ? data.data.rows : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for loading project and page data.
|
||||
*
|
||||
* @example
|
||||
* // Runtime mode (public presentation)
|
||||
* const { project, pages, isLoading, error } = usePageDataLoader({
|
||||
* projectSlug: 'my-project',
|
||||
* environment: 'production',
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Constructor mode (authenticated)
|
||||
* const { project, pages, isLoading, error, reload } = usePageDataLoader({
|
||||
* projectId: 'uuid-here',
|
||||
* environment: 'dev',
|
||||
* enabled: isAuthReady,
|
||||
* });
|
||||
*/
|
||||
export function usePageDataLoader({
|
||||
projectId,
|
||||
projectSlug,
|
||||
environment,
|
||||
enabled = true,
|
||||
apiHeaders = {},
|
||||
initialPageId: initialPageIdFromProps = '',
|
||||
}: UsePageDataLoaderOptions): UsePageDataLoaderResult {
|
||||
const [project, setProject] = useState<RuntimeProject | null>(null);
|
||||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedInitialPageId, setSelectedInitialPageId] = useState('');
|
||||
|
||||
// Memoize API config to prevent unnecessary reloads
|
||||
const apiConfig = useMemo(
|
||||
() => ({
|
||||
headers: apiHeaders,
|
||||
}),
|
||||
// Serialize headers for comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[JSON.stringify(apiHeaders)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Load project and pages data
|
||||
*/
|
||||
const loadData = useCallback(
|
||||
async (preservePageId?: string) => {
|
||||
// Need either projectId or projectSlug
|
||||
if (!projectId && !projectSlug) {
|
||||
setError('No project identifier provided.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
let foundProject: RuntimeProject | null = null;
|
||||
|
||||
// Load by ID (constructor mode)
|
||||
if (projectId) {
|
||||
const projectResponse = await axios.get(
|
||||
`/projects/${projectId}`,
|
||||
apiConfig,
|
||||
);
|
||||
foundProject = projectResponse.data;
|
||||
}
|
||||
// Load by slug (runtime mode)
|
||||
else if (projectSlug) {
|
||||
const projectsResponse = await axios.get('/projects', {
|
||||
...apiConfig,
|
||||
params: { slug: projectSlug },
|
||||
});
|
||||
|
||||
const projectRows = getRows(projectsResponse) as RuntimeProject[];
|
||||
foundProject =
|
||||
projectRows.find((p) => p.slug === projectSlug) || null;
|
||||
|
||||
if (!foundProject) {
|
||||
setError(`Project "${projectSlug}" not found.`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundProject) {
|
||||
setError('Project not found.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProject(foundProject);
|
||||
|
||||
// Load pages for this project
|
||||
const pagesParams: Record<string, string> = {
|
||||
project: foundProject.id,
|
||||
};
|
||||
|
||||
// For constructor mode, also filter by environment in params
|
||||
if (projectId) {
|
||||
pagesParams.environment = environment;
|
||||
pagesParams.limit = '500';
|
||||
pagesParams.sort = 'asc';
|
||||
pagesParams.field = 'sort_order';
|
||||
}
|
||||
|
||||
const pagesResponse = await axios.get('/tour_pages', {
|
||||
...apiConfig,
|
||||
params: pagesParams,
|
||||
});
|
||||
|
||||
let pageRows = getRows(pagesResponse) as RuntimePage[];
|
||||
|
||||
// For runtime mode, filter by environment client-side
|
||||
// (backend may not have environment header support)
|
||||
if (projectSlug) {
|
||||
pageRows = pageRows.filter((p) => p.environment === environment);
|
||||
}
|
||||
|
||||
// Sort by sort_order
|
||||
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
|
||||
setPages(pageRows);
|
||||
|
||||
// Determine initial page
|
||||
const preservedPageExists =
|
||||
preservePageId && pageRows.some((p) => p.id === preservePageId);
|
||||
const defaultPageId = preservedPageExists
|
||||
? preservePageId
|
||||
: initialPageIdFromProps ||
|
||||
(pageRows.length > 0 ? pageRows[0].id : '');
|
||||
|
||||
setSelectedInitialPageId(defaultPageId);
|
||||
} catch (err: unknown) {
|
||||
const axiosError = err as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// Handle authentication errors
|
||||
if (axiosError?.response?.status === 401) {
|
||||
setError('Your session has expired. Please sign in again.');
|
||||
logger.error('Unauthorized request during data load');
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
axiosError?.response?.data?.message ||
|
||||
axiosError?.message ||
|
||||
'Failed to load presentation data.';
|
||||
|
||||
logger.error(
|
||||
'Failed to load page data:',
|
||||
err instanceof Error ? err : { error: err },
|
||||
);
|
||||
setError(message);
|
||||
setPages([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
projectSlug,
|
||||
environment,
|
||||
enabled,
|
||||
apiConfig,
|
||||
initialPageIdFromProps,
|
||||
],
|
||||
);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return {
|
||||
project,
|
||||
pages,
|
||||
isLoading,
|
||||
error,
|
||||
reload: loadData,
|
||||
initialPageId: selectedInitialPageId,
|
||||
};
|
||||
}
|
||||
@ -611,6 +611,12 @@ export function usePreloadOrchestrator(
|
||||
) {
|
||||
storagePaths.push(currentPage.background_video_url);
|
||||
}
|
||||
if (
|
||||
currentPage?.background_audio_url &&
|
||||
isRelativeStoragePath(currentPage.background_audio_url)
|
||||
) {
|
||||
storagePaths.push(currentPage.background_audio_url);
|
||||
}
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (isRelativeStoragePath(asset.url)) {
|
||||
@ -618,18 +624,32 @@ export function usePreloadOrchestrator(
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldPreloadAggressively) {
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (
|
||||
page?.background_image_url &&
|
||||
isRelativeStoragePath(page.background_image_url)
|
||||
) {
|
||||
storagePaths.push(page.background_image_url);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Always collect neighbor background URLs for presigning
|
||||
// This ensures instant navigation to neighbor pages
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (
|
||||
page?.background_image_url &&
|
||||
isRelativeStoragePath(page.background_image_url)
|
||||
) {
|
||||
storagePaths.push(page.background_image_url);
|
||||
}
|
||||
// Always collect neighbor video URLs for smooth transitions
|
||||
if (
|
||||
page?.background_video_url &&
|
||||
isRelativeStoragePath(page.background_video_url)
|
||||
) {
|
||||
storagePaths.push(page.background_video_url);
|
||||
}
|
||||
// Also collect neighbor audio URLs
|
||||
if (
|
||||
page?.background_audio_url &&
|
||||
isRelativeStoragePath(page.background_audio_url)
|
||||
) {
|
||||
storagePaths.push(page.background_audio_url);
|
||||
}
|
||||
});
|
||||
|
||||
// Batch fetch presigned URLs, then add to queue
|
||||
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
|
||||
@ -679,6 +699,22 @@ export function usePreloadOrchestrator(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (currentPage?.background_audio_url) {
|
||||
const storageKey = currentPage.background_audio_url;
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-aud-${currentPageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.currentPage + 100,
|
||||
assetType: 'audio',
|
||||
pageId: currentPageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add element assets
|
||||
assets.forEach((asset) => {
|
||||
@ -698,29 +734,63 @@ export function usePreloadOrchestrator(
|
||||
}
|
||||
});
|
||||
|
||||
// If aggressive preloading, also preload neighbor backgrounds
|
||||
if (shouldPreloadAggressively) {
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page?.background_image_url) {
|
||||
const storageKey = page.background_image_url;
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-img-${pageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.neighborBase,
|
||||
assetType: 'image',
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
// Always preload immediate neighbor backgrounds for smooth navigation
|
||||
// This is critical for instant page switches without white flash
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page?.background_image_url) {
|
||||
const storageKey = page.background_image_url;
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-img-${pageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
// Neighbor backgrounds get high priority (just below current page)
|
||||
priority: PRELOAD_CONFIG.priority.neighborBase + 100,
|
||||
assetType: 'image',
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Always preload neighbor videos for smooth page transitions
|
||||
if (page?.background_video_url) {
|
||||
const storageKey = page.background_video_url;
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-vid-${pageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.neighborBase + 50,
|
||||
assetType: 'video',
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Always preload neighbor audio for seamless playback
|
||||
if (page?.background_audio_url) {
|
||||
const storageKey = page.background_audio_url;
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-aud-${pageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.neighborBase + 30,
|
||||
assetType: 'audio',
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// If there are storage paths to presign, fetch them first
|
||||
@ -755,7 +825,6 @@ export function usePreloadOrchestrator(
|
||||
pages,
|
||||
pageLinks,
|
||||
addToQueue,
|
||||
shouldPreloadAggressively,
|
||||
maxNeighborDepth,
|
||||
]);
|
||||
|
||||
|
||||
158
frontend/src/lib/mediaHelpers.ts
Normal file
158
frontend/src/lib/mediaHelpers.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Media Helpers
|
||||
*
|
||||
* Utilities for media duration probing and formatting.
|
||||
* Used by constructor.tsx for displaying video/audio duration info.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { resolveAssetPlaybackUrl } from './assetUrl';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human-readable string.
|
||||
*
|
||||
* @param durationSec - Duration in seconds (or null/undefined)
|
||||
* @returns Formatted string like "Duration: 1:30" or "Duration: unknown"
|
||||
*
|
||||
* @example
|
||||
* formatDurationNote(90); // "Duration: 1:30"
|
||||
* formatDurationNote(45); // "Duration: 45s"
|
||||
* formatDurationNote(null); // "Duration: unknown"
|
||||
*/
|
||||
export const formatDurationNote = (
|
||||
durationSec?: number | string | null,
|
||||
): string => {
|
||||
const parsed = Number(durationSec);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
||||
|
||||
const totalSeconds = Math.round(parsed);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) return `Duration: ${seconds}s`;
|
||||
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read media duration from a URL by loading metadata.
|
||||
*
|
||||
* Creates a temporary video/audio element to probe the duration.
|
||||
* Times out after 12 seconds if metadata doesn't load.
|
||||
*
|
||||
* @param playbackUrl - URL to the media file
|
||||
* @param mediaType - Type of media ('video' or 'audio')
|
||||
* @returns Promise resolving to duration in seconds, or null if failed
|
||||
*
|
||||
* @example
|
||||
* const duration = await readMediaDuration('https://example.com/video.mp4', 'video');
|
||||
*/
|
||||
export const readMediaDuration = (
|
||||
playbackUrl: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
): Promise<number | null> =>
|
||||
new Promise((resolve) => {
|
||||
const mediaElement =
|
||||
mediaType === 'video'
|
||||
? document.createElement('video')
|
||||
: document.createElement('audio');
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.removeEventListener('error', onError);
|
||||
mediaElement.removeEventListener('abort', onError);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
mediaElement.pause();
|
||||
mediaElement.removeAttribute('src');
|
||||
mediaElement.load();
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const duration = Number(mediaElement.duration);
|
||||
cleanup();
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
resolve(duration);
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, 12000);
|
||||
|
||||
mediaElement.preload = 'metadata';
|
||||
mediaElement.crossOrigin = 'anonymous';
|
||||
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.addEventListener('error', onError);
|
||||
mediaElement.addEventListener('abort', onError);
|
||||
mediaElement.src = playbackUrl;
|
||||
mediaElement.load();
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve media duration with blob fallback.
|
||||
*
|
||||
* First tries to load duration directly from the URL.
|
||||
* If that fails (e.g., CORS issues), downloads as blob and retries.
|
||||
*
|
||||
* @param source - Storage path or URL to the media
|
||||
* @param mediaType - Type of media ('video' or 'audio')
|
||||
* @returns Promise resolving to duration in seconds, or null if failed
|
||||
*
|
||||
* @example
|
||||
* const duration = await resolveDurationWithFallback('assets/video.mp4', 'video');
|
||||
*/
|
||||
export const resolveDurationWithFallback = async (
|
||||
source: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
): Promise<number | null> => {
|
||||
const playbackUrl = resolveAssetPlaybackUrl(source);
|
||||
if (!playbackUrl) return null;
|
||||
|
||||
// Try direct metadata loading first
|
||||
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
||||
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
||||
return Number(directDuration);
|
||||
}
|
||||
|
||||
// Fallback: download as blob and probe
|
||||
try {
|
||||
const requestUrl =
|
||||
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
|
||||
? playbackUrl
|
||||
: playbackUrl.replace(/^\/api(?=\/)/, '');
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
|
||||
const response = await axios.get(requestUrl, {
|
||||
responseType: 'blob',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
try {
|
||||
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
||||
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
||||
return Number(blobDuration);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to fetch media for duration probing:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
136
frontend/src/lib/navigationHelpers.ts
Normal file
136
frontend/src/lib/navigationHelpers.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Navigation Helpers
|
||||
*
|
||||
* Shared utilities for page navigation in RuntimePresentation and constructor.tsx.
|
||||
* Handles target page resolution, back navigation detection, and transition blocking.
|
||||
*/
|
||||
|
||||
import type { RuntimePage } from '../types/runtime';
|
||||
import type {
|
||||
NavigableElement,
|
||||
NavigationTarget,
|
||||
TransitionPhase,
|
||||
} from '../types/presentation';
|
||||
|
||||
/**
|
||||
* Resolve target page from element navigation properties.
|
||||
* Supports both targetPageSlug (new) and targetPageId (legacy).
|
||||
*
|
||||
* @param element - Element with navigation properties
|
||||
* @param pages - Available pages to search
|
||||
* @returns The target page or undefined if not found
|
||||
*/
|
||||
export const resolveNavigationTarget = (
|
||||
element: NavigableElement,
|
||||
pages: RuntimePage[],
|
||||
): NavigationTarget | null => {
|
||||
const targetPageSlug = element.targetPageSlug;
|
||||
const legacyTargetPageId = element.targetPageId;
|
||||
|
||||
let targetPage: RuntimePage | undefined;
|
||||
|
||||
if (targetPageSlug) {
|
||||
targetPage = pages.find((p) => p.slug === targetPageSlug);
|
||||
} else if (legacyTargetPageId) {
|
||||
targetPage = pages.find((p) => p.id === legacyTargetPageId);
|
||||
}
|
||||
|
||||
if (!targetPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBack = isBackNavigation(element);
|
||||
|
||||
return {
|
||||
page: targetPage,
|
||||
pageId: targetPage.id,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
isBack,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if navigation direction is "back".
|
||||
* Elements with navType='back' or type='navigation_prev' navigate backwards.
|
||||
*
|
||||
* @param element - Element to check
|
||||
* @returns true if this is a back navigation
|
||||
*/
|
||||
export const isBackNavigation = (element: NavigableElement): boolean => {
|
||||
return element.navType === 'back' || element.type === 'navigation_prev';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get navigation direction based on element properties.
|
||||
*
|
||||
* @param element - Element with navigation properties
|
||||
* @returns 'back' or 'forward'
|
||||
*/
|
||||
export const getNavigationDirection = (
|
||||
element: NavigableElement,
|
||||
): 'back' | 'forward' => {
|
||||
return isBackNavigation(element) ? 'back' : 'forward';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if transition is actively blocking navigation.
|
||||
* Navigation should be blocked during preparing, playing, or reversing phases.
|
||||
*
|
||||
* @param transitionPhase - Current transition phase
|
||||
* @param isBuffering - Whether video is buffering
|
||||
* @returns true if navigation should be blocked
|
||||
*/
|
||||
export const isTransitionBlocking = (
|
||||
transitionPhase: TransitionPhase,
|
||||
isBuffering: boolean,
|
||||
): boolean => {
|
||||
const activePhases: TransitionPhase[] = ['preparing', 'playing', 'reversing'];
|
||||
return activePhases.includes(transitionPhase) || isBuffering;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if element has a playable transition.
|
||||
* A transition is playable if it has a video URL, and for back navigation,
|
||||
* either supports reverse or has a separate reverse video.
|
||||
*
|
||||
* @param element - Element with transition properties
|
||||
* @param direction - Navigation direction
|
||||
* @returns true if element has a playable transition
|
||||
*/
|
||||
export const hasPlayableTransition = (
|
||||
element: {
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: string;
|
||||
reverseVideoUrl?: string;
|
||||
},
|
||||
direction: 'back' | 'forward' = 'forward',
|
||||
): boolean => {
|
||||
if (!element.transitionVideoUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For back navigation with separate_video mode, need reverse video
|
||||
if (
|
||||
direction === 'back' &&
|
||||
element.transitionReverseMode === 'separate_video' &&
|
||||
!element.reverseVideoUrl
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if element is a navigation element type.
|
||||
*
|
||||
* @param elementType - Element type to check
|
||||
* @returns true if element is a navigation type
|
||||
*/
|
||||
export const isNavigationType = (elementType: string): boolean => {
|
||||
return (
|
||||
elementType === 'navigation_next' ||
|
||||
elementType === 'navigation_prev' ||
|
||||
elementType === 'navigation'
|
||||
);
|
||||
};
|
||||
@ -37,10 +37,20 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { logger } from '../lib/logger';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { parseJsonObject } from '../lib/parseJson';
|
||||
import { buildElementStyle } from '../lib/elementStyles';
|
||||
import {
|
||||
resolveNavigationTarget,
|
||||
hasPlayableTransition,
|
||||
getNavigationDirection,
|
||||
} from '../lib/navigationHelpers';
|
||||
import {
|
||||
formatDurationNote,
|
||||
resolveDurationWithFallback,
|
||||
} from '../lib/mediaHelpers';
|
||||
import {
|
||||
createDefaultElement,
|
||||
mergeElementWithDefaults,
|
||||
@ -142,113 +152,6 @@ const getAssetLabel = (asset: ProjectAsset) => {
|
||||
const getAssetSourceValue = (asset: ProjectAsset) =>
|
||||
String(asset.storage_key || asset.cdn_url || '').trim();
|
||||
|
||||
const formatDurationNote = (durationSec?: number | string | null) => {
|
||||
const parsed = Number(durationSec);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
|
||||
|
||||
const totalSeconds = Math.round(parsed);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) return `Duration: ${seconds}s`;
|
||||
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const readMediaDuration = (
|
||||
playbackUrl: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
): Promise<number | null> =>
|
||||
new Promise((resolve) => {
|
||||
const mediaElement =
|
||||
mediaType === 'video'
|
||||
? document.createElement('video')
|
||||
: document.createElement('audio');
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.removeEventListener('error', onError);
|
||||
mediaElement.removeEventListener('abort', onError);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
mediaElement.pause();
|
||||
mediaElement.removeAttribute('src');
|
||||
mediaElement.load();
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const duration = Number(mediaElement.duration);
|
||||
cleanup();
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
resolve(duration);
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, 12000);
|
||||
|
||||
mediaElement.preload = 'metadata';
|
||||
mediaElement.crossOrigin = 'anonymous';
|
||||
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.addEventListener('error', onError);
|
||||
mediaElement.addEventListener('abort', onError);
|
||||
mediaElement.src = playbackUrl;
|
||||
mediaElement.load();
|
||||
});
|
||||
|
||||
const resolveDurationWithFallback = async (
|
||||
source: string,
|
||||
mediaType: 'video' | 'audio',
|
||||
) => {
|
||||
const playbackUrl = resolveAssetPlaybackUrl(source);
|
||||
if (!playbackUrl) return null;
|
||||
|
||||
const directDuration = await readMediaDuration(playbackUrl, mediaType);
|
||||
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
|
||||
return Number(directDuration);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestUrl =
|
||||
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
|
||||
? playbackUrl
|
||||
: playbackUrl.replace(/^\/api(?=\/)/, '');
|
||||
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
|
||||
const response = await axios.get(requestUrl, {
|
||||
responseType: 'blob',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
try {
|
||||
const blobDuration = await readMediaDuration(blobUrl, mediaType);
|
||||
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
|
||||
return Number(blobDuration);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to fetch media for duration probing:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
||||
if (asset.type) return asset.type === 'background_image';
|
||||
const normalizedName = String(asset.name || '').toLowerCase();
|
||||
@ -531,21 +434,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Clear previous background overlay when new background is ready (direct navigation)
|
||||
useEffect(() => {
|
||||
if (
|
||||
pageSwitch.isSwitching &&
|
||||
pageSwitch.isNewBgReady &&
|
||||
pageSwitch.previousBgImageUrl
|
||||
) {
|
||||
pageSwitch.clearPreviousBackground();
|
||||
}
|
||||
}, [
|
||||
pageSwitch.isSwitching,
|
||||
pageSwitch.isNewBgReady,
|
||||
pageSwitch.previousBgImageUrl,
|
||||
pageSwitch.clearPreviousBackground,
|
||||
]);
|
||||
// Use shared background transition hook for direct navigation clearing
|
||||
// (No fade-out needed in constructor - transitions complete immediately)
|
||||
useBackgroundTransition({ pageSwitch });
|
||||
|
||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||
@ -1856,42 +1747,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
if (element.navDisabled) {
|
||||
return;
|
||||
}
|
||||
const direction =
|
||||
element.navType === 'back' || element.type === 'navigation_prev'
|
||||
? 'back'
|
||||
: 'forward';
|
||||
// Support both targetPageSlug (new) and targetPageId (legacy)
|
||||
const targetPageSlug = String(element.targetPageSlug || '').trim();
|
||||
const legacyTargetPageId = String(element.targetPageId || '').trim();
|
||||
|
||||
// Resolve slug to page ID, or use legacy targetPageId
|
||||
const targetPage = targetPageSlug
|
||||
? pages.find((p) => p.slug === targetPageSlug)
|
||||
: legacyTargetPageId
|
||||
? pages.find((p) => p.id === legacyTargetPageId)
|
||||
: null;
|
||||
const targetPageId = targetPage?.id || '';
|
||||
// Use shared navigation helpers
|
||||
const direction = getNavigationDirection(element);
|
||||
const navTarget = resolveNavigationTarget(element, pages);
|
||||
|
||||
if (!targetPageId) {
|
||||
if (!navTarget) {
|
||||
setErrorMessage(
|
||||
'No target page configured for this navigation button.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPlayableTransition =
|
||||
Boolean(element.transitionVideoUrl) &&
|
||||
!(
|
||||
direction === 'back' &&
|
||||
element.transitionReverseMode === 'separate_video' &&
|
||||
!element.reverseVideoUrl
|
||||
);
|
||||
|
||||
if (!hasPlayableTransition) {
|
||||
// Check if transition can be played using shared helper
|
||||
if (!hasPlayableTransition(element, direction)) {
|
||||
setPendingNavigationPageId('');
|
||||
setTransitionPreview(null);
|
||||
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
||||
switchToPage(targetPage).then(() => {
|
||||
switchToPage(navTarget.page).then(() => {
|
||||
setSelectedElementId('');
|
||||
setSelectedMenuItem('none');
|
||||
setErrorMessage('');
|
||||
@ -1899,7 +1772,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingNavigationPageId(targetPageId);
|
||||
setPendingNavigationPageId(navTarget.pageId);
|
||||
openTransitionPreviewForElement(element, direction);
|
||||
}
|
||||
return;
|
||||
|
||||
@ -11,6 +11,7 @@ export interface PreloadPage {
|
||||
id: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
107
frontend/src/types/presentation.ts
Normal file
107
frontend/src/types/presentation.ts
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Presentation Types
|
||||
*
|
||||
* Shared types for RuntimePresentation and constructor.tsx components.
|
||||
* These types facilitate code sharing between the two main presentation components.
|
||||
*/
|
||||
|
||||
import type { RuntimePage } from './runtime';
|
||||
|
||||
/**
|
||||
* Transition preview state for video transitions
|
||||
*/
|
||||
export interface TransitionPreviewState {
|
||||
targetPageId: string;
|
||||
videoUrl: string;
|
||||
storageKey: string;
|
||||
isReverse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background state for page display
|
||||
*/
|
||||
export interface BackgroundState {
|
||||
imageUrl: string;
|
||||
videoUrl: string;
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation target resolved from element click
|
||||
*/
|
||||
export interface NavigationTarget {
|
||||
page: RuntimePage;
|
||||
pageId: string;
|
||||
transitionVideoUrl?: string;
|
||||
isBack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API configuration for runtime requests
|
||||
*/
|
||||
export interface RuntimeApiConfig {
|
||||
headers: {
|
||||
'X-Runtime-Project-Slug'?: string;
|
||||
'X-Runtime-Environment'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Page data loader result
|
||||
*/
|
||||
export interface PageDataLoaderResult {
|
||||
project: RuntimeProject | null;
|
||||
pages: RuntimePage[];
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
reload: (preservePageId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime project for page data loader
|
||||
*/
|
||||
export interface RuntimeProject {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas element with navigation properties (for click handling)
|
||||
*/
|
||||
export interface NavigableElement {
|
||||
id: string;
|
||||
type: string;
|
||||
targetPageSlug?: string;
|
||||
targetPageId?: string;
|
||||
transitionVideoUrl?: string;
|
||||
navType?: 'forward' | 'back';
|
||||
navDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition phase from useTransitionPlayback
|
||||
*/
|
||||
export type TransitionPhase =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'playing'
|
||||
| 'reversing'
|
||||
| 'completed';
|
||||
|
||||
/**
|
||||
* Background transition options
|
||||
*/
|
||||
export interface BackgroundTransitionOptions {
|
||||
pendingTransitionComplete: boolean;
|
||||
isBackgroundReady: boolean;
|
||||
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
pageSwitch: {
|
||||
clearPreviousBackground: () => void;
|
||||
isSwitching: boolean;
|
||||
isNewBgReady: boolean;
|
||||
previousBgImageUrl: string;
|
||||
};
|
||||
onCleanup: () => void;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user