/** * 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(null); const lastInitializedPageIdRef = useRef(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(); 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 (
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}; };