581 lines
21 KiB
TypeScript
581 lines
21 KiB
TypeScript
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 '../components/BaseButton';
|
||
import CardBox from '../components/CardBox';
|
||
import LayoutGuest from '../layouts/Guest';
|
||
import { getPageTitle } from '../config';
|
||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||
import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline';
|
||
import { logger } from '../lib/logger';
|
||
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 RuntimeContext = {
|
||
mode: RuntimeMode;
|
||
projectSlug: string | null;
|
||
};
|
||
|
||
const getRows = (response: any) =>
|
||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
||
|
||
const RuntimePageView = () => {
|
||
const [context, setContext] = useState<RuntimeContext | null>(null);
|
||
const [projects, setProjects] = useState<RuntimeProject[]>([]);
|
||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
||
const [elements, setElements] = useState<RuntimeElement[]>([]);
|
||
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
|
||
const [transitions, setTransitions] = useState<RuntimeTransition[]>([]);
|
||
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);
|
||
|
||
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
|
||
const preloadOrchestrator = usePreloadOrchestrator({
|
||
pages,
|
||
pageLinks,
|
||
elements,
|
||
currentPageId,
|
||
pageHistory,
|
||
enabled: !isLoading && !error,
|
||
});
|
||
|
||
// Fullscreen toggle handler for presentation mode
|
||
const toggleFullscreen = useCallback(async () => {
|
||
try {
|
||
if (!document.fullscreenElement) {
|
||
await document.documentElement.requestFullscreen();
|
||
setIsFullscreen(true);
|
||
} else {
|
||
await document.exitFullscreen();
|
||
setIsFullscreen(false);
|
||
}
|
||
} catch (error) {
|
||
logger.error(
|
||
'Fullscreen toggle failed:',
|
||
error instanceof Error ? error : { error },
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
// Listen for fullscreen changes (e.g., when user presses Escape)
|
||
useEffect(() => {
|
||
const handleFullscreenChange = () => {
|
||
setIsFullscreen(Boolean(document.fullscreenElement));
|
||
};
|
||
|
||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||
return () =>
|
||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false;
|
||
|
||
const loadRuntime = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError('');
|
||
|
||
const contextResponse = await axios.get('/runtime-context', {
|
||
validateStatus: (status) =>
|
||
(status >= 200 && status < 300) || status === 503,
|
||
});
|
||
if (isCancelled) return;
|
||
|
||
if (contextResponse.status === 503) {
|
||
setContext({ mode: 'unknown', projectSlug: null });
|
||
return;
|
||
}
|
||
|
||
setContext(
|
||
contextResponse.data || { mode: 'unknown', projectSlug: null },
|
||
);
|
||
|
||
const [
|
||
projectsResponse,
|
||
pagesResponse,
|
||
elementsResponse,
|
||
pageLinksResponse,
|
||
transitionsResponse,
|
||
] = await Promise.all([
|
||
axios.get('/projects'),
|
||
axios.get('/tour_pages'),
|
||
axios.get('/page_elements'),
|
||
axios.get('/page_links'),
|
||
axios.get('/transitions'),
|
||
]);
|
||
|
||
if (isCancelled) return;
|
||
|
||
setProjects(getRows(projectsResponse));
|
||
setPages(getRows(pagesResponse));
|
||
setElements(getRows(elementsResponse));
|
||
setPageLinks(getRows(pageLinksResponse));
|
||
setTransitions(getRows(transitionsResponse));
|
||
} catch (runtimeError: any) {
|
||
if (isCancelled) return;
|
||
if (
|
||
axios.isAxiosError(runtimeError) &&
|
||
runtimeError.response?.status === 503
|
||
) {
|
||
setContext({ mode: 'unknown', projectSlug: null });
|
||
return;
|
||
}
|
||
const message =
|
||
runtimeError?.response?.data?.message ||
|
||
runtimeError?.message ||
|
||
'Failed to load runtime data.';
|
||
setError(message);
|
||
} finally {
|
||
if (!isCancelled) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadRuntime();
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const pageElements = useMemo(() => {
|
||
if (!currentPage) return [];
|
||
return elements
|
||
.filter((element) => element.pageId === currentPage.id)
|
||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||
}, [elements, currentPage]);
|
||
|
||
const pageById = useMemo(() => {
|
||
return pages.reduce(
|
||
(acc, page) => {
|
||
acc[page.id] = page;
|
||
return acc;
|
||
},
|
||
{} as Record<string, RuntimePage>,
|
||
);
|
||
}, [pages]);
|
||
|
||
const transitionById = useMemo(() => {
|
||
return transitions.reduce(
|
||
(acc, transition) => {
|
||
acc[transition.id] = transition;
|
||
return acc;
|
||
},
|
||
{} as Record<string, RuntimeTransition>,
|
||
);
|
||
}, [transitions]);
|
||
|
||
const currentPageLinks = useMemo(() => {
|
||
if (!currentPage) return [];
|
||
return pageLinks.filter(
|
||
(link) => link.from_pageId === currentPage.id && link.is_active !== false,
|
||
);
|
||
}, [pageLinks, currentPage]);
|
||
|
||
const startNavigation = useCallback(
|
||
({
|
||
targetPageId,
|
||
linkDirection,
|
||
transition,
|
||
}: {
|
||
targetPageId: string;
|
||
linkDirection?: string;
|
||
transition?: RuntimeTransition | null;
|
||
}) => {
|
||
const currentId = currentPageId || defaultPage?.id;
|
||
if (!targetPageId || !currentId || targetPageId === currentId) {
|
||
return;
|
||
}
|
||
|
||
const isBack =
|
||
linkDirection === 'back' || isBackNavigation(targetPageId);
|
||
const transitionName =
|
||
transition?.name || transition?.slug || 'Transition';
|
||
const canUseReverseVideo =
|
||
isBack && transition?.supports_reverse !== false;
|
||
|
||
const resolvedVideoUrl = resolveAssetPlaybackUrl(transition?.video_url);
|
||
if (!resolvedVideoUrl) {
|
||
applyPageSelection(targetPageId, isBack);
|
||
return;
|
||
}
|
||
|
||
setOverlayTransition({
|
||
targetPageId,
|
||
transitionName,
|
||
videoUrl: resolvedVideoUrl,
|
||
isReverse: canUseReverseVideo,
|
||
durationSec:
|
||
typeof transition.duration_sec === 'number'
|
||
? transition.duration_sec
|
||
: Number(transition.duration_sec),
|
||
});
|
||
},
|
||
[applyPageSelection, currentPageId, defaultPage?.id, isBackNavigation],
|
||
);
|
||
|
||
const { isBuffering: isTransitionBuffering } = useTransitionPlayback({
|
||
videoRef: overlayVideoRef,
|
||
transition: overlayTransition
|
||
? {
|
||
videoUrl: overlayTransition.videoUrl,
|
||
reverseMode: overlayTransition.isReverse ? 'reverse' : 'none',
|
||
durationSec: overlayTransition.durationSec,
|
||
targetPageId: overlayTransition.targetPageId,
|
||
displayName: overlayTransition.transitionName,
|
||
}
|
||
: null,
|
||
onComplete: (targetPageId) => {
|
||
if (targetPageId && overlayTransition) {
|
||
applyPageSelection(targetPageId, overlayTransition.isReverse);
|
||
}
|
||
setOverlayTransition(null);
|
||
},
|
||
preload: {
|
||
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
||
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
||
},
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('Runtime')}</title>
|
||
</Head>
|
||
<main className='min-h-screen p-4 md:p-8'>
|
||
<CardBox className='max-w-5xl mx-auto'>
|
||
<div className='space-y-4'>
|
||
<div className='flex justify-between items-center'>
|
||
<h1 className='text-xl font-semibold'>Runtime Viewer</h1>
|
||
<div className='flex items-center gap-3'>
|
||
<OfflineStatusIndicator showLabel />
|
||
<OfflineToggle
|
||
projectId={currentProject?.id || null}
|
||
projectSlug={currentProject?.slug}
|
||
projectName={currentProject?.name}
|
||
size='small'
|
||
/>
|
||
<BaseButton
|
||
small
|
||
color='lightDark'
|
||
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||
label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||
onClick={toggleFullscreen}
|
||
/>
|
||
<div className='text-sm text-gray-500'>
|
||
Mode: {context?.mode || 'unknown'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preload status indicator */}
|
||
{preloadOrchestrator.isPreloading && (
|
||
<div className='flex items-center gap-2 text-xs text-blue-600'>
|
||
<span className='animate-pulse'>Preloading assets...</span>
|
||
<span>({preloadOrchestrator.queueLength} in queue)</span>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading && <p>Loading runtime data...</p>}
|
||
|
||
{!isLoading && error && (
|
||
<div className='space-y-3'>
|
||
<p className='text-red-600'>{error}</p>
|
||
<BaseButton href='/login' color='info' label='Sign In' />
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && !error && (
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<h2 className='font-semibold'>
|
||
{currentProject?.name || 'Project'}
|
||
</h2>
|
||
<p className='text-sm text-gray-500'>
|
||
{currentProject?.description || 'No description.'}
|
||
</p>
|
||
</div>
|
||
|
||
{!!pages.length && (
|
||
<div className='border rounded p-3 bg-gray-50'>
|
||
<p className='text-sm font-semibold mb-2'>Pages</p>
|
||
<div className='flex gap-2 flex-wrap'>
|
||
{pages
|
||
.slice()
|
||
.sort(
|
||
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
|
||
)
|
||
.map((page) => (
|
||
<BaseButton
|
||
key={page.id}
|
||
small
|
||
color={
|
||
currentPage?.id === page.id ? 'info' : 'white'
|
||
}
|
||
label={page.name || page.slug || 'Page'}
|
||
onClick={() =>
|
||
startNavigation({ targetPageId: page.id })
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{currentPage && (
|
||
<div className='border rounded p-4 bg-white'>
|
||
<h3 className='font-semibold mb-2'>
|
||
{currentPage.name || currentPage.slug || 'Page'}
|
||
</h3>
|
||
{currentPage.background_image_url && (
|
||
<Image
|
||
src={currentPage.background_image_url}
|
||
alt={currentPage.name || 'Runtime page background'}
|
||
className='w-full max-h-80 object-cover rounded mb-3'
|
||
width={1280}
|
||
height={720}
|
||
unoptimized
|
||
/>
|
||
)}
|
||
{currentPage.background_video_url && (
|
||
<p className='text-xs text-gray-500 mb-3'>
|
||
Background video: {currentPage.background_video_url}
|
||
</p>
|
||
)}
|
||
<div className='space-y-2'>
|
||
{pageElements.map((element) => (
|
||
<div
|
||
key={element.id}
|
||
className='border rounded px-3 py-2'
|
||
>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<p className='font-medium'>
|
||
{element.name ||
|
||
element.element_type ||
|
||
'Element'}
|
||
</p>
|
||
<p className='text-xs text-gray-500'>
|
||
{element.is_visible === false
|
||
? 'Hidden'
|
||
: 'Visible'}
|
||
</p>
|
||
</div>
|
||
<p className='text-xs text-gray-500 mb-2'>
|
||
{element.element_type || 'unknown'}
|
||
</p>
|
||
|
||
{(element.x_percent !== undefined ||
|
||
element.y_percent !== undefined) && (
|
||
<p className='text-xs text-gray-500 mb-2'>
|
||
Position: {element.x_percent ?? 0}% /{' '}
|
||
{element.y_percent ?? 0}% · Size:{' '}
|
||
{element.width_percent ?? 0}% ×{' '}
|
||
{element.height_percent ?? 0}% · Rotation:{' '}
|
||
{element.rotation_deg ?? 0}°
|
||
</p>
|
||
)}
|
||
|
||
{getElementPreviewText(
|
||
parseJsonField(element.content_json),
|
||
) && (
|
||
<p className='text-sm mb-2'>
|
||
{getElementPreviewText(
|
||
parseJsonField(element.content_json),
|
||
)}
|
||
</p>
|
||
)}
|
||
|
||
<details className='text-xs'>
|
||
<summary className='cursor-pointer text-blue-600'>
|
||
Content JSON
|
||
</summary>
|
||
<pre className='mt-2 p-2 rounded bg-gray-50 overflow-auto'>
|
||
{JSON.stringify(
|
||
parseJsonField(element.content_json),
|
||
null,
|
||
2,
|
||
) || 'null'}
|
||
</pre>
|
||
</details>
|
||
|
||
<details className='text-xs mt-2'>
|
||
<summary className='cursor-pointer text-blue-600'>
|
||
Style JSON
|
||
</summary>
|
||
<pre className='mt-2 p-2 rounded bg-gray-50 overflow-auto'>
|
||
{JSON.stringify(
|
||
parseJsonField(element.style_json),
|
||
null,
|
||
2,
|
||
) || 'null'}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
))}
|
||
{!pageElements.length && (
|
||
<p className='text-sm text-gray-500'>
|
||
No page elements found.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className='mt-4 border-t pt-3'>
|
||
<h4 className='font-semibold mb-2 text-sm'>
|
||
Page links / transitions
|
||
</h4>
|
||
<div className='space-y-2'>
|
||
{currentPageLinks.map((link) => {
|
||
const targetPage = link.to_pageId
|
||
? pageById[link.to_pageId]
|
||
: null;
|
||
const transition = link.transitionId
|
||
? transitionById[link.transitionId]
|
||
: null;
|
||
|
||
return (
|
||
<div key={link.id} className='border rounded p-2'>
|
||
<div className='flex flex-wrap items-center gap-2 text-sm'>
|
||
<span className='font-medium'>
|
||
{link.direction || 'link'}
|
||
</span>
|
||
{targetPage && (
|
||
<BaseButton
|
||
small
|
||
color='info'
|
||
label={`Go to ${targetPage.name || targetPage.slug || 'page'}`}
|
||
onClick={() =>
|
||
startNavigation({
|
||
targetPageId: targetPage.id,
|
||
linkDirection: link.direction,
|
||
transition,
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
{!targetPage && link.external_url && (
|
||
<a
|
||
className='text-blue-600 underline'
|
||
href={link.external_url}
|
||
target='_blank'
|
||
rel='noreferrer'
|
||
>
|
||
Open external
|
||
</a>
|
||
)}
|
||
</div>
|
||
{transition && (
|
||
<p className='text-xs text-gray-500 mt-1'>
|
||
Transition:{' '}
|
||
{transition.name ||
|
||
transition.slug ||
|
||
transition.id}
|
||
{typeof transition.duration_sec === 'number'
|
||
? ` · ${transition.duration_sec}s`
|
||
: ''}
|
||
{transition.video_url
|
||
? ' · video'
|
||
: ' · no video'}
|
||
</p>
|
||
)}
|
||
{link.trigger_selector && (
|
||
<p className='text-xs text-gray-500 mt-1'>
|
||
Trigger: {link.trigger_selector}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{!currentPageLinks.length && (
|
||
<p className='text-sm text-gray-500'>
|
||
No active links from this page.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!currentPage && (
|
||
<p className='text-sm text-gray-500'>
|
||
No runtime page found for this host.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardBox>
|
||
</main>
|
||
|
||
{overlayTransition && (
|
||
<div className='fixed inset-0 z-50 bg-black/95 flex items-center justify-center'>
|
||
<video
|
||
ref={overlayVideoRef}
|
||
src={overlayTransition.videoUrl}
|
||
className='w-full h-full object-cover transition-opacity duration-300'
|
||
style={{ opacity: isTransitionBuffering ? 0 : 1 }}
|
||
muted
|
||
playsInline
|
||
autoPlay={!overlayTransition.isReverse}
|
||
preload='auto'
|
||
/>
|
||
<div className='absolute bottom-4 left-4 text-xs text-white/80'>
|
||
{overlayTransition.transitionName} ·{' '}
|
||
{overlayTransition.isReverse ? 'back (reverse)' : 'forward'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
RuntimePageView.getLayout = function getLayout(page: ReactElement) {
|
||
return <LayoutGuest>{page}</LayoutGuest>;
|
||
};
|
||
|
||
export default RuntimePageView;
|