/** * 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 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 { 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'; import { resolveNavigationTarget, isTransitionBlocking, } from '../lib/navigationHelpers'; import type { TransitionPhase } from '../types/presentation'; 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); const [selectedPageId, setSelectedPageId] = useState(null); const [pageHistory, setPageHistory] = useState([]); 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: any; initialIndex: number; } | null>(null); const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(null); // 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 const { pageLinks, preloadElements } = useMemo(() => { const result = extractPageLinksAndElements(pages); if (result.pageLinks.length > 0 || result.preloadElements.length > 0) { logger.info('[PRELOAD] Extracted page links and elements', { pageLinksCount: result.pageLinks.length, preloadElementsCount: result.preloadElements.length, pageLinks: result.pageLinks.map((link) => ({ from: link.from_pageId?.slice(-8), to: link.to_pageId?.slice(-8), hasTransition: !!link.transition?.video_url, })), }); } return result; }, [pages]); // 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', } : null, onComplete: async (targetPageId) => { 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]); }); 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, () => { setSelectedPageId(targetPageId); setPageHistory((prev) => [...prev, targetPageId]); }); } }, [pages, pageSwitch, resetFadeOut], ); const handleElementClick = useCallback( (element: any) => { // Block navigation while transition is actively playing or buffering if ( isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering) ) { return; } // 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: element.targetPageSlug, legacyTargetPageId: element.targetPageId, resolvedTargetPageId: navTarget?.pageId, transitionVideoUrl: element.transitionVideoUrl, hasTransition: Boolean(element.transitionVideoUrl), }); if (navTarget) { navigateToPage( navTarget.pageId, navTarget.transitionVideoUrl, navTarget.isBack, ); } }, [navigateToPage, pages, transitionPhase, isBuffering], ); // Handler for gallery card clicks const handleGalleryCardClick = useCallback( (element: any, 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; if (isLoading) { return (
Loading presentation...
); } if (error) { return (

Error

{error}

); } return ( <> {project?.name || 'Presentation'} {faviconUrl && } {ogImageUrl && ( <> )} {project?.name && ( <> )} {project?.description && ( <> )}
{/* 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 && (
{backgroundImageUrl.startsWith('blob:') ? ( // eslint-disable-next-line @next/next/no-img-element { setIsBackgroundReady(true); pageSwitch.markBackgroundReady(); }} onError={() => { setIsBackgroundReady(true); pageSwitch.markBackgroundReady(); }} /> ) : ( { setIsBackgroundReady(true); pageSwitch.markBackgroundReady(); }} onError={() => { setIsBackgroundReady(true); pageSwitch.markBackgroundReady(); }} /> )}
)} {/* Previous background overlay - shows during direct navigation until new bg is ready */} {pageSwitch.previousBgImageUrl && pageSwitch.isSwitching && !pageSwitch.isNewBgReady && (
)} {/* Background video - z-1 keeps it below backdrop blur (z-5) */} {backgroundVideoUrl && (
); } // Layout wrapper for standalone usage RuntimePresentation.getLayout = function getLayout(page: ReactElement) { return {page}; };