39948-vm/frontend/src/components/RuntimePresentation.tsx
2026-04-09 10:06:18 +04:00

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