39948-vm/frontend/src/components/RuntimePresentation.tsx
2026-04-03 13:17:43 +04:00

621 lines
22 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 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<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
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<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(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 (
<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
key={backgroundVideoUrl}
className='absolute inset-0 z-1 w-full h-full object-cover'
src={backgroundVideoUrl}
autoPlay
loop
muted
playsInline
/>
)}
{/* Page elements - z-10 ensures they appear above backdrop layer */}
<div className='absolute inset-0 z-10'>
{pageElements.map((element: any) => (
<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}
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>
</div>
</>
);
}
// Layout wrapper for standalone usage
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};