826 lines
26 KiB
TypeScript
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>;
|
|
};
|