preloading logic

This commit is contained in:
Dmitri 2026-03-24 15:40:35 +04:00
parent f12ec7c8bb
commit 9159f467f5
33 changed files with 3185 additions and 1947 deletions

View File

@ -4,7 +4,6 @@ DB_PASS=88dbeaf8-e906-405e-9e41-c3baadeda5c6
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
PORT=3000 PORT=3000
PLATFORM_ROOT_DOMAIN=tour-builder-platform-2eb6.dev.flatlogic.app
GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR
MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73 MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73

View File

@ -44,10 +44,16 @@ function extractUrlsFromContent(contentJson) {
const urls = []; const urls = [];
const urlFields = [ const urlFields = [
'image_url', 'iconUrl',
'video_url', 'imageUrl',
'audio_url', 'mediaUrl',
'background_url', 'videoUrl',
'audioUrl',
'transitionVideoUrl',
'backgroundImageUrl',
'reverseVideoUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'src', 'src',
'url', 'url',
'poster', 'poster',
@ -58,11 +64,13 @@ function extractUrlsFromContent(contentJson) {
if (depth > 5 || !obj || typeof obj !== 'object') return; if (depth > 5 || !obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && urlFields.includes(key) && value.startsWith('http')) { if (typeof value === 'string' && value && urlFields.includes(key)) {
urls.push({ if (value.startsWith('http') || value.startsWith('/')) {
url: value, urls.push({
fieldType: key, url: value,
}); fieldType: key,
});
}
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === 'object' && value !== null) {
checkObject(value, depth + 1); checkObject(value, depth + 1);
} }
@ -105,6 +113,12 @@ class PWAManifestService {
const manifestAssets = []; const manifestAssets = [];
const seenUrls = new Set(); const seenUrls = new Set();
// Helper to convert size_mb to bytes
const mbToBytes = (sizeMb) => {
if (!sizeMb || isNaN(sizeMb)) return 0;
return Math.round(parseFloat(sizeMb) * 1024 * 1024);
};
// Helper to add an asset to the manifest // Helper to add an asset to the manifest
const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => { const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => {
if (!url || seenUrls.has(url)) return; if (!url || seenUrls.has(url)) return;
@ -139,21 +153,21 @@ class PWAManifestService {
variant.variant_type, variant.variant_type,
asset.type || getAssetType(asset.mime_type, asset.filename), asset.type || getAssetType(asset.mime_type, asset.filename),
variant.mime_type || asset.mime_type, variant.mime_type || asset.mime_type,
variant.size_bytes || 0, variant.size_bytes || mbToBytes(variant.size_mb),
asset.pages?.map((p) => p.id) || [] asset.pages?.map((p) => p.id) || []
); );
} }
// If no variants, add original // If no variants, add original (use cdn_url as primary, fall back to storage_key)
if (selectedVariants.length === 0 && asset.url) { if (selectedVariants.length === 0 && (asset.cdn_url || asset.storage_key)) {
addAsset( addAsset(
asset.id, asset.id,
asset.url, asset.cdn_url || asset.storage_key,
asset.filename, asset.name || asset.filename,
'original', 'original',
asset.type || getAssetType(asset.mime_type, asset.filename), asset.type || getAssetType(asset.mime_type, asset.name),
asset.mime_type, asset.mime_type,
asset.size_bytes || 0, mbToBytes(asset.size_mb),
asset.pages?.map((p) => p.id) || [] asset.pages?.map((p) => p.id) || []
); );
} }

3
frontend/.gitignore vendored
View File

@ -31,3 +31,6 @@ yarn-error.log*
# vercel # vercel
.vercel .vercel
/.idea/ /.idea/
# typescript
tsconfig.tsbuildinfo

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,11 @@ import FileUploader from '../Uploaders/UploadService';
import type { AssetSection } from './AssetSectionCard'; import type { AssetSection } from './AssetSectionCard';
import type { UploadQueueItem } from './UploadProgressList'; import type { UploadQueueItem } from './UploadProgressList';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import {
probeMediaDuration,
isVideoMimeType,
isAudioMimeType,
} from '../../lib/mediaDuration';
interface UseAssetUploaderOptions { interface UseAssetUploaderOptions {
selectedProjectId: string; selectedProjectId: string;
@ -106,6 +111,25 @@ export function useAssetUploader({
}, },
); );
// Probe media duration for video/audio files
let durationSec: number | null = null;
let widthPx: number | null = null;
let heightPx: number | null = null;
if (isVideoMimeType(file.type)) {
const probeResult = await probeMediaDuration(file, 'video');
if (probeResult) {
durationSec = probeResult.duration;
widthPx = probeResult.width ?? null;
heightPx = probeResult.height ?? null;
}
} else if (isAudioMimeType(file.type)) {
const probeResult = await probeMediaDuration(file, 'audio');
if (probeResult) {
durationSec = probeResult.duration;
}
}
await axios.post('/assets', { await axios.post('/assets', {
data: { data: {
project: projectId, project: projectId,
@ -117,6 +141,9 @@ export function useAssetUploader({
mime_type: file.type || null, mime_type: file.type || null,
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)), size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
is_public: false, is_public: false,
duration_sec: durationSec,
width_px: widthPx,
height_px: heightPx,
}, },
}); });

View File

@ -0,0 +1,796 @@
/**
* 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 { PRELOAD_CONFIG } from '../config/preload.config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { useReversePlayback } from '../hooks/useReversePlayback';
import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { buildElementStyle } from '../lib/elementStyles';
import type {
RuntimeProject,
RuntimePage,
RuntimePageLink,
TransitionOverlayState,
} from '../types/runtime';
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
}
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
/**
* Wait for all images on a page to be decoded before switching.
*/
const waitForPageImages = async (
page: RuntimePage | null,
timeoutMs = 2000,
): Promise<void> => {
if (!page) return;
const imageUrls: string[] = [];
if (page.background_image_url) {
const url = resolveAssetPlaybackUrl(page.background_image_url);
if (url) imageUrls.push(url);
}
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
const { images: imageFields, nested: nestedFields } =
PRELOAD_CONFIG.assetFields;
pageElements.forEach((el: Record<string, unknown>) => {
// Direct image fields
imageFields.forEach((field) => {
const value = el[field];
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
// Nested arrays (galleryCards, carouselSlides)
nestedFields.forEach((nestedField) => {
const items = el[nestedField];
if (Array.isArray(items)) {
items.forEach((item: Record<string, unknown>) => {
if (typeof item.imageUrl === 'string' && item.imageUrl) {
const url = resolveAssetPlaybackUrl(item.imageUrl);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
}
});
});
} catch {
// Ignore parse errors
}
if (imageUrls.length === 0) return;
const decodePromises = imageUrls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new window.Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
};
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [overlayTransition, setOverlayTransition] =
useState<TransitionOverlayState | null>(null);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const overlayVideoRef = useRef<HTMLVideoElement | 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],
);
// Transform elements from ui_schema_json for preloading
// (Elements in RuntimePresentation come from page's ui_schema_json, not raw API)
const preloadElements = useMemo(() => {
const result: Array<{
id: string;
pageId: string;
element_type: string;
content_json: string;
}> = [];
pages.forEach((page) => {
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
pageElements.forEach((el: Record<string, unknown>) => {
// Build content_json from config fields
const contentObj: Record<string, unknown> = {};
PRELOAD_CONFIG.assetFields.all.forEach((field) => {
if (el[field] !== undefined) {
contentObj[field] = el[field];
}
});
PRELOAD_CONFIG.assetFields.nested.forEach((field) => {
if (el[field] !== undefined) {
contentObj[field] = el[field];
}
});
result.push({
id: String(el.id || `${page.id}-${result.length}`),
pageId: page.id,
element_type: String(el.type || ''),
content_json: JSON.stringify(contentObj),
});
});
} catch {
// Ignore parse errors
}
});
return result;
}, [pages]);
// Initialize preload orchestrator with transformed data
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
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);
}, []);
// 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 and links for this project
// (Elements are extracted from ui_schema_json, transitions are eagerly loaded by API)
const [pagesResponse, pageLinksResponse] = await Promise.all([
axios.get('/tour_pages', {
...apiConfig,
params: { project: foundProject.id },
}),
axios.get('/page_links', {
...apiConfig,
params: { project: foundProject.id },
}),
]);
if (isCancelled) return;
const pageRows = getRows(pagesResponse);
const linkRows = getRows(pageLinksResponse);
// Filter by environment and sort by sort_order
const envFilteredPages = pageRows
.filter(
(p: any) =>
!p.environment ||
p.environment === environment ||
p.environment === 'dev',
)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(envFilteredPages);
setPageLinks(linkRows);
// Set initial page
if (envFilteredPages.length > 0) {
const entryPage = foundProject.entry_page_slug
? envFilteredPages.find(
(p: RuntimePage) => p.slug === foundProject.entry_page_slug,
)
: null;
const firstPage = entryPage || 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]);
const navigateToPage = useCallback(
async (
targetPageId: string,
transitionVideoUrl?: string,
isBack = false,
) => {
const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return;
if (transitionVideoUrl) {
// Play transition (forward or reverse)
setOverlayTransition({
targetPageId,
transitionName: 'Transition',
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
isReverse: isBack,
});
} else {
// Direct navigation - wait for images first
await waitForPageImages(targetPage);
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
}
},
[pages],
);
const handleElementClick = useCallback(
(element: any) => {
if (element.targetPageId) {
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
// Get transition video URL from element itself
const transitionVideoUrl = element.transitionVideoUrl;
navigateToPage(element.targetPageId, transitionVideoUrl, isBack);
}
},
[navigateToPage],
);
const finishOverlayTransition = useCallback(async () => {
if (!overlayTransition) return;
const targetPage = pages.find(
(p) => p.id === overlayTransition.targetPageId,
);
// Wait for images while showing last frame
await waitForPageImages(targetPage || null);
// Switch page
setSelectedPageId(overlayTransition.targetPageId);
setPageHistory((prev) => [...prev, overlayTransition.targetPageId]);
// Hide transition after React renders
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setOverlayTransition(null);
});
});
}, [overlayTransition, pages]);
// Use the reverse playback hook (same as constructor.tsx)
const { startReverse, stopReverse } = useReversePlayback({
videoRef: overlayVideoRef,
onComplete: finishOverlayTransition,
preloadedUrls: preloadOrchestrator.preloadedUrls,
videoUrl: overlayTransition?.videoUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
});
// Handle reverse playback when transition starts in reverse mode
useEffect(() => {
if (!overlayTransition?.isReverse) return;
const video = overlayVideoRef.current;
if (!video) return;
// Start reverse when video data is ready (preloaded assets load instantly)
const handleLoadedData = () => {
startReverse();
};
if (video.readyState >= 2) {
handleLoadedData();
} else {
video.addEventListener('loadeddata', handleLoadedData, { once: true });
}
return () => {
stopReverse();
video.removeEventListener('loadeddata', handleLoadedData);
};
}, [
overlayTransition?.isReverse,
overlayTransition?.videoUrl,
overlayTransition?.durationSec,
startReverse,
stopReverse,
]);
// Render element content based on type
const renderElementContent = (element: any) => {
// Navigation buttons
if (
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
if (element.iconUrl) {
return (
<div className='relative w-full h-full min-w-[40px] min-h-[40px]'>
<Image
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation'
fill
className='object-contain'
unoptimized
/>
</div>
);
}
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;
};
const backgroundImageUrl = selectedPage?.background_image_url
? resolveAssetPlaybackUrl(selectedPage.background_image_url)
: '';
const backgroundVideoUrl = selectedPage?.background_video_url
? resolveAssetPlaybackUrl(selectedPage.background_video_url)
: '';
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 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 */}
{overlayTransition && (
<div className='absolute inset-0 z-40 bg-black'>
<video
ref={overlayVideoRef}
className='w-full h-full object-cover'
src={overlayTransition.videoUrl}
autoPlay={!overlayTransition.isReverse}
muted
playsInline
preload='auto'
onEnded={
overlayTransition.isReverse
? undefined
: finishOverlayTransition
}
onError={finishOverlayTransition}
/>
</div>
)}
</div>
</>
);
}
// Layout wrapper for standalone usage
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -55,6 +55,40 @@ export const PRELOAD_CONFIG = {
maxDepth: 2, // How far to look ahead maxDepth: 2, // How far to look ahead
constructorMaxDepth: 1, // Reduced depth for constructor preview constructorMaxDepth: 1, // Reduced depth for constructor preview
}, },
// Asset URL field names in element content_json (camelCase)
assetFields: {
// All asset URL fields for preloading extraction
all: [
'iconUrl',
'imageUrl',
'mediaUrl',
'videoUrl',
'audioUrl',
'transitionVideoUrl',
'backgroundImageUrl',
'reverseVideoUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'src',
'url',
'poster',
'thumbnail',
] as const,
// Image-only fields for decode before page switch
images: [
'iconUrl',
'imageUrl',
'backgroundImageUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'src',
] as const,
// Nested array fields containing assets
nested: ['galleryCards', 'carouselSlides'] as const,
// Fields within nested items that contain URLs
nestedUrlFields: ['imageUrl', 'videoUrl'] as const,
},
} as const; } as const;
export type PreloadConfig = typeof PRELOAD_CONFIG; export type PreloadConfig = typeof PRELOAD_CONFIG;

View File

@ -6,3 +6,17 @@ export { useFilterItems } from './useFilterItems';
export { useCSVHandling } from './useCSVHandling'; export { useCSVHandling } from './useCSVHandling';
export { useFormSync } from './useFormSync'; export { useFormSync } from './useFormSync';
export { useEntityTable } from './useEntityTable'; export { useEntityTable } from './useEntityTable';
export { useTransitionPlayback } from './useTransitionPlayback';
export type {
ReverseMode,
TransitionConfig,
UseTransitionPlaybackOptions,
PlaybackPhase,
UseTransitionPlaybackResult,
} from './useTransitionPlayback';
export { usePageNavigation } from './usePageNavigation';
export type {
NavigablePage,
UsePageNavigationOptions,
UsePageNavigationResult,
} from './usePageNavigation';

View File

@ -7,59 +7,34 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PRELOAD_CONFIG } from '../config/preload.config'; import { PRELOAD_CONFIG } from '../config/preload.config';
import type {
interface PageLink { PreloadPage,
id: string; PreloadPageLink,
from_pageId?: string; PreloadElement,
to_pageId?: string; PreloadAssetInfo,
is_active?: boolean; PreloadNeighborInfo,
transition?: { } from '../types/preload';
id: string;
video_url?: string;
};
}
interface Page {
id: string;
}
interface Element {
id: string;
pageId?: string;
element_type?: string;
content_json?: string;
}
interface UseNeighborGraphOptions { interface UseNeighborGraphOptions {
pages: Page[]; pages: PreloadPage[];
pageLinks: PageLink[]; pageLinks: PreloadPageLink[];
elements: Element[]; elements: PreloadElement[];
maxDepth?: number; maxDepth?: number;
} }
interface NeighborInfo {
pageId: string;
distance: number;
}
interface AssetInfo {
url: string;
pageId: string;
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
priority: number;
}
interface NeighborGraphResult { interface NeighborGraphResult {
/** /**
* Get neighboring page IDs within maxDepth hops * Get neighboring page IDs within maxDepth hops
*/ */
getNeighbors: (currentPageId: string, maxDepth?: number) => NeighborInfo[]; getNeighbors: (
currentPageId: string,
maxDepth?: number,
) => PreloadNeighborInfo[];
/** /**
* Get all assets that should be preloaded for given pages * Get all assets that should be preloaded for given pages
*/ */
getAssetsForPages: (pageIds: string[]) => AssetInfo[]; getAssetsForPages: (pageIds: string[]) => PreloadAssetInfo[];
/** /**
* Get prioritized assets for preloading based on current page * Get prioritized assets for preloading based on current page
@ -67,7 +42,7 @@ interface NeighborGraphResult {
getPrioritizedAssets: ( getPrioritizedAssets: (
currentPageId: string, currentPageId: string,
maxDepth?: number, maxDepth?: number,
) => AssetInfo[]; ) => PreloadAssetInfo[];
/** /**
* Raw adjacency list for debugging * Raw adjacency list for debugging
@ -81,31 +56,17 @@ interface NeighborGraphResult {
function extractAssetsFromContent( function extractAssetsFromContent(
contentJson: string | undefined, contentJson: string | undefined,
pageId: string, pageId: string,
): AssetInfo[] { ): PreloadAssetInfo[] {
if (!contentJson) return []; if (!contentJson) return [];
try { try {
const content = const content =
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
const assets: AssetInfo[] = []; const assets: PreloadAssetInfo[] = [];
// Check for common asset URL fields (both snake_case and camelCase) // Asset URL fields in element content_json
const urlFields = [ const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
'image_url',
'video_url',
'audio_url',
'background_url',
'src',
'url',
'poster',
'thumbnail',
'transitionVideoUrl', // For transition videos in constructor
'videoUrl', // camelCase variant
'audioUrl', // camelCase variant
'iconUrl', // icon images
'backgroundImageUrl', // background images
];
const checkObject = (obj: Record<string, unknown>, depth = 0) => { const checkObject = (obj: Record<string, unknown>, depth = 0) => {
if (depth > 5 || !obj || typeof obj !== 'object') return; if (depth > 5 || !obj || typeof obj !== 'object') return;
@ -174,9 +135,9 @@ export function useNeighborGraph(
// BFS to find neighbors within depth // BFS to find neighbors within depth
const getNeighbors = useMemo(() => { const getNeighbors = useMemo(() => {
return (currentPageId: string, depth = maxDepth): NeighborInfo[] => { return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => {
const visited = new Set<string>(); const visited = new Set<string>();
const result: NeighborInfo[] = []; const result: PreloadNeighborInfo[] = [];
const queue: { pageId: string; distance: number }[] = [ const queue: { pageId: string; distance: number }[] = [
{ pageId: currentPageId, distance: 0 }, { pageId: currentPageId, distance: 0 },
]; ];
@ -210,8 +171,8 @@ export function useNeighborGraph(
// Get assets for a set of pages // Get assets for a set of pages
const getAssetsForPages = useMemo(() => { const getAssetsForPages = useMemo(() => {
return (pageIds: string[]): AssetInfo[] => { return (pageIds: string[]): PreloadAssetInfo[] => {
const assets: AssetInfo[] = []; const assets: PreloadAssetInfo[] = [];
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
pageIds.forEach((pageId) => { pageIds.forEach((pageId) => {
@ -236,8 +197,7 @@ export function useNeighborGraph(
// Add transition videos (transition is eagerly loaded in page_links) // Add transition videos (transition is eagerly loaded in page_links)
const matchingLinks = pageLinks.filter( const matchingLinks = pageLinks.filter(
(link) => (link) =>
link.is_active !== false && link.is_active !== false && pageIds.includes(link.from_pageId || ''),
pageIds.includes(link.from_pageId || ''),
); );
matchingLinks.forEach((link) => { matchingLinks.forEach((link) => {
@ -259,7 +219,7 @@ export function useNeighborGraph(
// Get prioritized assets for preloading // Get prioritized assets for preloading
const getPrioritizedAssets = useMemo(() => { const getPrioritizedAssets = useMemo(() => {
return (currentPageId: string, depth = maxDepth): AssetInfo[] => { return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => {
// Get current page assets (highest priority) // Get current page assets (highest priority)
const currentPageAssets = getAssetsForPages([currentPageId]).map( const currentPageAssets = getAssetsForPages([currentPageId]).map(
(asset) => ({ (asset) => ({
@ -272,7 +232,7 @@ export function useNeighborGraph(
// Get neighbor page assets // Get neighbor page assets
const neighbors = getNeighbors(currentPageId, depth); const neighbors = getNeighbors(currentPageId, depth);
const neighborAssets: AssetInfo[] = []; const neighborAssets: PreloadAssetInfo[] = [];
neighbors.forEach(({ pageId, distance }) => { neighbors.forEach(({ pageId, distance }) => {
const assets = getAssetsForPages([pageId]); const assets = getAssetsForPages([pageId]);
@ -292,7 +252,7 @@ export function useNeighborGraph(
const allAssets = [...currentPageAssets, ...neighborAssets]; const allAssets = [...currentPageAssets, ...neighborAssets];
// Deduplicate by URL, keeping highest priority // Deduplicate by URL, keeping highest priority
const urlToPriority = new Map<string, AssetInfo>(); const urlToPriority = new Map<string, PreloadAssetInfo>();
allAssets.forEach((asset) => { allAssets.forEach((asset) => {
const existing = urlToPriority.get(asset.url); const existing = urlToPriority.get(asset.url);
if (!existing || asset.priority > existing.priority) { if (!existing || asset.priority > existing.priority) {

View File

@ -0,0 +1,185 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
/**
* Minimal page interface for navigation
*/
export interface NavigablePage {
id: string;
sort_order?: number;
slug?: string;
}
export interface UsePageNavigationOptions<TPage extends NavigablePage> {
pages: TPage[];
defaultPageId?: string;
entryPageSlug?: string;
trackHistory?: boolean;
onPageChange?: (pageId: string, isBack: boolean) => void;
}
export interface UsePageNavigationResult<TPage extends NavigablePage> {
currentPageId: string | null;
currentPage: TPage | null;
pageHistory: string[];
previousPageId: string | null;
defaultPage: TPage | null;
setCurrentPageId: (pageId: string) => void;
applyPageSelection: (targetPageId: string, isBack?: boolean) => void;
isBackNavigation: (targetPageId: string) => boolean;
goBack: () => boolean;
resetHistory: () => void;
}
/**
* Hook for managing page navigation state with optional history tracking.
*
* @example
* // Basic usage (runtime)
* const nav = usePageNavigation({
* pages,
* entryPageSlug: project?.entry_page_slug,
* trackHistory: true,
* });
*
* // Editor usage (constructor) - no history
* const nav = usePageNavigation({
* pages,
* defaultPageId: initialPageId,
* trackHistory: false,
* });
*/
export function usePageNavigation<TPage extends NavigablePage>(
options: UsePageNavigationOptions<TPage>,
): UsePageNavigationResult<TPage> {
const {
pages,
defaultPageId,
entryPageSlug,
trackHistory = true,
onPageChange,
} = options;
const [currentPageId, setCurrentPageIdState] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
// Compute default page (by slug or sort order)
const defaultPage = useMemo(() => {
if (!pages.length) return null;
// Try entry page slug first
if (entryPageSlug) {
const bySlug = pages.find((p) => p.slug === entryPageSlug);
if (bySlug) return bySlug;
}
// Try explicit default page ID
if (defaultPageId) {
const byId = pages.find((p) => p.id === defaultPageId);
if (byId) return byId;
}
// Fall back to first by sort order
return [...pages].sort(
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
)[0];
}, [pages, defaultPageId, entryPageSlug]);
// Get current page object
const currentPage = useMemo(() => {
if (!pages.length) return null;
if (currentPageId) {
return pages.find((p) => p.id === currentPageId) || null;
}
return defaultPage;
}, [pages, currentPageId, defaultPage]);
// Previous page ID from history
const previousPageId = useMemo(() => {
if (pageHistory.length < 2) return null;
return pageHistory[pageHistory.length - 2];
}, [pageHistory]);
// Check if navigating to a page would be a "back" navigation
const isBackNavigation = useCallback(
(targetPageId: string): boolean => {
return previousPageId === targetPageId;
},
[previousPageId],
);
// Apply page selection with history tracking
const applyPageSelection = useCallback(
(targetPageId: string, isBack = false) => {
setCurrentPageIdState(targetPageId);
if (trackHistory) {
setPageHistory((prev) => {
if (!prev.length) return [targetPageId];
const currentId = prev[prev.length - 1];
if (currentId === targetPageId) return prev;
// If going back and target matches previous, pop history
if (
isBack &&
prev.length > 1 &&
prev[prev.length - 2] === targetPageId
) {
return prev.slice(0, -1);
}
return [...prev, targetPageId];
});
}
onPageChange?.(targetPageId, isBack);
},
[trackHistory, onPageChange],
);
// Simple setter (no history tracking)
const setCurrentPageId = useCallback(
(pageId: string) => {
setCurrentPageIdState(pageId);
if (trackHistory && pageHistory.length === 0) {
setPageHistory([pageId]);
}
},
[trackHistory, pageHistory.length],
);
// Go back in history
const goBack = useCallback((): boolean => {
if (!trackHistory || pageHistory.length < 2) return false;
const targetPageId = pageHistory[pageHistory.length - 2];
applyPageSelection(targetPageId, true);
return true;
}, [trackHistory, pageHistory, applyPageSelection]);
// Reset history
const resetHistory = useCallback(() => {
setPageHistory(currentPageId ? [currentPageId] : []);
}, [currentPageId]);
// Initialize to default page
useEffect(() => {
if (defaultPage?.id && !currentPageId) {
setCurrentPageIdState(defaultPage.id);
if (trackHistory) {
setPageHistory((prev) => (prev.length ? prev : [defaultPage.id]));
}
}
}, [defaultPage, currentPageId, trackHistory]);
return {
currentPageId,
currentPage,
pageHistory,
previousPageId,
defaultPage,
setCurrentPageId,
applyPageSelection,
isBackNavigation,
goBack,
resetHistory,
};
}

View File

@ -9,36 +9,21 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { useNeighborGraph } from './useNeighborGraph'; import { useNeighborGraph } from './useNeighborGraph';
import { useNetworkAware } from './useNetworkAware'; import { useNetworkAware } from './useNetworkAware';
import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { downloadEventBus } from '../lib/offline/DownloadEventBus';
import { StorageManager } from '../lib/offline/StorageManager';
import { PRELOAD_CONFIG } from '../config/preload.config'; import { PRELOAD_CONFIG } from '../config/preload.config';
import { OFFLINE_CONFIG } from '../config/offline.config'; import { OFFLINE_CONFIG } from '../config/offline.config';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import type {
interface Page { PreloadPage,
id: string; PreloadPageLink,
background_image_url?: string; PreloadElement,
background_video_url?: string; } from '../types/preload';
}
interface Element {
id: string;
pageId?: string;
element_type?: string;
content_json?: string;
}
interface PageLink {
id: string;
from_pageId?: string;
to_pageId?: string;
transitionId?: string;
is_active?: boolean;
}
interface UsePreloadOrchestratorOptions { interface UsePreloadOrchestratorOptions {
pages: Page[]; pages: PreloadPage[];
pageLinks: PageLink[]; pageLinks: PreloadPageLink[];
elements: Element[]; elements: PreloadElement[];
currentPageId: string | null; currentPageId: string | null;
pageHistory?: string[]; pageHistory?: string[];
enabled?: boolean; enabled?: boolean;
@ -71,19 +56,12 @@ const generateJobId = (): string => {
}; };
/** /**
* Check if a URL is already cached (simplified check) * Check if a URL is already cached (checks both IndexedDB and Cache API)
*/ */
const isUrlCached = async (url: string): Promise<boolean> => { const isUrlCached = async (url: string): Promise<boolean> => {
if (typeof caches === 'undefined') return false;
try { try {
const cacheNames = await caches.keys(); // StorageManager.hasAsset checks both IndexedDB (large files) and Cache API (small files)
for (const cacheName of cacheNames) { return StorageManager.hasAsset(url);
const cache = await caches.open(cacheName);
const response = await cache.match(url);
if (response) return true;
}
return false;
} catch { } catch {
return false; return false;
} }
@ -117,7 +95,15 @@ const decodeImage = async (url: string): Promise<void> => {
* Check if URL is an image based on extension or content type * Check if URL is an image based on extension or content type
*/ */
const isImageUrl = (url: string): boolean => { const isImageUrl = (url: string): boolean => {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg']; const imageExtensions = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.avif',
'.svg',
];
const lowerUrl = url.toLowerCase(); const lowerUrl = url.toLowerCase();
return imageExtensions.some((ext) => lowerUrl.includes(ext)); return imageExtensions.some((ext) => lowerUrl.includes(ext));
}; };
@ -198,13 +184,17 @@ const preloadWithProgress = async (
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
// Create a new response from collected chunks // Create a new response from collected chunks
const blob = new Blob(chunks as BlobPart[], { const blob = new Blob(chunks as BlobPart[], {
type: responseToCache.headers.get('content-type') || 'application/octet-stream', type:
responseToCache.headers.get('content-type') ||
'application/octet-stream',
}); });
const cachedResponse = new Response(blob, { const cachedResponse = new Response(blob, {
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
headers: { headers: {
'Content-Type': responseToCache.headers.get('content-type') || 'application/octet-stream', 'Content-Type':
responseToCache.headers.get('content-type') ||
'application/octet-stream',
'Content-Length': String(bytesLoaded), 'Content-Length': String(bytesLoaded),
}, },
}); });
@ -304,15 +294,23 @@ export function usePreloadOrchestrator(
activeDownloadsRef.current++; activeDownloadsRef.current++;
const jobId = generateJobId(); const jobId = generateJobId();
logger.info('[PRELOAD] Starting download', { url: item.url.slice(-50), assetType: item.assetType }); logger.info('[PRELOAD] Starting download', {
url: item.url.slice(-50),
assetType: item.assetType,
});
preloadWithProgress(item.url, jobId, item.id) preloadWithProgress(item.url, jobId, item.id)
.then(() => { .then(() => {
logger.info('[PRELOAD] Download complete', { url: item.url.slice(-50) }); logger.info('[PRELOAD] Download complete', {
url: item.url.slice(-50),
});
preloadedUrls.add(item.url); preloadedUrls.add(item.url);
}) })
.catch((err) => { .catch((err) => {
logger.error('[PRELOAD] Download failed', { url: item.url.slice(-50), error: err?.message }); logger.error('[PRELOAD] Download failed', {
url: item.url.slice(-50),
error: err?.message,
});
}) })
.finally(() => { .finally(() => {
activeDownloadsRef.current--; activeDownloadsRef.current--;
@ -340,7 +338,10 @@ export function usePreloadOrchestrator(
preloadedUrls.has(item.url) || preloadedUrls.has(item.url) ||
queueRef.current.some((q) => q.url === item.url) queueRef.current.some((q) => q.url === item.url)
) { ) {
logger.info('[PRELOAD] Skipping (already queued/preloaded)', { url: item.url.slice(-50), assetType: item.assetType }); logger.info('[PRELOAD] Skipping (already queued/preloaded)', {
url: item.url.slice(-50),
assetType: item.assetType,
});
return; return;
} }
@ -348,7 +349,7 @@ export function usePreloadOrchestrator(
url: item.url.slice(-60), url: item.url.slice(-60),
assetType: item.assetType, assetType: item.assetType,
priority: item.priority, priority: item.priority,
queueLength: queueRef.current.length + 1 queueLength: queueRef.current.length + 1,
}); });
// Insert in priority order (higher priority first) // Insert in priority order (higher priority first)
@ -389,29 +390,33 @@ export function usePreloadOrchestrator(
}, []); }, []);
// Get a cached asset as a blob URL (for video playback) // Get a cached asset as a blob URL (for video playback)
const getCachedBlobUrl = useCallback(async (url: string): Promise<string | null> => { // StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files)
if (typeof caches === 'undefined') return null; const getCachedBlobUrl = useCallback(
async (url: string): Promise<string | null> => {
try { try {
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); const blob = await StorageManager.getAsset(url);
const response = await cache.match(url); if (blob) {
if (!response) return null; return URL.createObjectURL(blob);
}
const blob = await response.blob(); return null;
return URL.createObjectURL(blob); } catch {
} catch { return null;
return null; }
} },
}, []); [],
);
// Check if URL is preloaded (in cache) // Check if URL is preloaded (in cache)
const isUrlPreloaded = useCallback(async (url: string): Promise<boolean> => { const isUrlPreloaded = useCallback(
// First check in-memory set async (url: string): Promise<boolean> => {
if (preloadedUrls.has(url)) return true; // First check in-memory set
if (preloadedUrls.has(url)) return true;
// Then check Cache API // Then check Cache API
return isUrlCached(url); return isUrlCached(url);
}, [preloadedUrls]); },
[preloadedUrls],
);
// React to page changes - preload neighbors // React to page changes - preload neighbors
useEffect(() => { useEffect(() => {
@ -432,7 +437,10 @@ export function usePreloadOrchestrator(
lastPreloadedPageRef.current = currentPageId; lastPreloadedPageRef.current = currentPageId;
lastPreloadedLinksCountRef.current = currentLinksCount; lastPreloadedLinksCountRef.current = currentLinksCount;
logger.info('[PRELOAD] Starting preload for page', { currentPageId, maxNeighborDepth }); logger.info('[PRELOAD] Starting preload for page', {
currentPageId,
maxNeighborDepth,
});
// Get prioritized assets based on current page // Get prioritized assets based on current page
const assets = neighborGraph.getPrioritizedAssets( const assets = neighborGraph.getPrioritizedAssets(
@ -442,7 +450,7 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Found assets from neighbor graph', { logger.info('[PRELOAD] Found assets from neighbor graph', {
assetCount: assets.length, assetCount: assets.length,
assets: assets.map(a => ({ type: a.assetType, url: a.url.slice(-50) })) assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })),
}); });
// Add background assets from pages // Add background assets from pages
@ -496,7 +504,9 @@ export function usePreloadOrchestrator(
neighbors.forEach(({ pageId }) => { neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId); const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url) { if (page?.background_image_url) {
const resolvedUrl = resolveAssetPlaybackUrl(page.background_image_url); const resolvedUrl = resolveAssetPlaybackUrl(
page.background_image_url,
);
if (resolvedUrl) { if (resolvedUrl) {
addToQueue({ addToQueue({
id: `bg-img-${pageId}`, id: `bg-img-${pageId}`,

View File

@ -4,26 +4,26 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
type MutableRefObject,
type RefObject, type RefObject,
} from 'react'; } from 'react';
import { logger } from '../lib/logger';
interface UseReversePlaybackOptions { interface UseReversePlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
duration: number;
onComplete: () => void; onComplete: () => void;
preloadedUrls?: Set<string>; preloadedUrls?: Set<string>;
videoUrl?: string; videoUrl?: string;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
} }
interface UseReversePlaybackResult { interface UseReversePlaybackResult {
startReverse: () => Promise<void>; startReverse: () => Promise<void>;
stopReverse: () => void; stopReverse: () => void;
isReversing: boolean; isReversing: boolean;
isBuffering: boolean;
canUseNativeReverse: boolean; canUseNativeReverse: boolean;
} }
// Feature detection for native reverse playback (Chrome 141+, Safari 16+)
function checkNativeReverseSupport(): boolean { function checkNativeReverseSupport(): boolean {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
@ -35,14 +35,13 @@ function checkNativeReverseSupport(): boolean {
} }
} }
// Native playbackRate = -1 (Chrome 141+, Safari 16+)
async function startNativeReverse( async function startNativeReverse(
video: HTMLVideoElement, video: HTMLVideoElement,
duration: number, duration: number,
onComplete: () => void onComplete: () => void,
): Promise<void> { didFinishRef: { current: boolean },
): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
// Seek to end
video.currentTime = duration; video.currentTime = duration;
video.playbackRate = -1; video.playbackRate = -1;
@ -52,188 +51,348 @@ async function startNativeReverse(
}; };
const onTimeUpdate = () => { const onTimeUpdate = () => {
if (didFinishRef.current) {
cleanup();
resolve(true);
return;
}
if (video.currentTime <= 0.05) { if (video.currentTime <= 0.05) {
cleanup(); cleanup();
video.pause(); video.pause();
video.currentTime = 0; video.currentTime = 0;
onComplete(); onComplete();
resolve(); resolve(true);
} }
}; };
video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('timeupdate', onTimeUpdate);
video.play().catch(() => { video.play().catch((err) => {
// Fallback if native reverse fails logger.error('Native reverse play failed:', err);
cleanup(); cleanup();
resolve(); resolve(false);
}); });
}); });
} }
// When video is already cached, seeking to end is instant function getBufferedEnd(video: HTMLVideoElement): number {
async function startPreloadedReverse( return video.buffered.length > 0
video: HTMLVideoElement, ? video.buffered.end(video.buffered.length - 1)
duration: number, : 0;
onComplete: () => void,
intervalRef: MutableRefObject<number | null>
): Promise<void> {
return new Promise((resolve) => {
video.pause();
video.currentTime = duration; // Instant seek (video is cached)
// Use 30 fps for smoother reverse when video is preloaded
const fps = 30;
const stepSize = 1 / fps;
intervalRef.current = window.setInterval(() => {
const newTime = video.currentTime - stepSize;
if (newTime <= 0) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
video.currentTime = 0;
onComplete();
resolve();
} else {
video.currentTime = newTime;
}
}, 1000 / fps);
});
}
// For non-preloaded videos, wait for canplaythrough then reverse
async function startBufferedReverse(
video: HTMLVideoElement,
duration: number,
onComplete: () => void,
intervalRef: MutableRefObject<number | null>
): Promise<void> {
return new Promise((resolve) => {
const startFrameStepping = () => {
video.pause();
video.currentTime = duration;
// Lower fps for non-cached videos (less seeking overhead)
const fps = 15;
const stepSize = 1 / fps;
intervalRef.current = window.setInterval(() => {
const newTime = video.currentTime - stepSize;
if (newTime <= 0) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
video.currentTime = 0;
onComplete();
resolve();
} else {
video.currentTime = newTime;
}
}, 1000 / fps);
};
// Check if video is already buffered enough
const isFullyBuffered =
video.readyState >= 4 ||
(video.buffered.length > 0 &&
video.buffered.end(video.buffered.length - 1) >= duration - 0.1);
if (isFullyBuffered) {
startFrameStepping();
return;
}
// Wait for buffering to complete
const onCanPlayThrough = () => {
video.removeEventListener('canplaythrough', onCanPlayThrough);
startFrameStepping();
};
video.addEventListener('canplaythrough', onCanPlayThrough);
// Also try seeking to end to force buffering
video.currentTime = duration;
video.play().catch(() => {
// If play fails, just wait for canplaythrough
});
// Timeout fallback - if canplaythrough doesn't fire within 3s, start anyway
setTimeout(() => {
video.removeEventListener('canplaythrough', onCanPlayThrough);
if (!intervalRef.current) {
startFrameStepping();
}
}, 3000);
});
} }
export function useReversePlayback( export function useReversePlayback(
options: UseReversePlaybackOptions options: UseReversePlaybackOptions,
): UseReversePlaybackResult { ): UseReversePlaybackResult {
const [isReversing, setIsReversing] = useState(false); const [isReversing, setIsReversing] = useState(false);
const intervalRef = useRef<number | null>(null); const [isBuffering, setIsBuffering] = useState(false);
const animationFrameRef = useRef<number | null>(null);
const didFinishRef = useRef(false);
const cleanupFnsRef = useRef<Array<() => void>>([]);
// Feature detection for native reverse
const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []); const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []);
const stopReverse = useCallback(() => { const cleanup = useCallback(() => {
if (intervalRef.current) { didFinishRef.current = true;
clearInterval(intervalRef.current); if (animationFrameRef.current !== null) {
intervalRef.current = null; cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
} }
setIsReversing(false); cleanupFnsRef.current.forEach((fn) => fn());
cleanupFnsRef.current = [];
// Reset playback rate if it was modified
const video = options.videoRef.current; const video = options.videoRef.current;
if (video && video.playbackRate !== 1) { if (video && video.playbackRate !== 1) {
video.playbackRate = 1; video.playbackRate = 1;
} }
}, [options.videoRef]); }, [options.videoRef]);
const stopReverse = useCallback(() => {
cleanup();
setIsReversing(false);
setIsBuffering(false);
}, [cleanup]);
const startReverse = useCallback(async () => { const startReverse = useCallback(async () => {
const video = options.videoRef.current; const video = options.videoRef.current;
if (!video) return; if (!video) return;
// Stop any existing reverse playback
stopReverse(); stopReverse();
didFinishRef.current = false;
setIsReversing(true); setIsReversing(true);
const { duration, onComplete, preloadedUrls, videoUrl } = options; const { onComplete, preloadedUrls, videoUrl, getCachedBlobUrl } = options;
// Strategy 1: Native playbackRate = -1 (smoothest) const actualDuration = video.duration;
if (canUseNativeReverse) { if (!Number.isFinite(actualDuration) || actualDuration <= 0) {
await startNativeReverse(video, duration, () => { logger.error('Invalid video duration for reverse playback', {
setIsReversing(false); videoDuration: video.duration,
onComplete(); readyState: video.readyState,
videoUrl,
}); });
return;
}
// Strategy 2: Check if video is preloaded (instant seek)
const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl);
if (isPreloaded) {
await startPreloadedReverse(video, duration, () => {
setIsReversing(false);
onComplete();
}, intervalRef);
return;
}
// Strategy 3: Wait for buffering then reverse
await startBufferedReverse(video, duration, () => {
setIsReversing(false); setIsReversing(false);
onComplete(); onComplete();
}, intervalRef); return;
}, [canUseNativeReverse, options, stopReverse]); }
const finishReverse = (reason: string) => {
if (didFinishRef.current) return;
didFinishRef.current = true;
cleanup();
setIsReversing(false);
setIsBuffering(false);
logger.info('Reverse playback finished', { reason });
onComplete();
};
// Strategy 1: Native playbackRate = -1
if (canUseNativeReverse) {
logger.info('Using native playbackRate = -1');
const success = await startNativeReverse(
video,
actualDuration,
() => finishReverse('native-complete'),
didFinishRef,
);
if (success) return;
logger.info('Native reverse failed, falling back to frame-stepping');
}
// Check if video is preloaded
const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl);
const fps = isPreloaded ? 30 : 15;
const stepSize = 1 / fps;
// Try to get blob URL from cache for better seeking
if (getCachedBlobUrl && videoUrl && !video.src.startsWith('blob:')) {
try {
const blobUrl = await getCachedBlobUrl(videoUrl);
if (blobUrl && !didFinishRef.current) {
logger.info('Using cached blob URL for reverse');
video.src = blobUrl;
video.load();
await new Promise<void>((resolve) => {
const onLoaded = () => {
video.removeEventListener('loadeddata', onLoaded);
resolve();
};
video.addEventListener('loadeddata', onLoaded);
setTimeout(resolve, 2000);
});
}
} catch (err) {
logger.warn('Failed to get cached blob URL', { err });
}
}
const startFrameStepping = (targetTime: number) => {
const maxBuffered = getBufferedEnd(video);
const safeTarget = Math.min(targetTime, maxBuffered);
logger.info('Preparing reverse seek', {
requestedTarget: targetTime,
maxBuffered,
safeTarget,
readyState: video.readyState,
});
if (safeTarget < 0.1) {
logger.info('No buffered range available');
finishReverse('no-buffered');
return;
}
const beginFrameStepping = () => {
video.pause();
let lastFrameTime = performance.now();
let stepCount = 0;
const step = (currentFrameTime: number) => {
if (didFinishRef.current) {
animationFrameRef.current = null;
return;
}
if (!video.paused) {
video.pause();
}
const elapsed = currentFrameTime - lastFrameTime;
if (elapsed < 1000 / fps) {
animationFrameRef.current = requestAnimationFrame(step);
return;
}
lastFrameTime = currentFrameTime;
stepCount++;
const currentTime = video.currentTime;
const newTime = currentTime - stepSize;
if (stepCount % 30 === 0) {
logger.info('Frame-stepping progress', { stepCount, currentTime });
}
if (newTime <= 0) {
animationFrameRef.current = null;
video.currentTime = 0;
finishReverse('reverse-complete');
} else {
video.currentTime = newTime;
animationFrameRef.current = requestAnimationFrame(step);
}
};
animationFrameRef.current = requestAnimationFrame(step);
};
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
if (didFinishRef.current) return;
if (video.currentTime < 0.1) {
logger.info('Seek failed, retrying');
const onRetry = () => {
video.removeEventListener('seeked', onRetry);
if (didFinishRef.current) return;
if (video.currentTime >= 0.1) {
beginFrameStepping();
} else {
finishReverse('seek-failed');
}
};
video.addEventListener('seeked', onRetry);
cleanupFnsRef.current.push(() =>
video.removeEventListener('seeked', onRetry),
);
video.currentTime = safeTarget;
return;
}
beginFrameStepping();
};
video.addEventListener('seeked', onSeeked);
cleanupFnsRef.current.push(() =>
video.removeEventListener('seeked', onSeeked),
);
if (!video.paused) {
const onPause = () => {
video.removeEventListener('pause', onPause);
if (didFinishRef.current) return;
requestAnimationFrame(() => {
if (!didFinishRef.current) {
video.currentTime = safeTarget;
}
});
};
video.addEventListener('pause', onPause);
cleanupFnsRef.current.push(() =>
video.removeEventListener('pause', onPause),
);
video.pause();
} else {
video.currentTime = safeTarget;
}
};
const bufferedEnd = getBufferedEnd(video);
logger.info('Starting reverse playback', {
duration: actualDuration,
bufferedEnd,
isPreloaded,
readyState: video.readyState,
});
// If already fully buffered, start immediately
if (bufferedEnd >= actualDuration - 0.1) {
logger.info('Video fully buffered, starting immediately');
startFrameStepping(actualDuration);
return;
}
// Need to buffer more
setIsBuffering(true);
let progressCheckCount = 0;
const maxProgressChecks = 160; // ~8 seconds at 20 checks/sec
const onProgress = () => {
if (didFinishRef.current) return;
progressCheckCount++;
const currentBuffered = getBufferedEnd(video);
if (currentBuffered >= actualDuration - 0.1) {
setIsBuffering(false);
startFrameStepping(actualDuration);
return;
}
if (progressCheckCount >= maxProgressChecks) {
setIsBuffering(false);
if (currentBuffered >= 0.5) {
startFrameStepping(currentBuffered);
} else {
finishReverse('buffer-timeout');
}
}
};
const onCanPlayThrough = () => {
if (didFinishRef.current) return;
const currentBuffered = getBufferedEnd(video);
if (currentBuffered >= 0.5) {
setIsBuffering(false);
const targetTime = Math.min(currentBuffered, actualDuration);
startFrameStepping(targetTime);
}
};
const onEnded = () => {
if (didFinishRef.current) return;
setIsBuffering(false);
const currentBuffered = getBufferedEnd(video);
if (currentBuffered >= 0.5) {
startFrameStepping(currentBuffered);
} else {
startFrameStepping(actualDuration);
}
};
video.addEventListener('progress', onProgress);
video.addEventListener('canplaythrough', onCanPlayThrough);
video.addEventListener('ended', onEnded);
cleanupFnsRef.current.push(
() => video.removeEventListener('progress', onProgress),
() => video.removeEventListener('canplaythrough', onCanPlayThrough),
() => video.removeEventListener('ended', onEnded),
);
// Start playback to trigger buffering
video.muted = true;
video.currentTime = 0;
video.play().catch(() => undefined);
// Fallback timeout
const timeoutId = setTimeout(() => {
if (didFinishRef.current) return;
setIsBuffering(false);
const currentBuffered = getBufferedEnd(video);
if (currentBuffered >= 0.5) {
startFrameStepping(currentBuffered);
} else {
finishReverse('buffer-timeout');
}
}, 8000);
cleanupFnsRef.current.push(() => clearTimeout(timeoutId));
}, [canUseNativeReverse, options, stopReverse, cleanup]);
// Cleanup on unmount
useEffect(() => () => stopReverse(), [stopReverse]); useEffect(() => () => stopReverse(), [stopReverse]);
return { startReverse, stopReverse, isReversing, canUseNativeReverse }; return {
startReverse,
stopReverse,
isReversing,
isBuffering,
canUseNativeReverse,
};
} }

View File

@ -0,0 +1,635 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
import { useReversePlayback } from './useReversePlayback';
export type ReverseMode = 'none' | 'reverse' | 'separate';
export interface TransitionConfig {
videoUrl: string;
reverseMode: ReverseMode;
reverseVideoUrl?: string;
durationSec?: number;
targetPageId?: string;
displayName?: string;
}
export interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null;
onComplete: (targetPageId?: string) => void;
onError?: (reason: string) => void;
timeouts?: {
playbackStartMs?: number;
durationBufferMs?: number;
hardTimeoutMs?: number;
};
features?: {
useBlobUrl?: boolean;
preDecodeImages?: boolean;
getTargetPageImages?: () => string[];
};
preload?: {
preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
};
}
export type PlaybackPhase =
| 'idle'
| 'preparing'
| 'playing'
| 'reversing'
| 'finishing'
| 'completed';
export interface UseTransitionPlaybackResult {
phase: PlaybackPhase;
isBuffering: boolean;
isReversing: boolean;
cancel: () => void;
forceComplete: () => void;
}
const DEFAULT_TIMEOUTS = {
playbackStartMs: 3000,
durationBufferMs: 200,
hardTimeoutMs: 45000,
};
function getBufferedEnd(video: HTMLVideoElement): number {
return video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
}
function shouldLoadViaBlob(
url: string,
reverseMode: ReverseMode,
useBlobUrlOption?: boolean,
): boolean {
if (useBlobUrlOption === false) return false;
if (reverseMode === 'reverse') return true;
if (useBlobUrlOption === true) return true;
try {
const parsedUrl = new URL(url, window.location.origin);
const isSameOrigin = parsedUrl.origin === window.location.origin;
if (!isSameOrigin) return false;
return (
parsedUrl.pathname === '/api/file/download' ||
parsedUrl.pathname === '/file/download'
);
} catch {
return false;
}
}
function buildBlobRequestUrl(url: string): string {
if (url.startsWith('/api/')) {
return url.replace(/^\/api(?=\/)/, '');
}
return url;
}
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return;
const decodePromises = urls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
export function useTransitionPlayback(
options: UseTransitionPlaybackOptions,
): UseTransitionPlaybackResult {
const {
videoRef,
transition,
onComplete,
onError,
timeouts: customTimeouts,
features,
preload,
} = options;
const playbackStartMs =
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
const durationBufferMs =
customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs;
const hardTimeoutMs =
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
const [phase, setPhase] = useState<PlaybackPhase>('idle');
const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false);
const didFinishRef = useRef(false);
const didStartPlaybackRef = useRef(false);
const activeSourceUrlRef = useRef<string | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const onCompleteRef = useRef(onComplete);
const onErrorRef = useRef(onError);
const transitionRef = useRef(transition);
const featuresRef = useRef(features);
const preloadRef = useRef(preload);
const startReverseRef = useRef<(() => Promise<void>) | null>(null);
const stopReverseRef = useRef<(() => void) | null>(null);
const sourceUrl = useMemo(() => {
if (!transition) return '';
return transition.reverseMode === 'separate' && transition.reverseVideoUrl
? transition.reverseVideoUrl
: transition.videoUrl;
}, [transition]);
const clearTimers = useCallback(() => {
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (finishTimerRef.current) {
clearTimeout(finishTimerRef.current);
finishTimerRef.current = null;
}
if (hardTimeoutTimerRef.current) {
clearTimeout(hardTimeoutTimerRef.current);
hardTimeoutTimerRef.current = null;
}
}, []);
const revokeBlobUrl = useCallback((force = false) => {
if (!force || !lastLoadedBlobUrlRef.current) return;
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
lastLoadedBlobUrlRef.current = null;
}, []);
const finishPlayback = useCallback(
async (reason: string) => {
if (didFinishRef.current) return;
didFinishRef.current = true;
activeSourceUrlRef.current = null;
clearTimers();
const video = videoRef.current;
if (video) {
video.pause();
}
const currentTransition = transitionRef.current;
const currentFeatures = featuresRef.current;
logger.info('Transition playback finished', {
reason,
displayName: currentTransition?.displayName,
targetPageId: currentTransition?.targetPageId,
});
setPhase('finishing');
if (
currentFeatures?.preDecodeImages &&
currentFeatures.getTargetPageImages &&
currentTransition?.targetPageId
) {
try {
const imageUrls = currentFeatures.getTargetPageImages();
await waitForImages(imageUrls);
} catch {
// Ignore pre-decode errors
}
}
setPhase('completed');
onCompleteRef.current(currentTransition?.targetPageId);
},
[clearTimers, videoRef],
);
const handleError = useCallback(
(reason: string) => {
if (didFinishRef.current) return;
logger.error('Transition playback error', { reason });
onErrorRef.current?.(reason);
finishPlayback(reason);
},
[finishPlayback],
);
const handleReverseComplete = useCallback(() => {
finishPlayback('reverse-complete');
}, [finishPlayback]);
const {
startReverse,
stopReverse,
isReversing,
isBuffering: isReverseBuffering,
} = useReversePlayback({
videoRef,
onComplete: handleReverseComplete,
preloadedUrls: preload?.preloadedUrls,
videoUrl: sourceUrl,
getCachedBlobUrl: preload?.getCachedBlobUrl,
});
useEffect(() => {
onCompleteRef.current = onComplete;
onErrorRef.current = onError;
transitionRef.current = transition;
featuresRef.current = features;
preloadRef.current = preload;
startReverseRef.current = startReverse;
stopReverseRef.current = stopReverse;
});
useEffect(() => {
setIsReverseBufferingLocal(isReverseBuffering);
}, [isReverseBuffering]);
const cancel = useCallback(() => {
if (phase === 'idle') return;
clearTimers();
stopReverseRef.current?.();
didFinishRef.current = true;
setPhase('idle');
const video = videoRef.current;
if (video) {
video.pause();
video.removeAttribute('src');
video.load();
}
revokeBlobUrl(true);
}, [phase, clearTimers, videoRef, revokeBlobUrl]);
const forceComplete = useCallback(() => {
finishPlayback('forced');
}, [finishPlayback]);
useEffect(() => {
const video = videoRef.current;
const currentTransition = transitionRef.current;
if (!currentTransition || !video || !sourceUrl) {
return;
}
if (activeSourceUrlRef.current === sourceUrl) {
logger.info('Skipping duplicate effect for same source', { sourceUrl });
return;
}
activeSourceUrlRef.current = sourceUrl;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
setPhase('preparing');
const isReverseMode = currentTransition.reverseMode === 'reverse';
const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => {
if (!video.error) return null;
const mediaError = video.error as MediaError & { message?: string };
return {
code: mediaError.code,
message: mediaError.message || '',
};
};
const logIssue = (reason: string, error?: unknown) => {
logger.error('Transition playback issue:', {
reason,
src: video.currentSrc || sourceUrl,
readyState: video.readyState,
networkState: video.networkState,
duration: video.duration,
configuredDurationSec,
reverseMode: currentTransition.reverseMode,
mediaError: getMediaErrorDetails(),
error: error instanceof Error ? error : { error },
});
};
const scheduleFinishByDuration = (durationSec: number) => {
if (
!Number.isFinite(durationSec) ||
durationSec <= 0 ||
finishTimerRef.current
) {
return;
}
finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'),
durationSec * 1000 + durationBufferMs,
);
};
const attemptPlay = () => {
video.play().catch((playError) => {
if (!isReverseMode) {
logIssue('play-failed', playError);
}
});
};
const resolvePlayableSource = async (): Promise<string> => {
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
logger.info('Reusing cached blob URL');
return lastLoadedBlobUrlRef.current;
}
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current !== sourceUrl
) {
revokeBlobUrl(true);
}
const needsBlobUrl = shouldLoadViaBlob(
sourceUrl,
currentTransition.reverseMode,
featuresRef.current?.useBlobUrl,
);
if (!needsBlobUrl) {
return sourceUrl;
}
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
logger.info('Using preloaded blob URL from cache', {
reverseMode: currentTransition.reverseMode,
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', {
cacheError,
});
}
}
logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode,
});
const token =
typeof window !== 'undefined'
? localStorage.getItem('token') || ''
: '';
const requestUrl = buildBlobRequestUrl(sourceUrl);
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blobUrl = URL.createObjectURL(response.data);
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created blob URL for video', {
blobUrl: blobUrl.substring(0, 50),
});
return blobUrl;
};
const loadAndPlay = async () => {
logger.info('loadAndPlay called', {
reverseMode: currentTransition.reverseMode,
sourceUrl,
});
didStartPlaybackRef.current = false;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
}
try {
const playableSourceUrl = await resolvePlayableSource();
if (didFinishRef.current) return;
video.pause();
stopReverseRef.current?.();
const isSameSource =
lastLoadedSourceUrlRef.current === playableSourceUrl;
if (isReverseMode && isSameSource && video.readyState >= 2) {
logger.info('Reusing buffered video for reverse', {
readyState: video.readyState,
duration: video.duration,
bufferedEnd: getBufferedEnd(video),
});
setPhase('reversing');
void startReverseRef.current?.();
return;
}
video.src = playableSourceUrl;
video.currentTime = 0;
video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl;
attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => {
if (didStartPlaybackRef.current || didFinishRef.current) return;
logIssue('playback-start-slow');
if (isReverseMode) {
didStartPlaybackRef.current = true;
setPhase('reversing');
void startReverseRef.current?.();
} else {
attemptPlay();
}
}, playbackStartMs);
} catch (error) {
logIssue('source-prepare-failed', error);
handleError('source-prepare-failed');
}
};
const onLoadedMetadata = () => {
if (didFinishRef.current) return;
if (!isReverseMode) {
video.currentTime = 0;
attemptPlay();
}
};
const onCanPlayThrough = () => {
if (didFinishRef.current) return;
logger.info('canplaythrough fired', {
reverseMode: currentTransition.reverseMode,
didStartPlayback: didStartPlaybackRef.current,
});
if (isReverseMode && !didStartPlaybackRef.current) {
didStartPlaybackRef.current = true;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
video.pause();
setPhase('reversing');
void startReverseRef.current?.();
}
};
const onCanPlay = () => {
if (didFinishRef.current) return;
if (isReverseMode && didStartPlaybackRef.current) return;
attemptPlay();
};
const onPlaying = () => {
logger.info('onPlaying fired', {
reverseMode: currentTransition.reverseMode,
didStartPlayback: didStartPlaybackRef.current,
didFinish: didFinishRef.current,
});
if (didFinishRef.current) return;
if (isReverseMode && !didStartPlaybackRef.current) {
logger.info('Triggering reverse from onPlaying');
didStartPlaybackRef.current = true;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
video.pause();
setPhase('reversing');
void startReverseRef.current?.();
return;
}
didStartPlaybackRef.current = true;
setPhase('playing');
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (!isReverseMode) {
const mediaDurationSec = Number(video.duration);
const durationSec =
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
? mediaDurationSec
: NaN;
if (Number.isFinite(durationSec) && durationSec > 0) {
scheduleFinishByDuration(durationSec);
}
}
};
const onEnded = () => finishPlayback('ended');
const onVideoError = () => {
if (didFinishRef.current) return;
logIssue('video-error');
handleError('video-error');
};
const onAbort = () => {
if (didFinishRef.current) return;
logIssue('video-abort');
handleError('video-abort');
};
const onStalled = () => {
if (didFinishRef.current) return;
logIssue('video-stalled');
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplaythrough', onCanPlayThrough);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded);
video.addEventListener('error', onVideoError);
video.addEventListener('abort', onAbort);
video.addEventListener('stalled', onStalled);
hardTimeoutTimerRef.current = setTimeout(() => {
if (didFinishRef.current) return;
logIssue('hard-timeout');
handleError('hard-timeout');
}, hardTimeoutMs);
void loadAndPlay();
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplaythrough', onCanPlayThrough);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
clearTimers();
stopReverseRef.current?.();
};
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]);
useEffect(() => {
if (!transition) {
setPhase('idle');
activeSourceUrlRef.current = null;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
}
}, [transition]);
return {
phase,
isBuffering: isReverseBufferingLocal,
isReversing,
cancel,
forceComplete,
};
}

View File

@ -0,0 +1,135 @@
/**
* Element Styles
*
* Unified types and utilities for UI element CSS styling.
* Used by constructor, RuntimePresentation, and ui-elements admin pages.
*/
import type { CSSProperties } from 'react';
/**
* CSS style properties supported by UI elements.
* These properties can be set in ui-elements defaults and applied at runtime.
*/
export interface ElementStyleProperties {
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string;
padding?: string;
gap?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
border?: string;
borderRadius?: string;
opacity?: string;
boxShadow?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: string;
}
/**
* Array of CSS property names for iteration.
* Used when applying styles from element data to CSSProperties.
*/
export const ELEMENT_STYLE_PROPS = [
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'margin',
'padding',
'gap',
'fontSize',
'lineHeight',
'fontWeight',
'border',
'borderRadius',
'boxShadow',
'display',
'position',
'justifyContent',
'alignItems',
'textAlign',
'zIndex',
] as const;
/**
* Properties that need numeric conversion
*/
const NUMERIC_PROPS = ['opacity', 'zIndex'] as const;
/**
* Get trimmed CSS value from unknown input
*/
const getTrimmedValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
/**
* Build React CSSProperties from element style properties.
* Handles type coercion for special properties like opacity and zIndex.
*
* @param element - Object containing style properties
* @returns React CSSProperties object
*
* @example
* const style = buildElementStyle({
* width: '100px',
* opacity: '0.5',
* display: 'flex',
* });
*/
export function buildElementStyle(
element: Partial<ElementStyleProperties>,
): CSSProperties {
const style: CSSProperties = {};
const source = element as Record<string, unknown>;
// Apply string properties
ELEMENT_STYLE_PROPS.forEach((prop) => {
const value = getTrimmedValue(source[prop]);
if (value) {
(style as Record<string, unknown>)[prop] = value;
}
});
// Handle opacity (needs numeric conversion)
const opacityValue = getTrimmedValue(source.opacity);
if (opacityValue) {
const parsed = Number(opacityValue);
if (Number.isFinite(parsed)) {
style.opacity = parsed;
}
}
// Handle zIndex (needs numeric conversion, already in style from loop but override with number)
const zIndexValue = getTrimmedValue(source.zIndex);
if (zIndexValue) {
const parsed = Number(zIndexValue);
if (Number.isFinite(parsed)) {
style.zIndex = parsed;
}
}
return style;
}
/**
* All style property names including numeric ones.
* Used for form state management in ui-elements admin.
*/
export const ALL_STYLE_PROPS = [...ELEMENT_STYLE_PROPS, 'opacity'] as const;
export type StylePropName = (typeof ALL_STYLE_PROPS)[number];

View File

@ -0,0 +1,141 @@
/**
* Image Pre-Decode Utilities
*
* Utilities for pre-decoding images before displaying to prevent white flashes
* during page transitions.
*/
import { resolveAssetPlaybackUrl } from './assetUrl';
import { PRELOAD_CONFIG } from '../config/preload.config';
/**
* Minimal page interface for image extraction
*/
export interface PageWithImages {
background_image_url?: string;
ui_schema_json?: string | Record<string, unknown>;
}
/**
* Decodes a list of image URLs in parallel with a timeout.
*
* @param urls - Array of image URLs to decode
* @param timeoutMs - Maximum time to wait for all images (default: 2000ms)
* @returns Promise that resolves when all images are decoded or timeout is reached
*
* @example
* await decodeImages(['/image1.jpg', '/image2.jpg']);
*/
export const decodeImages = async (
urls: string[],
timeoutMs = 2000,
): Promise<void> => {
if (urls.length === 0) return;
const decodePromises = urls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
};
/**
* Extracts all image URLs from a page (background + element images).
*
* @param page - Page object with background_image_url and ui_schema_json
* @returns Array of resolved image URLs
*
* @example
* const imageUrls = extractPageImageUrls(page);
*/
export const extractPageImageUrls = (
page: PageWithImages | null,
): string[] => {
if (!page) return [];
const imageUrls: string[] = [];
// Background image
if (page.background_image_url) {
const url = resolveAssetPlaybackUrl(page.background_image_url);
if (url) imageUrls.push(url);
}
// Parse ui_schema_json for element images
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
const { images: imageFields, nested: nestedFields } =
PRELOAD_CONFIG.assetFields;
pageElements.forEach((el: Record<string, unknown>) => {
// Direct image fields
imageFields.forEach((field) => {
const value = el[field];
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
// Nested arrays (galleryCards, carouselSlides)
nestedFields.forEach((nestedField) => {
const items = el[nestedField];
if (Array.isArray(items)) {
items.forEach((item: Record<string, unknown>) => {
if (typeof item.imageUrl === 'string' && item.imageUrl) {
const url = resolveAssetPlaybackUrl(item.imageUrl);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
}
});
});
} catch {
// Ignore parse errors
}
return imageUrls;
};
/**
* Waits for all images on a page to be decoded before continuing.
* This eliminates white flash by ensuring images are ready to paint.
*
* @param page - Page object with images to decode
* @param timeoutMs - Maximum time to wait (default: 2000ms)
*
* @example
* await waitForPageImages(targetPage);
* setActivePage(targetPage);
*/
export const waitForPageImages = async (
page: PageWithImages | null,
timeoutMs = 2000,
): Promise<void> => {
const imageUrls = extractPageImageUrls(page);
await decodeImages(imageUrls, timeoutMs);
};

View File

@ -0,0 +1,119 @@
/**
* Probe media duration from a URL or File using HTML5 media element.
* This extracts the actual duration from the media file metadata.
*/
import { logger } from './logger';
export interface MediaDurationResult {
duration: number;
width?: number;
height?: number;
}
/**
* Probe duration from a media URL
*/
export function probeMediaDuration(
source: string | File,
mediaType: 'video' | 'audio',
timeoutMs = 10000,
): Promise<MediaDurationResult | null> {
return new Promise((resolve) => {
const mediaElement =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.removeEventListener('error', onError);
if (timeoutId) clearTimeout(timeoutId);
mediaElement.pause();
if (mediaElement.src.startsWith('blob:')) {
URL.revokeObjectURL(mediaElement.src);
}
mediaElement.removeAttribute('src');
mediaElement.load();
};
const onLoadedMetadata = () => {
const duration = mediaElement.duration;
if (!Number.isFinite(duration) || duration <= 0) {
logger.warn('Invalid duration from media metadata', {
duration,
source: typeof source === 'string' ? source : source.name,
});
cleanup();
resolve(null);
return;
}
const result: MediaDurationResult = { duration };
if (mediaType === 'video' && mediaElement instanceof HTMLVideoElement) {
if (mediaElement.videoWidth > 0) {
result.width = mediaElement.videoWidth;
}
if (mediaElement.videoHeight > 0) {
result.height = mediaElement.videoHeight;
}
}
cleanup();
resolve(result);
};
const onError = () => {
logger.warn('Failed to load media for duration probe', {
source: typeof source === 'string' ? source : source.name,
error: mediaElement.error?.message,
});
cleanup();
resolve(null);
};
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.addEventListener('error', onError);
timeoutId = setTimeout(() => {
logger.warn('Duration probe timeout', {
source: typeof source === 'string' ? source : source.name,
timeoutMs,
});
cleanup();
resolve(null);
}, timeoutMs);
mediaElement.preload = 'metadata';
if (typeof source === 'string') {
mediaElement.src = source;
} else {
mediaElement.src = URL.createObjectURL(source);
}
mediaElement.load();
});
}
/**
* Check if a MIME type is a video type
*/
export function isVideoMimeType(mimeType: string | null | undefined): boolean {
if (!mimeType) return false;
return mimeType.startsWith('video/');
}
/**
* Check if a MIME type is an audio type
*/
export function isAudioMimeType(mimeType: string | null | undefined): boolean {
if (!mimeType) return false;
return mimeType.startsWith('audio/');
}

View File

@ -0,0 +1,89 @@
/**
* JSON Parsing Utilities
*
* Shared utilities for safely parsing JSON values that may be strings or already parsed.
*/
/**
* Parses a value that might be a JSON string or already an object.
* Returns a typed object with fallback support.
*
* @param value - The value to parse (string, object, or undefined)
* @param fallback - Fallback value if parsing fails (defaults to empty object)
* @returns The parsed object or fallback
*
* @example
* const schema = parseJsonObject<ConstructorSchema>(page.ui_schema_json, {});
* const settings = parseJsonObject<Settings>(settingsValue, { enabled: false });
*/
export const parseJsonObject = <T>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T;
}
if (typeof value === 'object') {
return value as T;
}
return (fallback || ({} as T)) as T;
} catch {
return (fallback || ({} as T)) as T;
}
};
/**
* Parses a JSON field that might be a string, object, or null.
* More lenient than parseJsonObject - returns the original value on parse error.
*
* @param value - The value to parse
* @returns The parsed value, original value, or null
*
* @example
* const content = parseJsonField(element.content_json);
* const style = parseJsonField(element.style_json);
*/
export const parseJsonField = (value: unknown): unknown => {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return value;
if (!value.trim()) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
};
/**
* Extracts preview text from parsed content by checking common text field names.
*
* @param content - Parsed content object
* @returns The first text value found, or null
*
* @example
* const previewText = getElementPreviewText(parseJsonField(element.content_json));
*/
export const getElementPreviewText = (content: unknown): string | null => {
if (typeof content === 'string') return content;
if (!content || typeof content !== 'object') return null;
const candidateKeys = [
'title',
'text',
'subtitle',
'description',
'body',
'label',
'value',
];
const contentRecord = content as Record<string, unknown>;
const firstTextKey = candidateKeys.find(
(key) => typeof contentRecord[key] === 'string',
);
return firstTextKey ? String(contentRecord[firstTextKey]) : null;
};

View File

@ -274,7 +274,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<link rel='icon' href='/favicon.svg' /> <link rel='icon' href='/favicon.svg' />
<link rel='manifest' href='/manifest.json' /> <link rel='manifest' href='/manifest.json' />
<meta name='theme-color' content='#3B82F6' /> <meta name='theme-color' content='#3B82F6' />
<meta name='apple-mobile-web-app-capable' content='yes' /> <meta name='mobile-web-app-capable' content='yes' />
<meta <meta
name='apple-mobile-web-app-status-bar-style' name='apple-mobile-web-app-status-bar-style'
content='default' content='default'

File diff suppressed because it is too large Load Diff

View File

@ -201,7 +201,7 @@ export default function Login() {
</BaseButtons> </BaseButtons>
<br /> <br />
<p className={'text-center'}> <p className={'text-center'}>
Don't have an account yet?{' '} Don&apos;t have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}> <Link className={`${textColor}`} href={'/register'}>
New Account New Account
</Link> </Link>

View File

@ -0,0 +1,30 @@
/**
* Production Presentation Page
* URL: /p/[projectSlug] (e.g., /p/cardiff)
*/
import { ReactElement } from 'react';
import { useRouter } from 'next/router';
import RuntimePresentation from '../../../components/RuntimePresentation';
import LayoutGuest from '../../../layouts/Guest';
export default function ProductionPresentation() {
const router = useRouter();
const { projectSlug } = router.query;
if (!projectSlug || typeof projectSlug !== 'string') {
return (
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
<div className='text-white'>Loading...</div>
</div>
);
}
return (
<RuntimePresentation projectSlug={projectSlug} environment='production' />
);
}
ProductionPresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,28 @@
/**
* Stage Presentation Page
* URL: /p/[projectSlug]/stage (e.g., /p/cardiff/stage)
*/
import { ReactElement } from 'react';
import { useRouter } from 'next/router';
import RuntimePresentation from '../../../components/RuntimePresentation';
import LayoutGuest from '../../../layouts/Guest';
export default function StagePresentation() {
const router = useRouter();
const { projectSlug } = router.query;
if (!projectSlug || typeof projectSlug !== 'string') {
return (
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
<div className='text-white'>Loading...</div>
</div>
);
}
return <RuntimePresentation projectSlug={projectSlug} environment='stage' />;
}
StagePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -16,6 +16,7 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { parseJsonObject } from '../../lib/parseJson';
type TourPage = { type TourPage = {
id: string; id: string;
@ -45,29 +46,6 @@ type ProjectElementItem = {
name: string; name: string;
}; };
const parseJsonObject = (value?: unknown): Record<string, any> => {
if (!value) return {};
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
}
if (typeof value === 'object') {
return value as Record<string, any>;
}
return {};
} catch (error) {
logger.error(
'Failed to parse page schema JSON on pages elements list:',
error instanceof Error ? error : { error },
);
return {};
}
};
const toElementLabel = (value: string) => const toElementLabel = (value: string) =>
value value
.split('_') .split('_')

View File

@ -25,6 +25,12 @@ import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions'; import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks'; import { useAppSelector } from '../../stores/hooks';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { parseJsonObject } from '../../lib/parseJson';
import type {
BaseCanvasElement,
GalleryCard,
CarouselSlide,
} from '../../types/constructor';
type TourPage = { type TourPage = {
id: string; id: string;
@ -34,57 +40,14 @@ type TourPage = {
environment?: string; environment?: string;
source_key?: string; source_key?: string;
requires_auth?: boolean; requires_auth?: boolean;
ui_schema_json?: Record<string, any> | string; ui_schema_json?: Record<string, unknown> | string;
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; background_loop?: boolean;
}; };
type GalleryCard = { type ConstructorElement = BaseCanvasElement & {
id: string;
imageUrl: string;
title: string;
description: string;
};
type CarouselSlide = {
id: string;
imageUrl: string;
caption: string;
};
type ConstructorElement = {
id: string;
type: string;
label?: string;
xPercent?: number;
yPercent?: number;
width?: string;
height?: string;
minWidth?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
margin?: string;
padding?: string;
gap?: string;
fontSize?: string;
lineHeight?: string;
fontWeight?: string;
border?: string;
borderRadius?: string;
opacity?: string;
boxShadow?: string;
display?: string;
position?: string;
justifyContent?: string;
alignItems?: string;
textAlign?: string;
zIndex?: string;
appearDelaySec?: number;
appearDurationSec?: number | null;
iconUrl?: string;
navLabel?: string; navLabel?: string;
navType?: 'forward' | 'back'; navType?: 'forward' | 'back';
navDisabled?: boolean; navDisabled?: boolean;
@ -118,29 +81,6 @@ type ConstructorSchema = {
elements?: ConstructorElement[]; elements?: ConstructorElement[];
}; };
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T;
}
if (typeof value === 'object') {
return value as T;
}
return (fallback || ({} as T)) as T;
} catch (error) {
logger.error(
'Failed to parse JSON on element edit page:',
error instanceof Error ? error : { error },
);
return (fallback || ({} as T)) as T;
}
};
const toStringQuery = (value: string | string[] | undefined) => { const toStringQuery = (value: string | string[] | undefined) => {
if (Array.isArray(value)) return String(value[0] || '').trim(); if (Array.isArray(value)) return String(value[0] || '').trim();
return String(value || '').trim(); return String(value || '').trim();

View File

@ -31,10 +31,6 @@ type ProjectData = {
description?: string | null; description?: string | null;
}; };
type RuntimeContextData = {
rootDomain?: string | null;
};
const ProjectWorkspacePage = () => { const ProjectWorkspacePage = () => {
const router = useRouter(); const router = useRouter();
const { projectsId } = router.query; const { projectsId } = router.query;
@ -45,8 +41,6 @@ const ProjectWorkspacePage = () => {
}, [projectsId]); }, [projectsId]);
const [project, setProject] = useState<ProjectData | null>(null); const [project, setProject] = useState<ProjectData | null>(null);
const [runtimeContext, setRuntimeContext] =
useState<RuntimeContextData | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [isPublishModalActive, setIsPublishModalActive] = useState(false); const [isPublishModalActive, setIsPublishModalActive] = useState(false);
@ -63,34 +57,6 @@ const ProjectWorkspacePage = () => {
try { try {
const response = await axios.get(`/projects/${projectId}`); const response = await axios.get(`/projects/${projectId}`);
setProject(response?.data || null); setProject(response?.data || null);
try {
const runtimeContextResponse = await axios.get('/runtime-context', {
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 503,
});
if (runtimeContextResponse.status === 503) {
setRuntimeContext(null);
} else {
setRuntimeContext(runtimeContextResponse?.data || null);
}
} catch (runtimeContextError) {
if (
axios.isAxiosError(runtimeContextError) &&
runtimeContextError.response?.status === 503
) {
setRuntimeContext(null);
return;
}
logger.error(
'Failed to load runtime context:',
runtimeContextError instanceof Error
? runtimeContextError
: { error: runtimeContextError },
);
setRuntimeContext(null);
}
} catch (error: any) { } catch (error: any) {
setErrorMessage( setErrorMessage(
error?.response?.data?.message || error?.response?.data?.message ||
@ -166,38 +132,17 @@ const ProjectWorkspacePage = () => {
const presentationLinks = useMemo(() => { const presentationLinks = useMemo(() => {
const projectSlug = project?.slug?.trim(); const projectSlug = project?.slug?.trim();
const inferredRootDomain =
typeof window !== 'undefined'
? (() => {
const host = window.location.hostname;
if (!host || host === 'localhost' || host === '127.0.0.1')
return '';
const hostParts = host.split('.').filter(Boolean); if (!projectSlug) {
if (hostParts.length <= 2) return host;
if (hostParts[0] === 'admin') return hostParts.slice(1).join('.');
if (hostParts[0] === 'stage' && hostParts.length > 2)
return hostParts.slice(2).join('.');
return hostParts.slice(1).join('.');
})()
: '';
const rootDomain = runtimeContext?.rootDomain?.trim() || inferredRootDomain;
if (!projectSlug || !rootDomain) {
return { production: '', stage: '' }; return { production: '', stage: '' };
} }
const protocol = // Use path-based URLs that work without DNS configuration
typeof window !== 'undefined' && window.location.protocol === 'http:'
? 'http'
: 'https';
return { return {
production: `${protocol}://${projectSlug}.${rootDomain}`, production: `/p/${projectSlug}`,
stage: `${protocol}://stage.${projectSlug}.${rootDomain}`, stage: `/p/${projectSlug}/stage`,
}; };
}, [project?.slug, runtimeContext?.rootDomain]); }, [project?.slug]);
const openPresentation = (url: string, label: string) => { const openPresentation = (url: string, label: string) => {
if (!url) { if (!url) {

View File

@ -15,10 +15,20 @@ import CardBox from '../components/CardBox';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { useReversePlayback } from '../hooks/useReversePlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { usePageNavigation } from '../hooks/usePageNavigation';
import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline'; import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonField, getElementPreviewText } from '../lib/parseJson';
import type {
RuntimeProject,
RuntimePage,
RuntimeElement,
RuntimePageLink,
RuntimeTransition,
TransitionOverlayState,
} from '../types/runtime';
type RuntimeMode = 'admin' | 'stage' | 'production' | 'unknown'; type RuntimeMode = 'admin' | 'stage' | 'production' | 'unknown';
@ -27,106 +37,8 @@ type RuntimeContext = {
projectSlug: string | null; projectSlug: string | null;
}; };
type RuntimeProject = {
id: string;
name?: string;
slug?: string;
description?: string;
entry_page_slug?: string;
};
type RuntimePage = {
id: string;
slug?: string;
name?: string;
sort_order?: number;
background_image_url?: string;
background_video_url?: string;
ui_schema_json?: string;
};
type RuntimeElement = {
id: string;
pageId?: string;
name?: string;
element_type?: string;
sort_order?: number;
is_visible?: boolean;
x_percent?: number;
y_percent?: number;
width_percent?: number;
height_percent?: number;
rotation_deg?: number;
style_json?: string;
content_json?: string;
};
type RuntimePageLink = {
id: string;
from_pageId?: string;
to_pageId?: string;
transitionId?: string;
transition?: {
id: string;
video_url?: string;
};
direction?: string;
external_url?: string;
is_active?: boolean;
trigger_selector?: string;
};
type RuntimeTransition = {
id: string;
name?: string;
slug?: string;
video_url?: string;
duration_sec?: number;
supports_reverse?: boolean;
};
type TransitionOverlayState = {
targetPageId: string;
transitionName: string;
videoUrl: string;
isReverse: boolean;
durationSec?: number;
};
const getRows = (response: any) => const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : []; Array.isArray(response?.data?.rows) ? response.data.rows : [];
const parseJsonField = (value: unknown) => {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return value;
if (!value.trim()) return null;
try {
return JSON.parse(value);
} catch (error) {
return value;
}
};
const getElementPreviewText = (content: unknown) => {
if (typeof content === 'string') return content;
if (!content || typeof content !== 'object') return null;
const candidateKeys = [
'title',
'text',
'subtitle',
'description',
'body',
'label',
'value',
];
const contentRecord = content as Record<string, unknown>;
const firstTextKey = candidateKeys.find(
(key) => typeof contentRecord[key] === 'string',
);
return firstTextKey ? String(contentRecord[firstTextKey]) : null;
};
const RuntimePageView = () => { const RuntimePageView = () => {
const [context, setContext] = useState<RuntimeContext | null>(null); const [context, setContext] = useState<RuntimeContext | null>(null);
@ -135,8 +47,6 @@ const RuntimePageView = () => {
const [elements, setElements] = useState<RuntimeElement[]>([]); const [elements, setElements] = useState<RuntimeElement[]>([]);
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]); const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
const [transitions, setTransitions] = useState<RuntimeTransition[]>([]); const [transitions, setTransitions] = useState<RuntimeTransition[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [overlayTransition, setOverlayTransition] = const [overlayTransition, setOverlayTransition] =
useState<TransitionOverlayState | null>(null); useState<TransitionOverlayState | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -144,14 +54,30 @@ const RuntimePageView = () => {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const overlayVideoRef = useRef<HTMLVideoElement | null>(null); const overlayVideoRef = useRef<HTMLVideoElement | null>(null);
const reverseAnimationFrame = useRef<number | null>(null);
const currentProject = useMemo(() => projects[0] || null, [projects]);
// Page navigation with history tracking
const {
currentPageId,
currentPage,
pageHistory,
previousPageId,
defaultPage,
applyPageSelection,
isBackNavigation,
} = usePageNavigation({
pages,
entryPageSlug: currentProject?.entry_page_slug,
trackHistory: true,
});
// Initialize preload orchestrator for neighbor-based asset preloading // Initialize preload orchestrator for neighbor-based asset preloading
const preloadOrchestrator = usePreloadOrchestrator({ const preloadOrchestrator = usePreloadOrchestrator({
pages, pages,
pageLinks, pageLinks,
elements, elements,
currentPageId: selectedPageId, currentPageId,
pageHistory, pageHistory,
enabled: !isLoading && !error, enabled: !isLoading && !error,
}); });
@ -257,56 +183,6 @@ const RuntimePageView = () => {
}; };
}, []); }, []);
const currentProject = useMemo(() => projects[0] || null, [projects]);
const defaultPage = useMemo(() => {
if (!pages.length) return null;
if (currentProject?.entry_page_slug) {
const byEntrySlug = pages.find(
(page) => page.slug === currentProject.entry_page_slug,
);
if (byEntrySlug) return byEntrySlug;
}
return [...pages].sort(
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
)[0];
}, [pages, currentProject]);
const currentPage = useMemo(() => {
if (!pages.length) return null;
if (selectedPageId) {
return pages.find((page) => page.id === selectedPageId) || null;
}
return defaultPage;
}, [pages, selectedPageId, defaultPage]);
const applyPageSelection = useCallback(
(targetPageId: string, isBack: boolean) => {
setSelectedPageId(targetPageId);
setPageHistory((prev) => {
if (!prev.length) return [targetPageId];
const currentId = prev[prev.length - 1];
if (currentId === targetPageId) return prev;
if (
isBack &&
prev.length > 1 &&
prev[prev.length - 2] === targetPageId
) {
return prev.slice(0, -1);
}
return [...prev, targetPageId];
});
},
[],
);
useEffect(() => {
if (defaultPage?.id && !selectedPageId) {
setSelectedPageId(defaultPage.id);
setPageHistory((prev) => (prev.length ? prev : [defaultPage.id]));
}
}, [defaultPage, selectedPageId]);
const pageElements = useMemo(() => { const pageElements = useMemo(() => {
if (!currentPage) return []; if (!currentPage) return [];
return elements return elements
@ -351,15 +227,13 @@ const RuntimePageView = () => {
linkDirection?: string; linkDirection?: string;
transition?: RuntimeTransition | null; transition?: RuntimeTransition | null;
}) => { }) => {
const currentId = selectedPageId || defaultPage?.id; const currentId = currentPageId || defaultPage?.id;
if (!targetPageId || !currentId || targetPageId === currentId) { if (!targetPageId || !currentId || targetPageId === currentId) {
return; return;
} }
const previousPageId =
pageHistory.length > 1 ? pageHistory[pageHistory.length - 2] : null;
const isBack = const isBack =
linkDirection === 'back' || previousPageId === targetPageId; linkDirection === 'back' || isBackNavigation(targetPageId);
const transitionName = const transitionName =
transition?.name || transition?.slug || 'Transition'; transition?.name || transition?.slug || 'Transition';
const canUseReverseVideo = const canUseReverseVideo =
@ -382,209 +256,31 @@ const RuntimePageView = () => {
: Number(transition.duration_sec), : Number(transition.duration_sec),
}); });
}, },
[applyPageSelection, defaultPage?.id, pageHistory, selectedPageId], [applyPageSelection, currentPageId, defaultPage?.id, isBackNavigation],
); );
const finishOverlayTransition = useCallback(() => { const { isBuffering: isTransitionBuffering } = useTransitionPlayback({
if (reverseAnimationFrame.current !== null) { videoRef: overlayVideoRef,
cancelAnimationFrame(reverseAnimationFrame.current); transition: overlayTransition
reverseAnimationFrame.current = null; ? {
} videoUrl: overlayTransition.videoUrl,
reverseMode: overlayTransition.isReverse ? 'reverse' : 'none',
setOverlayTransition((active) => { durationSec: overlayTransition.durationSec,
if (!active) return null; targetPageId: overlayTransition.targetPageId,
applyPageSelection(active.targetPageId, active.isReverse); displayName: overlayTransition.transitionName,
return null;
});
}, [applyPageSelection]);
useEffect(() => {
const video = overlayVideoRef.current;
if (!overlayTransition || !video) {
return;
}
const configuredDurationMs =
(overlayTransition.durationSec && overlayTransition.durationSec > 0
? overlayTransition.durationSec
: 0.7) * 1000;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
const cleanupReverseFrame = () => {
if (reverseAnimationFrame.current !== null) {
// Using clearInterval since we now use setInterval for reverse playback
clearInterval(reverseAnimationFrame.current);
reverseAnimationFrame.current = null;
}
};
// Feature detection for native reverse playback (Chrome 141+, Safari 16+)
const canUseNativeReverse = (() => {
try {
const testVideo = document.createElement('video');
testVideo.playbackRate = -1;
return testVideo.playbackRate === -1;
} catch {
return false;
}
})();
const duration =
Number.isFinite(video.duration) && video.duration > 0
? video.duration
: Math.max(configuredDurationMs / 1000, 0.7);
// Check if video URL is preloaded
const videoUrl = overlayTransition.videoUrl || '';
const isPreloaded = preloadOrchestrator.preloadedUrls.has(videoUrl);
// Improved reverse playback with multiple strategies
const runReverse = () => {
cleanupReverseFrame();
video.pause();
logger.info('Starting reverse playback', {
duration,
canUseNativeReverse,
isPreloaded,
readyState: video.readyState,
});
// Strategy 1: Native playbackRate = -1 (Chrome 141+, Safari 16+)
if (canUseNativeReverse) {
logger.info('Using native playbackRate = -1 strategy');
video.currentTime = duration;
video.playbackRate = -1;
const onTimeUpdate = () => {
if (video.currentTime <= 0.05) {
video.removeEventListener('timeupdate', onTimeUpdate);
video.playbackRate = 1;
video.pause();
video.currentTime = 0;
finishOverlayTransition();
}
};
video.addEventListener('timeupdate', onTimeUpdate);
video.play().catch((err) => {
logger.error('Native reverse play failed:', err instanceof Error ? err : { error: err });
video.removeEventListener('timeupdate', onTimeUpdate);
video.playbackRate = 1;
// Fall back to frame-stepping
startFrameStepping();
});
return;
}
// Strategy 2 & 3: Frame-stepping (preloaded or buffered)
startFrameStepping();
};
// Frame-stepping reverse playback
const startFrameStepping = () => {
logger.info('Using frame-stepping strategy', { isPreloaded });
video.pause();
video.currentTime = duration;
// Check if video is fully buffered
const isFullyBuffered =
video.readyState >= 4 ||
(video.buffered.length > 0 &&
video.buffered.end(video.buffered.length - 1) >= duration - 0.1);
// Use higher fps if video is preloaded/buffered, lower if not
const fps = isPreloaded || isFullyBuffered ? 30 : 15;
const intervalId = setInterval(() => {
if (video.currentTime <= 0) {
clearInterval(intervalId);
reverseAnimationFrame.current = null;
video.currentTime = 0;
finishOverlayTransition();
} else {
video.currentTime -= 1 / fps;
} }
}, 1000 / fps); : null,
onComplete: (targetPageId) => {
// Store interval ID for cleanup if (targetPageId && overlayTransition) {
reverseAnimationFrame.current = intervalId as unknown as number; applyPageSelection(targetPageId, overlayTransition.isReverse);
};
let reverseStarted = false;
const onLoadedMetadata = () => {
// For forward playback, we can start immediately
if (!overlayTransition.isReverse) {
video.currentTime = 0;
video.play().catch((playError) => {
logger.error(
'Transition video playback failed:',
playError instanceof Error ? playError : { error: playError },
);
fallbackTimer = setTimeout(
finishOverlayTransition,
configuredDurationMs,
);
});
} else {
// For reverse, start with a small delay to allow some buffering
setTimeout(() => {
if (!reverseStarted) {
reverseStarted = true;
runReverse();
}
}, 300);
} }
}; setOverlayTransition(null);
},
const onCanPlayThrough = () => { preload: {
// For reverse playback, start immediately when enough data is buffered preloadedUrls: preloadOrchestrator.preloadedUrls,
if (overlayTransition.isReverse && !reverseStarted) { getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
reverseStarted = true; },
runReverse(); });
}
};
const onEnded = () => {
finishOverlayTransition();
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplaythrough', onCanPlayThrough);
video.addEventListener('ended', onEnded);
// Fallback timeout in case video events don't fire
// For reverse playback, add extra buffer since seeking is slower
const fallbackMs = overlayTransition.isReverse
? Math.max(
(overlayTransition.durationSec && overlayTransition.durationSec > 0
? overlayTransition.durationSec * 1000
: configuredDurationMs) + 1500, // Extra buffer for seek delays
2000,
)
: configuredDurationMs + 300;
fallbackTimer = setTimeout(finishOverlayTransition, fallbackMs);
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplaythrough', onCanPlayThrough);
video.removeEventListener('ended', onEnded);
cleanupReverseFrame();
if (fallbackTimer) clearTimeout(fallbackTimer);
};
}, [finishOverlayTransition, overlayTransition]);
useEffect(() => {
return () => {
if (reverseAnimationFrame.current !== null) {
clearInterval(reverseAnimationFrame.current);
}
};
}, []);
return ( return (
<> <>
@ -860,7 +556,8 @@ const RuntimePageView = () => {
<video <video
ref={overlayVideoRef} ref={overlayVideoRef}
src={overlayTransition.videoUrl} src={overlayTransition.videoUrl}
className='w-full h-full object-cover' className='w-full h-full object-cover transition-opacity duration-300'
style={{ opacity: isTransitionBuffering ? 0 : 1 }}
muted muted
playsInline playsInline
autoPlay={!overlayTransition.isReverse} autoPlay={!overlayTransition.isReverse}

View File

@ -9,13 +9,12 @@ import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import type { UiElementDefault } from '../types/constructor';
type UiElementType = { type UiElementType = UiElementDefault & {
id: string;
element_type: string; element_type: string;
name?: string; name?: string;
sort_order?: number; sort_order?: number;
is_active?: boolean;
}; };
const toHumanLabel = (value: string) => const toHumanLabel = (value: string) =>

View File

@ -23,61 +23,22 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { parseJsonObject } from '../../lib/parseJson';
import type {
CanvasElementType,
GalleryCard,
CarouselSlide,
UiElementDefault,
} from '../../types/constructor';
import type { ElementStyleProperties } from '../../lib/elementStyles';
type UiElementType = { type UiElementType = UiElementDefault & {
id: string;
element_type: string; element_type: string;
name?: string; name?: string;
sort_order?: number; sort_order?: number;
is_active?: boolean;
default_settings_json?: Record<string, any> | string | null;
}; };
type ElementType = type ElementType = CanvasElementType;
| 'navigation_next'
| 'navigation_prev'
| 'gallery'
| 'carousel'
| 'tooltip'
| 'description'
| 'video_player'
| 'audio_player';
type GalleryCard = {
id: string;
imageUrl: string;
title: string;
description: string;
};
type CarouselSlide = {
id: string;
imageUrl: string;
caption: string;
};
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T;
}
if (typeof value === 'object') {
return value as T;
}
return (fallback || ({} as T)) as T;
} catch (error) {
logger.error(
'Failed to parse UI element defaults JSON:',
error instanceof Error ? error : { error },
);
return (fallback || ({} as T)) as T;
}
};
const clampPercent = (value: string) => { const clampPercent = (value: string) => {
const parsed = Number(value); const parsed = Number(value);

View File

@ -0,0 +1,147 @@
/**
* Constructor Types
*
* Shared types for the constructor page and related UI elements.
* Used in constructor.tsx, page_elements pages, and ui-elements pages.
*/
import type { ElementStyleProperties } from '../lib/elementStyles';
/**
* Element types available in the constructor canvas
*/
export type CanvasElementType =
| 'navigation_next'
| 'navigation_prev'
| 'gallery'
| 'carousel'
| 'tooltip'
| 'description'
| 'video_player'
| 'audio_player';
/**
* Navigation button direction
*/
export type NavigationButtonKind = 'forward' | 'back';
/**
* Gallery card item
*/
export interface GalleryCard {
id: string;
imageUrl: string;
title: string;
description: string;
}
/**
* Carousel slide item
*/
export interface CarouselSlide {
id: string;
imageUrl: string;
caption: string;
}
/**
* Base canvas element with common positioning and styling fields.
* Extends ElementStyleProperties for CSS styling.
*/
export interface BaseCanvasElement extends ElementStyleProperties {
id: string;
type: CanvasElementType | string;
label?: string;
xPercent?: number;
yPercent?: number;
iconUrl?: string;
appearDelaySec?: number;
appearDurationSec?: number | null;
}
/**
* Full canvas element with all content fields
*/
export interface CanvasElement extends BaseCanvasElement {
type: CanvasElementType;
label: string;
xPercent: number;
yPercent: number;
iconUrl?: string;
mediaUrl?: string;
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
backgroundImageUrl?: string;
videoUrl?: string;
audioUrl?: string;
galleryCards?: GalleryCard[];
carouselSlides?: CarouselSlide[];
carouselPrevIconUrl?: string;
carouselNextIconUrl?: string;
tooltipTitle?: string;
tooltipText?: string;
descriptionTitle?: string;
descriptionText?: string;
descriptionTitleFontSize?: string;
descriptionTextFontSize?: string;
descriptionTitleFontFamily?: string;
descriptionTextFontFamily?: string;
descriptionTitleColor?: string;
descriptionTextColor?: string;
descriptionBackgroundColor?: string;
navLabel?: string;
navType?: NavigationButtonKind;
navDisabled?: boolean;
targetPageId?: string;
transitionVideoUrl?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string;
transitionDurationSec?: number;
}
/**
* Constructor schema containing elements array
*/
export interface ConstructorSchema {
elements?: CanvasElement[];
}
/**
* UI element default settings from database
*/
export interface UiElementDefault {
id: string;
element_type?: string;
is_active?: boolean;
default_settings_json?: string | Record<string, unknown>;
}
/**
* Project asset for constructor asset selection
*/
export interface ConstructorAsset {
id: string;
name?: string;
asset_type?: 'image' | 'video' | 'audio' | 'file';
type?:
| 'icon'
| 'background_image'
| 'audio'
| 'video'
| 'transition'
| 'logo'
| 'favicon'
| 'document'
| 'general';
cdn_url?: string | null;
storage_key?: string | null;
}
/**
* Asset option for select dropdowns
*/
export interface AssetOption {
value: string;
label: string;
}

View File

@ -8,3 +8,7 @@ export * from './redux';
export * from './forms'; export * from './forms';
export * from './filters'; export * from './filters';
export * from './permissions'; export * from './permissions';
export * from './offline';
export * from './preload';
export * from './runtime';
export * from './constructor';

View File

@ -0,0 +1,56 @@
/**
* Preload Types
*
* Shared types for asset preloading infrastructure.
*/
/**
* Simplified page type for preloading (runtime subset of TourPage)
*/
export interface PreloadPage {
id: string;
background_image_url?: string;
background_video_url?: string;
}
/**
* Simplified page link type for preloading (runtime subset of PageLink)
*/
export interface PreloadPageLink {
id: string;
from_pageId?: string;
to_pageId?: string;
is_active?: boolean;
transition?: {
id: string;
video_url?: string;
};
}
/**
* Simplified element type for preloading
*/
export interface PreloadElement {
id: string;
pageId?: string;
element_type?: string;
content_json?: string;
}
/**
* Asset info returned by neighbor graph
*/
export interface PreloadAssetInfo {
url: string;
pageId: string;
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
priority: number;
}
/**
* Neighbor info for graph traversal
*/
export interface PreloadNeighborInfo {
pageId: string;
distance: number;
}

View File

@ -0,0 +1,77 @@
/**
* Runtime Types
*
* Shared types for runtime presentation (runtime.tsx, RuntimePresentation.tsx).
* These extend the minimal preload types with additional runtime-specific fields.
*/
import type { PreloadPage, PreloadPageLink, PreloadElement } from './preload';
/**
* Runtime project data
*/
export interface RuntimeProject {
id: string;
name?: string;
slug?: string;
description?: string;
entry_page_slug?: string;
}
/**
* Runtime page - extends PreloadPage with display/navigation fields
*/
export interface RuntimePage extends PreloadPage {
slug?: string;
name?: string;
sort_order?: number;
ui_schema_json?: string;
}
/**
* Runtime page link - extends PreloadPageLink with navigation fields
*/
export interface RuntimePageLink extends PreloadPageLink {
transitionId?: string;
direction?: string;
external_url?: string;
trigger_selector?: string;
}
/**
* Runtime element - extends PreloadElement with positioning/visibility
*/
export interface RuntimeElement extends PreloadElement {
name?: string;
sort_order?: number;
is_visible?: boolean;
x_percent?: number;
y_percent?: number;
width_percent?: number;
height_percent?: number;
rotation_deg?: number;
style_json?: string;
}
/**
* Runtime transition data
*/
export interface RuntimeTransition {
id: string;
name?: string;
slug?: string;
video_url?: string;
duration_sec?: number;
supports_reverse?: boolean;
}
/**
* State for transition overlay during page navigation
*/
export interface TransitionOverlayState {
targetPageId: string;
transitionName: string;
videoUrl: string;
isReverse: boolean;
durationSec?: number;
}

File diff suppressed because one or more lines are too long