39948-vm/frontend/src/components/RuntimePresentation.tsx
2026-03-27 09:51:33 +04:00

826 lines
26 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 axios from 'axios';
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 LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
import { buildElementStyle } from '../lib/elementStyles';
import type { RuntimeProject, RuntimePage } from '../types/runtime';
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
}
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string;
isReverse: boolean;
} | null>(null);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
// API request config with custom headers for project/environment
const apiConfig = useMemo(
() => ({
headers: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
}),
[projectSlug, environment],
);
// 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,
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,
},
});
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);
}, []);
// Fade out and remove transition overlay when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Start fade-out animation
setIsOverlayFadingOut(true);
// After fade completes (300ms), remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
setIsOverlayFadingOut(false);
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
}, 300);
return () => clearTimeout(fadeTimer);
}
}, [
pendingTransitionComplete,
isBackgroundReady,
isOverlayFadingOut,
pageSwitch.clearPreviousBackground,
]);
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
// Load presentation data
useEffect(() => {
let isCancelled = false;
const loadPresentation = async () => {
try {
setIsLoading(true);
setError('');
// Fetch project by slug
const projectsResponse = await axios.get('/projects', {
...apiConfig,
params: { slug: projectSlug },
});
if (isCancelled) return;
const projectRows = getRows(projectsResponse);
const foundProject = projectRows.find(
(p: RuntimeProject) => p.slug === projectSlug,
);
if (!foundProject) {
setError(`Project "${projectSlug}" not found.`);
return;
}
setProject(foundProject);
// Fetch pages for this project
// (Elements and navigation are extracted from ui_schema_json)
const pagesResponse = await axios.get('/tour_pages', {
...apiConfig,
params: { project: foundProject.id },
});
if (isCancelled) return;
const pageRows = getRows(pagesResponse);
// Filter by environment and sort by sort_order
// STRICT: Only show pages matching the exact environment
// Production = production only, Stage = stage only, Dev = dev only
const envFilteredPages = pageRows
.filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(envFilteredPages);
// Set initial page (first page by sort_order)
if (envFilteredPages.length > 0) {
const firstPage = envFilteredPages[0];
setSelectedPageId(firstPage.id);
setPageHistory([firstPage.id]);
}
} catch (err: any) {
if (isCancelled) return;
const message =
err?.response?.data?.message ||
err?.message ||
'Failed to load presentation.';
setError(message);
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadPresentation();
return () => {
isCancelled = true;
};
}, [projectSlug, environment, apiConfig]);
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 when isOverlayFadingOut resets
setIsOverlayFadingOut(false);
setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
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],
);
const handleElementClick = useCallback(
(element: any) => {
// Disable navigation while transition is actively playing or buffering
// Only block during active phases, not during fade-out (completed phase)
const isActivelyPlaying =
transitionPhase === 'preparing' ||
transitionPhase === 'playing' ||
transitionPhase === 'reversing';
if (isActivelyPlaying || isBuffering) {
return;
}
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
// Resolve slug to page ID, or use legacy targetPageId
let targetPageId: string | undefined;
if (targetPageSlug) {
const targetPage = pages.find((p) => p.slug === targetPageSlug);
targetPageId = targetPage?.id;
} else if (legacyTargetPageId) {
targetPageId = legacyTargetPageId;
}
// Debug: log element navigation data
logger.info('Element clicked', {
elementType: element.type,
targetPageSlug,
legacyTargetPageId,
resolvedTargetPageId: targetPageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
});
if (targetPageId) {
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
// Get transition video URL from element itself
const transitionVideoUrl = element.transitionVideoUrl;
navigateToPage(targetPageId, transitionVideoUrl, isBack);
}
},
[navigateToPage, pages, transitionPhase, isBuffering],
);
// Render element content based on type
const renderElementContent = (element: any) => {
// Navigation buttons
if (
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
if (element.iconUrl) {
// Use img tag with flexible sizing - auto for dimensions not provided
const imgStyle: React.CSSProperties = {
width: element.width || 'auto',
height: element.height || 'auto',
objectFit: 'contain',
};
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation'
style={imgStyle}
draggable={false}
/>
);
}
return (
<div className='px-4 py-2 bg-white/80 rounded text-black text-sm'>
{element.navLabel ||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
</div>
);
}
// Description element
if (element.type === 'description') {
if (element.iconUrl) {
return (
<div className='relative w-full h-full'>
<Image
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description'
fill
className='object-contain'
unoptimized
/>
</div>
);
}
const bgColor = element.descriptionBackgroundColor || 'transparent';
return (
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
<p
className='font-bold'
style={{
fontSize: element.descriptionTitleFontSize || '24px',
fontFamily: element.descriptionTitleFontFamily || 'inherit',
color: element.descriptionTitleColor || '#ffffff',
}}
>
{element.descriptionTitle || ''}
</p>
{element.descriptionText && (
<p
style={{
fontSize: element.descriptionTextFontSize || '16px',
fontFamily: element.descriptionTextFontFamily || 'inherit',
color: element.descriptionTextColor || '#ffffff',
}}
>
{element.descriptionText}
</p>
)}
</div>
);
}
// Tooltip
if (element.type === 'tooltip') {
if (element.iconUrl) {
return (
<div className='relative w-full h-full'>
<Image
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip'
fill
className='object-contain'
unoptimized
/>
</div>
);
}
return (
<div className='bg-white/90 p-3 rounded max-w-[200px]'>
<p className='font-bold text-black text-sm'>{element.tooltipTitle}</p>
<p className='text-gray-600 text-xs'>{element.tooltipText}</p>
</div>
);
}
// Video player
if (element.type === 'video_player' && element.mediaUrl) {
return (
<video
className='w-full h-full object-cover rounded'
src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
/>
);
}
// Audio player
if (element.type === 'audio_player' && element.mediaUrl) {
return (
<audio
className='w-full'
src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
/>
);
}
// Gallery
if (element.type === 'gallery') {
const cards = element.galleryCards || [];
return (
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded'>
{cards.map((card: any) => (
<div key={card.id} className='relative aspect-square'>
{card.imageUrl && (
<Image
src={resolveAssetPlaybackUrl(card.imageUrl)}
alt={card.title || ''}
fill
className='object-cover rounded'
unoptimized
/>
)}
</div>
))}
</div>
);
}
// Carousel
if (element.type === 'carousel') {
const slides = element.carouselSlides || [];
const firstSlide = slides[0];
if (firstSlide?.imageUrl) {
return (
<div className='relative w-full h-full'>
<Image
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
alt={firstSlide.caption || ''}
fill
className='object-cover rounded'
unoptimized
/>
</div>
);
}
}
// Default: icon or image
if (element.iconUrl) {
return (
<div className='relative w-full h-full'>
<Image
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt=''
fill
className='object-contain'
unoptimized
/>
</div>
);
}
if (element.imageUrl) {
return (
<div className='relative w-full h-full'>
<Image
src={resolveAssetPlaybackUrl(element.imageUrl)}
alt=''
fill
className='object-contain'
unoptimized
/>
</div>
);
}
// Text fallback
if (element.text) {
return <div className='text-white'>{element.text}</div>;
}
return null;
};
// 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>{getPageTitle(project?.name || 'Presentation')}</title>
</Head>
<div
className='relative w-screen h-screen overflow-hidden bg-black'
style={{
backgroundImage: backgroundImageUrl
? `url("${backgroundImageUrl}")`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* Background image element - CSS backgroundImage provides instant display,
Image component enhances with optimized loading. bg-black prevents white flash. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 pointer-events-none'>
<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 */}
{backgroundVideoUrl && (
<video
key={backgroundVideoUrl}
className='absolute inset-0 w-full h-full object-cover'
src={backgroundVideoUrl}
autoPlay
loop
muted
playsInline
/>
)}
{/* Page elements */}
<div className='absolute inset-0'>
{pageElements.map((element: any) => {
const xPercent = element.xPercent ?? 0;
const yPercent = element.yPercent ?? 0;
const rotation = element.rotation ?? 0;
// Build element style using shared utility
const elementStyle: React.CSSProperties = {
left: `${xPercent}%`,
top: `${yPercent}%`,
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
...buildElementStyle(element),
};
return (
<div
key={element.id}
className='absolute cursor-pointer'
style={elementStyle}
onClick={() => handleElementClick(element)}
>
{renderElementContent(element)}
</div>
);
})}
</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>
{/* Environment badge */}
<div className='absolute top-4 left-4 z-50'>
<span
className={`px-2 py-1 rounded text-xs font-bold ${
environment === 'stage'
? 'bg-yellow-500 text-black'
: 'bg-green-500 text-white'
}`}
>
{environment.toUpperCase()}
</span>
</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>
)}
</div>
</>
);
}
// Layout wrapper for standalone usage
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};