39948-vm/frontend/src/pages/runtime.tsx
2026-03-24 15:40:35 +04:00

581 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;