Isdeleted
diff --git a/frontend/src/components/Projects/configureProjectsCols.tsx b/frontend/src/components/Projects/configureProjectsCols.tsx
index fa6999e..b4e0a34 100644
--- a/frontend/src/components/Projects/configureProjectsCols.tsx
+++ b/frontend/src/components/Projects/configureProjectsCols.tsx
@@ -68,18 +68,6 @@ export const loadColumns = async (
editable: hasUpdatePermission,
},
- {
- field: 'phase',
- headerName: 'Phase',
- flex: 1,
- minWidth: 120,
- filterable: false,
- headerClassName: 'datagrid--header',
- cellClassName: 'datagrid--cell',
-
- editable: hasUpdatePermission,
- },
-
{
field: 'logo_url',
headerName: 'LogoURL',
@@ -152,18 +140,6 @@ export const loadColumns = async (
editable: hasUpdatePermission,
},
- {
- field: 'entry_page_slug',
- headerName: 'Entrypageslug',
- flex: 1,
- minWidth: 120,
- filterable: false,
- headerClassName: 'datagrid--header',
- cellClassName: 'datagrid--cell',
-
- editable: hasUpdatePermission,
- },
-
{
field: 'is_deleted',
headerName: 'Isdeleted',
diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx
index 6ce8170..ebebe26 100644
--- a/frontend/src/components/RuntimePresentation.tsx
+++ b/frontend/src/components/RuntimePresentation.tsx
@@ -21,22 +21,15 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
import LayoutGuest from '../layouts/Guest';
-import { getPageTitle, baseURLApi } from '../config';
-import { PRELOAD_CONFIG } from '../config/preload.config';
+import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
+import { extractPageLinksAndElements } from '../lib/extractPageLinks';
+import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
+import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
-import {
- resolveAssetPlaybackUrl,
- markPresignedUrlFailed,
- isRelativeStoragePath,
-} from '../lib/assetUrl';
import { buildElementStyle } from '../lib/elementStyles';
-import type {
- RuntimeProject,
- RuntimePage,
- RuntimePageLink,
-} from '../types/runtime';
+import type { RuntimeProject, RuntimePage } from '../types/runtime';
interface RuntimePresentationProps {
projectSlug: string;
@@ -46,163 +39,12 @@ interface RuntimePresentationProps {
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
-/**
- * Check if URL is a presigned S3 URL
- */
-const isPresignedUrl = (url: string): boolean => {
- return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
-};
-
-/**
- * Build proxy URL from storage key
- */
-const buildProxyUrl = (storageKey: string): string => {
- const normalizedPath = storageKey.replace(/^\/+/, '');
- return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
-};
-
-/**
- * Load and decode a single image with presigned URL fallback
- */
-const loadImageWithFallback = (
- url: string,
- storageKey?: string,
-): Promise => {
- return new Promise((resolve) => {
- const img = new window.Image();
-
- const tryLoad = (srcUrl: string, isRetry = false) => {
- img.src = srcUrl;
-
- const handleSuccess = () => {
- if (typeof img.decode === 'function') {
- img
- .decode()
- .then(() => resolve())
- .catch(() => resolve());
- } else {
- resolve();
- }
- };
-
- const handleError = () => {
- // If this was a presigned URL and we have a storage key, retry with proxy
- if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
- logger.info('Image presigned URL failed, retrying with proxy', {
- storageKey: storageKey.slice(-50),
- });
- markPresignedUrlFailed(storageKey);
- const proxyUrl = buildProxyUrl(storageKey);
- tryLoad(proxyUrl, true);
- } else {
- // Give up and resolve anyway to not block navigation
- resolve();
- }
- };
-
- img.onload = handleSuccess;
- img.onerror = handleError;
- };
-
- tryLoad(url);
- });
-};
-
-/**
- * Wait for all images on a page to be decoded before switching.
- * Handles presigned URL failures by retrying with proxy URLs.
- */
-const waitForPageImages = async (
- page: RuntimePage | null,
- timeoutMs = 3000,
-): Promise => {
- if (!page) return;
-
- // Collect image URLs with their original storage keys for fallback
- const imageEntries: Array<{ url: string; storageKey?: string }> = [];
-
- if (page.background_image_url) {
- const storageKey = page.background_image_url;
- const url = resolveAssetPlaybackUrl(storageKey);
- if (url) {
- imageEntries.push({
- url,
- storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
- });
- }
- }
-
- 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) => {
- // Direct image fields
- imageFields.forEach((field) => {
- const value = el[field];
- if (typeof value === 'string' && value) {
- const url = resolveAssetPlaybackUrl(value);
- if (url && !imageEntries.some((e) => e.url === url)) {
- imageEntries.push({
- url,
- storageKey: isRelativeStoragePath(value) ? value : undefined,
- });
- }
- }
- });
-
- // Nested arrays (galleryCards, carouselSlides)
- nestedFields.forEach((nestedField) => {
- const items = el[nestedField];
- if (Array.isArray(items)) {
- items.forEach((item: Record) => {
- if (typeof item.imageUrl === 'string' && item.imageUrl) {
- const url = resolveAssetPlaybackUrl(item.imageUrl);
- if (url && !imageEntries.some((e) => e.url === url)) {
- imageEntries.push({
- url,
- storageKey: isRelativeStoragePath(item.imageUrl)
- ? item.imageUrl
- : undefined,
- });
- }
- }
- });
- }
- });
- });
- } catch {
- // Ignore parse errors
- }
-
- if (imageEntries.length === 0) return;
-
- const decodePromises = imageEntries.map((entry) =>
- loadImageWithFallback(entry.url, entry.storageKey),
- );
-
- await Promise.race([
- Promise.all(decodePromises),
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
- ]);
-};
-
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const [project, setProject] = useState(null);
const [pages, setPages] = useState([]);
- const [pageLinks, setPageLinks] = useState([]);
const [selectedPageId, setSelectedPageId] = useState(null);
const [pageHistory, setPageHistory] = useState([]);
const [transitionPreview, setTransitionPreview] = useState<{
@@ -216,8 +58,10 @@ export default function RuntimePresentation({
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
+ const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const transitionVideoRef = useRef(null);
+ const lastInitializedPageIdRef = useRef(null);
// API request config with custom headers for project/environment
const apiConfig = useMemo(
@@ -230,53 +74,21 @@ export default function RuntimePresentation({
[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) => {
- // Build content_json from config fields
- const contentObj: Record = {};
- 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
- }
- });
-
+ // Extract page links and preload elements from ui_schema_json
+ // This enables the neighbor graph to find connected pages for preloading
+ const { pageLinks, preloadElements } = useMemo(() => {
+ const result = extractPageLinksAndElements(pages);
+ if (result.pageLinks.length > 0 || result.preloadElements.length > 0) {
+ logger.info('[PRELOAD] Extracted page links and elements', {
+ pageLinksCount: result.pageLinks.length,
+ preloadElementsCount: result.preloadElements.length,
+ pageLinks: result.pageLinks.map((link) => ({
+ from: link.from_pageId?.slice(-8),
+ to: link.to_pageId?.slice(-8),
+ hasTransition: !!link.transition?.video_url,
+ })),
+ });
+ }
return result;
}, [pages]);
@@ -290,6 +102,17 @@ export default function RuntimePresentation({
enabled: !isLoading && !error,
});
+ // Initialize page switch hook for smooth background transitions
+ const pageSwitch = usePageSwitch({
+ preloadCache: preloadOrchestrator
+ ? {
+ getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
+ getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
+ preloadedUrls: preloadOrchestrator.preloadedUrls,
+ }
+ : undefined,
+ });
+
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
@@ -301,20 +124,22 @@ export default function RuntimePresentation({
displayName: 'Transition',
}
: null,
- onComplete: (targetPageId) => {
- const video = transitionVideoRef.current;
+ onComplete: async (targetPageId) => {
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
- waitForPageImages(targetPage || null).then(() => {
- // Mark background as not ready - new image will need to load
- setIsBackgroundReady(false);
+ // Mark this page as initialized to prevent redundant effect calls
+ lastInitializedPageIdRef.current = targetPageId;
+ // Use shared hook to resolve blob URLs and switch page
+ await pageSwitch.switchToPage(targetPage, () => {
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
- // Signal that transition is complete and waiting for background
- setPendingTransitionComplete(true);
});
+ setIsBackgroundReady(false);
+ // Signal that transition is complete and waiting for Image onLoad
+ setPendingTransitionComplete(true);
} else {
// No target page - clean up and remove overlay
+ const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
@@ -323,16 +148,9 @@ export default function RuntimePresentation({
},
features: {
useBlobUrl: true,
- preDecodeImages: true,
- getTargetPageImages: () => {
- if (!transitionPreview?.targetPageId) return [];
- const targetPage = pages.find(
- (p) => p.id === transitionPreview.targetPageId,
- );
- if (!targetPage?.background_image_url) return [];
- const url = resolveAssetPlaybackUrl(targetPage.background_image_url);
- return url ? [url] : [];
- },
+ // Don't pre-decode images in the hook - we handle it via overlay:
+ // Overlay shows last transition frame while new page background loads behind it
+ preDecodeImages: false,
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
@@ -367,20 +185,44 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
- // Remove transition overlay when background is ready
+ // Fade out and remove transition overlay when background is ready
useEffect(() => {
- if (pendingTransitionComplete && isBackgroundReady) {
- const video = transitionVideoRef.current;
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- video?.removeAttribute('src');
- video?.load();
- setTransitionPreview(null);
- setPendingTransitionComplete(false);
- });
- });
+ if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
+ // Start fade-out animation
+ setIsOverlayFadingOut(true);
+
+ // After fade completes (300ms), remove the overlay
+ const fadeTimer = setTimeout(() => {
+ const video = transitionVideoRef.current;
+ video?.removeAttribute('src');
+ video?.load();
+ setTransitionPreview(null);
+ setPendingTransitionComplete(false);
+ setIsOverlayFadingOut(false);
+ // Clear previous background from shared hook
+ pageSwitch.clearPreviousBackground();
+ }, 300);
+
+ return () => clearTimeout(fadeTimer);
}
- }, [pendingTransitionComplete, isBackgroundReady]);
+ }, [pendingTransitionComplete, isBackgroundReady, isOverlayFadingOut, pageSwitch.clearPreviousBackground]);
+
+ // Clear previous background overlay when new background is ready (direct navigation)
+ useEffect(() => {
+ if (
+ pageSwitch.isSwitching &&
+ pageSwitch.isNewBgReady &&
+ pageSwitch.previousBgImageUrl
+ ) {
+ // New background is ready - clear the previous background overlay
+ pageSwitch.clearPreviousBackground();
+ }
+ }, [
+ pageSwitch.isSwitching,
+ pageSwitch.isNewBgReady,
+ pageSwitch.previousBgImageUrl,
+ pageSwitch.clearPreviousBackground,
+ ]);
// Load presentation data
useEffect(() => {
@@ -411,45 +253,29 @@ export default function RuntimePresentation({
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 },
- }),
- ]);
+ // Fetch pages for this project
+ // (Elements and navigation are extracted from ui_schema_json)
+ const pagesResponse = await axios.get('/tour_pages', {
+ ...apiConfig,
+ params: { project: foundProject.id },
+ });
if (isCancelled) return;
const pageRows = getRows(pagesResponse);
- const linkRows = getRows(pageLinksResponse);
// Filter by environment and sort by sort_order
+ // STRICT: Only show pages matching the exact environment
+ // Production = production only, Stage = stage only, Dev = dev only
const envFilteredPages = pageRows
- .filter(
- (p: any) =>
- !p.environment ||
- p.environment === environment ||
- p.environment === 'dev',
- )
+ .filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(envFilteredPages);
- setPageLinks(linkRows);
- // Set initial page
+ // Set initial page (first page by sort_order)
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];
+ const firstPage = envFilteredPages[0];
setSelectedPageId(firstPage.id);
setPageHistory([firstPage.id]);
}
@@ -494,6 +320,25 @@ export default function RuntimePresentation({
}
}, [selectedPage]);
+ // Set initial backgrounds when page first loads (before preload cache is populated)
+ // The condition ensures this only runs once on initial load when backgrounds are empty.
+ // After that, navigateToPage handles all subsequent navigation explicitly.
+ useEffect(() => {
+ if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
+ // Only initialize when backgrounds are empty (initial load)
+ // navigateToPage handles subsequent navigation by calling switchToPage directly
+ if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
+ lastInitializedPageIdRef.current = selectedPage.id;
+ pageSwitch.switchToPage(selectedPage);
+ }
+ }
+ }, [
+ selectedPage,
+ pageSwitch.currentBgImageUrl,
+ pageSwitch.currentBgVideoUrl,
+ pageSwitch.switchToPage,
+ ]);
+
// Handle background ready state for pages without images or with videos
useEffect(() => {
// If no background image, or if there's a video (video takes over), mark as ready
@@ -515,6 +360,10 @@ export default function RuntimePresentation({
if (!targetPage) return;
if (transitionVideoUrl) {
+ // Reset states from previous transition before starting new one
+ // This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets
+ setIsOverlayFadingOut(false);
+ setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
targetPageId,
@@ -522,28 +371,62 @@ export default function RuntimePresentation({
isReverse: isBack,
});
} else {
- // Direct navigation - wait for images first, then switch
- await waitForPageImages(targetPage);
- // Mark background as loading (Image onLoad will set it back to true)
+ // Direct navigation - use shared hook for smooth transition
+ // Previous background stays visible until new one is ready
setIsBackgroundReady(false);
- setSelectedPageId(targetPageId);
- setPageHistory((prev) => [...prev, targetPageId]);
+ // Mark this page as initialized to prevent redundant effect calls
+ lastInitializedPageIdRef.current = targetPageId;
+
+ await pageSwitch.switchToPage(targetPage, () => {
+ setSelectedPageId(targetPageId);
+ setPageHistory((prev) => [...prev, targetPageId]);
+ });
}
},
- [pages],
+ [pages, pageSwitch],
);
const handleElementClick = useCallback(
(element: any) => {
- if (element.targetPageId) {
+ // Disable navigation while transition is actively playing or buffering
+ // Only block during active phases, not during fade-out (completed phase)
+ const isActivelyPlaying = transitionPhase === 'preparing' || transitionPhase === 'playing' || transitionPhase === 'reversing';
+ if (isActivelyPlaying || isBuffering) {
+ return;
+ }
+
+ // Support both targetPageSlug (new) and targetPageId (legacy)
+ const targetPageSlug = element.targetPageSlug;
+ const legacyTargetPageId = element.targetPageId;
+
+ // Resolve slug to page ID, or use legacy targetPageId
+ let targetPageId: string | undefined;
+ if (targetPageSlug) {
+ const targetPage = pages.find((p) => p.slug === targetPageSlug);
+ targetPageId = targetPage?.id;
+ } else if (legacyTargetPageId) {
+ targetPageId = legacyTargetPageId;
+ }
+
+ // Debug: log element navigation data
+ logger.info('Element clicked', {
+ elementType: element.type,
+ targetPageSlug,
+ legacyTargetPageId,
+ resolvedTargetPageId: targetPageId,
+ transitionVideoUrl: element.transitionVideoUrl,
+ hasTransition: Boolean(element.transitionVideoUrl),
+ });
+
+ if (targetPageId) {
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
// Get transition video URL from element itself
const transitionVideoUrl = element.transitionVideoUrl;
- navigateToPage(element.targetPageId, transitionVideoUrl, isBack);
+ navigateToPage(targetPageId, transitionVideoUrl, isBack);
}
},
- [navigateToPage],
+ [navigateToPage, pages, transitionPhase, isBuffering],
);
// Render element content based on type
@@ -750,12 +633,10 @@ export default function RuntimePresentation({
return null;
};
- const backgroundImageUrl = selectedPage?.background_image_url
- ? resolveAssetPlaybackUrl(selectedPage.background_image_url)
- : '';
- const backgroundVideoUrl = selectedPage?.background_video_url
- ? resolveAssetPlaybackUrl(selectedPage.background_video_url)
- : '';
+ // Use resolved URLs from shared hook (blob URLs if cached, otherwise original URLs)
+ // Blob URLs render instantly since data is local in memory
+ const backgroundImageUrl = pageSwitch.currentBgImageUrl;
+ const backgroundVideoUrl = pageSwitch.currentBgVideoUrl;
if (isLoading) {
return (
@@ -792,7 +673,8 @@ export default function RuntimePresentation({
backgroundPosition: 'center',
}}
>
- {/* Background image element - ensures proper loading for waitForPageImages() */}
+ {/* Background image element - CSS backgroundImage provides instant display,
+ Image component enhances with optimized loading. bg-black prevents white flash. */}
{backgroundImageUrl && !backgroundVideoUrl && (
setIsBackgroundReady(true)}
- onError={() => setIsBackgroundReady(true)}
+ onLoad={() => {
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
+ onError={() => {
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
/>
)}
+ {/* Previous background overlay - shows during direct navigation until new bg is ready */}
+ {pageSwitch.previousBgImageUrl &&
+ pageSwitch.isSwitching &&
+ !pageSwitch.isNewBgReady && (
+
+ )}
+
{/* Background video */}
{backgroundVideoUrl && (