704 lines
24 KiB
TypeScript
704 lines
24 KiB
TypeScript
/**
|
|
* RuntimePresentation Component
|
|
*
|
|
* Renders a presentation for a specific project and environment.
|
|
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
|
|
*/
|
|
|
|
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
|
import Head from 'next/head';
|
|
import Image from 'next/image';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import BaseButton from './BaseButton';
|
|
import CardBox from './CardBox';
|
|
import { OfflineToggle } from './Offline/OfflineToggle';
|
|
import RuntimeElement from './RuntimeElement';
|
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
|
import { BackdropPortalProvider } from './BackdropPortal';
|
|
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 {
|
|
extractPageLinksOnly,
|
|
extractElementsForPages,
|
|
} from '../lib/extractPageLinks';
|
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
|
import { useBackgroundVideoPlayback } from '../hooks/useBackgroundVideoPlayback';
|
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|
import { logger } from '../lib/logger';
|
|
import {
|
|
resolveNavigationTarget,
|
|
isTransitionBlocking,
|
|
} from '../lib/navigationHelpers';
|
|
import type { TransitionPhase } from '../types/presentation';
|
|
import type { CanvasElement } from '../types/constructor';
|
|
|
|
interface RuntimePresentationProps {
|
|
projectSlug: string;
|
|
environment: 'stage' | 'production';
|
|
}
|
|
|
|
export default function RuntimePresentation({
|
|
projectSlug,
|
|
environment,
|
|
}: RuntimePresentationProps) {
|
|
// 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,
|
|
},
|
|
},
|
|
);
|
|
|
|
// Resolve project assets (favicon, og_image, logo) to presigned URLs
|
|
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
|
|
|
// 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;
|
|
storageKey: string;
|
|
isReverse: boolean;
|
|
} | null>(null);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
|
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
|
useState(false);
|
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
|
element: CanvasElement;
|
|
initialIndex: number;
|
|
} | null>(null);
|
|
|
|
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
|
|
|
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
|
|
|
|
// Phase 1: Extract pageLinks from ALL pages (needed for navigation graph)
|
|
// This is lightweight - only extracts navigation structure, not asset URLs
|
|
const pageLinks = useMemo(() => {
|
|
const links = extractPageLinksOnly(pages);
|
|
if (links.length > 0) {
|
|
logger.info('[PRELOAD] Extracted page links', {
|
|
count: links.length,
|
|
links: links.map((link) => ({
|
|
from: link.from_pageId?.slice(-8),
|
|
to: link.to_pageId?.slice(-8),
|
|
hasTransition: !!link.transition?.video_url,
|
|
})),
|
|
});
|
|
}
|
|
return links;
|
|
}, [pages]);
|
|
|
|
// Phase 2: Extract elements only for current + neighbor pages (progressive)
|
|
// This avoids parsing ui_schema_json for all pages upfront
|
|
const preloadElements = useMemo(() => {
|
|
if (!selectedPageId || pages.length === 0) return [];
|
|
|
|
// Build simple neighbor set from pageLinks
|
|
const neighborIds = new Set<string>();
|
|
neighborIds.add(selectedPageId); // Current page
|
|
pageLinks.forEach((link) => {
|
|
if (link.from_pageId === selectedPageId && link.to_pageId) {
|
|
neighborIds.add(link.to_pageId); // Direct neighbors
|
|
}
|
|
});
|
|
|
|
// Extract elements only for current + neighbors
|
|
const elements = extractElementsForPages(pages, Array.from(neighborIds));
|
|
logger.info('[PRELOAD] Extracted elements for pages', {
|
|
currentPage: selectedPageId.slice(-8),
|
|
pageCount: neighborIds.size,
|
|
elementCount: elements.length,
|
|
});
|
|
return elements;
|
|
}, [pages, pageLinks, selectedPageId]);
|
|
|
|
// Initialize preload orchestrator with transformed data
|
|
const preloadOrchestrator = usePreloadOrchestrator({
|
|
pages,
|
|
pageLinks,
|
|
elements: preloadElements,
|
|
currentPageId: selectedPageId,
|
|
pageHistory,
|
|
enabled: !isLoading && !error,
|
|
});
|
|
|
|
// Initialize page switch hook for smooth background transitions
|
|
const pageSwitch = usePageSwitch({
|
|
preloadCache: preloadOrchestrator
|
|
? {
|
|
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
|
|
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
|
|
videoRef: transitionVideoRef,
|
|
transition: transitionPreview
|
|
? {
|
|
videoUrl: transitionPreview.videoUrl,
|
|
storageKey: transitionPreview.storageKey,
|
|
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
|
|
targetPageId: transitionPreview.targetPageId,
|
|
displayName: 'Transition',
|
|
isBack: transitionPreview.isReverse, // Pass through for history management
|
|
}
|
|
: null,
|
|
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, () => {
|
|
// 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
|
|
setPendingTransitionComplete(true);
|
|
} else {
|
|
// No target page - clean up and remove overlay
|
|
const video = transitionVideoRef.current;
|
|
video?.removeAttribute('src');
|
|
video?.load();
|
|
setTransitionPreview(null);
|
|
setPendingTransitionComplete(false);
|
|
}
|
|
},
|
|
features: {
|
|
useBlobUrl: true,
|
|
// Don't pre-decode images in the hook - we handle it via overlay:
|
|
// Overlay shows last transition frame while new page background loads behind it
|
|
preDecodeImages: false,
|
|
},
|
|
preload: {
|
|
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
|
|
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
|
|
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
|
|
},
|
|
});
|
|
|
|
// 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) {
|
|
await document.documentElement.requestFullscreen();
|
|
setIsFullscreen(true);
|
|
} else {
|
|
await document.exitFullscreen();
|
|
setIsFullscreen(false);
|
|
}
|
|
} catch (err) {
|
|
logger.error(
|
|
'Fullscreen toggle failed:',
|
|
err instanceof Error ? err : { error: err },
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsFullscreen(Boolean(document.fullscreenElement));
|
|
};
|
|
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
return () =>
|
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
}, []);
|
|
|
|
const selectedPage = useMemo(
|
|
() => pages.find((p) => p.id === selectedPageId) || null,
|
|
[pages, selectedPageId],
|
|
);
|
|
|
|
const pageElements = useMemo(() => {
|
|
if (!selectedPage) return [];
|
|
|
|
try {
|
|
const uiSchema =
|
|
typeof selectedPage.ui_schema_json === 'string'
|
|
? JSON.parse(selectedPage.ui_schema_json)
|
|
: selectedPage.ui_schema_json;
|
|
|
|
return Array.isArray(uiSchema?.elements) ? uiSchema.elements : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, [selectedPage]);
|
|
|
|
// Set initial backgrounds when page first loads (before preload cache is populated)
|
|
// The condition ensures this only runs once on initial load when backgrounds are empty.
|
|
// After that, navigateToPage handles all subsequent navigation explicitly.
|
|
useEffect(() => {
|
|
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
|
|
// Only initialize when backgrounds are empty (initial load)
|
|
// navigateToPage handles subsequent navigation by calling switchToPage directly
|
|
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
|
|
lastInitializedPageIdRef.current = selectedPage.id;
|
|
pageSwitch.switchToPage(selectedPage);
|
|
}
|
|
}
|
|
}, [
|
|
selectedPage,
|
|
pageSwitch.currentBgImageUrl,
|
|
pageSwitch.currentBgVideoUrl,
|
|
pageSwitch.switchToPage,
|
|
]);
|
|
|
|
// Handle background ready state for pages without images or with videos
|
|
useEffect(() => {
|
|
// If no background image, or if there's a video (video takes over), mark as ready
|
|
if (
|
|
!selectedPage?.background_image_url ||
|
|
selectedPage?.background_video_url
|
|
) {
|
|
setIsBackgroundReady(true);
|
|
}
|
|
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
|
|
|
const navigateToPage = useCallback(
|
|
async (
|
|
targetPageId: string,
|
|
transitionVideoUrl?: string,
|
|
isBack = false,
|
|
) => {
|
|
const targetPage = pages.find((p) => p.id === targetPageId);
|
|
if (!targetPage) return;
|
|
|
|
if (transitionVideoUrl) {
|
|
// Reset states from previous transition before starting new one
|
|
// This prevents the fade-out effect from re-triggering
|
|
resetFadeOut();
|
|
setPendingTransitionComplete(false);
|
|
// Play transition using useTransitionPlayback hook
|
|
setTransitionPreview({
|
|
targetPageId,
|
|
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
|
|
storageKey: transitionVideoUrl, // Raw storage path for cache lookup
|
|
isReverse: isBack,
|
|
});
|
|
} else {
|
|
// Direct navigation - use shared hook for smooth transition
|
|
// Previous background stays visible until new one is ready
|
|
setIsBackgroundReady(false);
|
|
// Mark this page as initialized to prevent redundant effect calls
|
|
lastInitializedPageIdRef.current = targetPageId;
|
|
|
|
await pageSwitch.switchToPage(targetPage, () => {
|
|
// Use applyPageSelection for proper history management (pops on back)
|
|
applyPageSelection(targetPageId, isBack);
|
|
});
|
|
}
|
|
},
|
|
[pages, pageSwitch, resetFadeOut, applyPageSelection],
|
|
);
|
|
|
|
const handleElementClick = useCallback(
|
|
(element: CanvasElement) => {
|
|
// Block navigation while transition is actively playing or buffering
|
|
if (
|
|
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// 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', {
|
|
elementType: element.type,
|
|
targetPageSlug: element.targetPageSlug,
|
|
legacyTargetPageId: element.targetPageId,
|
|
resolvedTargetPageId: navTarget?.pageId,
|
|
transitionVideoUrl: element.transitionVideoUrl,
|
|
hasTransition: Boolean(element.transitionVideoUrl),
|
|
navBackMode: element.navBackMode,
|
|
previousPageId: navContext.previousPageId,
|
|
});
|
|
|
|
if (navTarget) {
|
|
navigateToPage(
|
|
navTarget.pageId,
|
|
navTarget.transitionVideoUrl,
|
|
navTarget.isBack,
|
|
);
|
|
}
|
|
},
|
|
[navigateToPage, pages, transitionPhase, isBuffering, getNavigationContext],
|
|
);
|
|
|
|
// Handler for gallery card clicks
|
|
const handleGalleryCardClick = useCallback(
|
|
(element: CanvasElement, cardIndex: number) => {
|
|
if (element.galleryCards?.length > 0) {
|
|
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// URL resolver that uses preloaded blob URLs when available (instant display)
|
|
const resolveUrlWithBlob = useCallback(
|
|
(url: string | undefined): string => {
|
|
if (!url) return '';
|
|
|
|
// Try to get blob URL from preload orchestrator (instant display)
|
|
// Check storage key first (most reliable), then resolved URL
|
|
const blobUrl =
|
|
preloadOrchestrator?.getReadyBlobUrl(url) ||
|
|
preloadOrchestrator?.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
|
|
if (blobUrl) return blobUrl;
|
|
|
|
// Fall back to standard resolution
|
|
return resolveAssetPlaybackUrl(url);
|
|
},
|
|
[preloadOrchestrator],
|
|
);
|
|
|
|
// Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
|
|
// Blob URLs render instantly since data is local in memory
|
|
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
|
|
const backgroundVideoUrl = pageSwitch.currentBgVideoUrl;
|
|
|
|
// Background video playback settings from selected page
|
|
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
|
|
const videoLoop = selectedPage?.background_video_loop ?? true;
|
|
const videoMuted = selectedPage?.background_video_muted ?? true;
|
|
const videoStartTime =
|
|
selectedPage?.background_video_start_time != null
|
|
? parseFloat(String(selectedPage.background_video_start_time))
|
|
: null;
|
|
const videoEndTime =
|
|
selectedPage?.background_video_end_time != null
|
|
? parseFloat(String(selectedPage.background_video_end_time))
|
|
: null;
|
|
|
|
// Use background video playback hook for custom start/end time handling
|
|
const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({
|
|
videoUrl: backgroundVideoUrl,
|
|
autoplay: videoAutoplay,
|
|
loop: videoLoop,
|
|
muted: videoMuted,
|
|
startTime: videoStartTime,
|
|
endTime: videoEndTime,
|
|
});
|
|
|
|
// When endTime is set, we disable native loop and handle it via the hook
|
|
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
|
<div className='text-white text-xl'>Loading presentation...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
|
<CardBox className='max-w-md'>
|
|
<h2 className='text-xl font-bold text-red-500 mb-4'>Error</h2>
|
|
<p className='text-gray-300'>{error}</p>
|
|
</CardBox>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{project?.name || 'Presentation'}</title>
|
|
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
|
|
{ogImageUrl && (
|
|
<>
|
|
<meta key='og:image' property='og:image' content={ogImageUrl} />
|
|
<meta
|
|
key='twitter:image:src'
|
|
property='twitter:image:src'
|
|
content={ogImageUrl}
|
|
/>
|
|
</>
|
|
)}
|
|
{project?.name && (
|
|
<>
|
|
<meta key='og:title' property='og:title' content={project.name} />
|
|
<meta
|
|
key='twitter:title'
|
|
property='twitter:title'
|
|
content={project.name}
|
|
/>
|
|
</>
|
|
)}
|
|
{project?.description && (
|
|
<>
|
|
<meta
|
|
key='og:description'
|
|
property='og:description'
|
|
content={project.description}
|
|
/>
|
|
<meta
|
|
key='twitter:description'
|
|
property='twitter:description'
|
|
content={project.description}
|
|
/>
|
|
</>
|
|
)}
|
|
</Head>
|
|
|
|
<div
|
|
className='relative w-screen h-screen overflow-clip bg-black'
|
|
style={{
|
|
backgroundImage: backgroundImageUrl
|
|
? `url("${backgroundImageUrl}")`
|
|
: undefined,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
>
|
|
<BackdropPortalProvider>
|
|
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
|
|
CSS backgroundImage provides instant display.
|
|
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
|
|
{backgroundImageUrl && !backgroundVideoUrl && (
|
|
<div className='absolute inset-0 z-1 pointer-events-none'>
|
|
{backgroundImageUrl.startsWith('blob:') ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
key={backgroundImageUrl}
|
|
src={backgroundImageUrl}
|
|
alt=''
|
|
className='absolute inset-0 w-full h-full object-cover'
|
|
onLoad={() => {
|
|
setIsBackgroundReady(true);
|
|
pageSwitch.markBackgroundReady();
|
|
}}
|
|
onError={() => {
|
|
setIsBackgroundReady(true);
|
|
pageSwitch.markBackgroundReady();
|
|
}}
|
|
/>
|
|
) : (
|
|
<Image
|
|
key={backgroundImageUrl}
|
|
src={backgroundImageUrl}
|
|
alt=''
|
|
fill
|
|
sizes='100vw'
|
|
className='object-cover'
|
|
priority
|
|
unoptimized
|
|
onLoad={() => {
|
|
setIsBackgroundReady(true);
|
|
pageSwitch.markBackgroundReady();
|
|
}}
|
|
onError={() => {
|
|
setIsBackgroundReady(true);
|
|
pageSwitch.markBackgroundReady();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
|
|
{pageSwitch.previousBgImageUrl &&
|
|
pageSwitch.isSwitching &&
|
|
!pageSwitch.isNewBgReady && (
|
|
<div
|
|
className='absolute inset-0 pointer-events-none z-10'
|
|
style={{
|
|
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
|
{backgroundVideoUrl && (
|
|
<video
|
|
ref={bgVideoRef}
|
|
key={backgroundVideoUrl}
|
|
className='absolute inset-0 z-1 h-full w-full object-cover'
|
|
src={backgroundVideoUrl}
|
|
autoPlay={videoAutoplay}
|
|
loop={useNativeLoop}
|
|
muted={videoMuted}
|
|
playsInline
|
|
/>
|
|
)}
|
|
|
|
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
|
|
<div className='absolute inset-0 z-40'>
|
|
{pageElements.map((element: CanvasElement) => (
|
|
<RuntimeElement
|
|
key={element.id}
|
|
element={element}
|
|
onClick={() => handleElementClick(element)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
onGalleryCardClick={(cardIndex) =>
|
|
handleGalleryCardClick(element, cardIndex)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Controls: Offline toggle and Fullscreen button */}
|
|
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
|
<OfflineToggle
|
|
projectId={project?.id || null}
|
|
projectSlug={projectSlug}
|
|
projectName={project?.name}
|
|
pages={pages}
|
|
showLabel={false}
|
|
size='small'
|
|
/>
|
|
<BaseButton
|
|
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
|
color='info'
|
|
small
|
|
onClick={toggleFullscreen}
|
|
/>
|
|
</div>
|
|
|
|
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
|
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
|
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
|
{transitionPreview && (
|
|
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
|
<video
|
|
ref={transitionVideoRef}
|
|
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
|
style={{
|
|
opacity:
|
|
transitionPhase === 'preparing' ||
|
|
isBuffering ||
|
|
isOverlayFadingOut
|
|
? 0
|
|
: 1,
|
|
}}
|
|
muted
|
|
playsInline
|
|
preload='auto'
|
|
disablePictureInPicture
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gallery Carousel Overlay */}
|
|
{activeGalleryCarousel && (
|
|
<GalleryCarouselOverlay
|
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
|
initialIndex={activeGalleryCarousel.initialIndex}
|
|
onClose={() => setActiveGalleryCarousel(null)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
prevIconUrl={
|
|
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
|
}
|
|
nextIconUrl={
|
|
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
|
}
|
|
backIconUrl={
|
|
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
|
}
|
|
backLabel={
|
|
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
|
}
|
|
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
|
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
|
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
|
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
|
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
|
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
|
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
|
prevHeight={
|
|
activeGalleryCarousel.element.galleryCarouselPrevHeight
|
|
}
|
|
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
|
nextHeight={
|
|
activeGalleryCarousel.element.galleryCarouselNextHeight
|
|
}
|
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
|
backHeight={
|
|
activeGalleryCarousel.element.galleryCarouselBackHeight
|
|
}
|
|
isEditMode={false}
|
|
/>
|
|
)}
|
|
</BackdropPortalProvider>
|
|
|
|
{/* Toast notifications for offline download status */}
|
|
<ToastContainer
|
|
position='bottom-center'
|
|
autoClose={5000}
|
|
hideProgressBar={false}
|
|
newestOnTop={false}
|
|
closeOnClick
|
|
rtl={false}
|
|
pauseOnFocusLoss
|
|
draggable
|
|
pauseOnHover
|
|
theme='dark'
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Layout wrapper for standalone usage
|
|
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutGuest>{page}</LayoutGuest>;
|
|
};
|