implemented:
- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL) - toggle for info panel media opening (fullscreen or in the panel) - ability to replace background with info panel media (image, video, 360 panorama) - ability to make 360 panorama as page background - global mute button -
This commit is contained in:
parent
ade1afab7c
commit
6413c7bdf0
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,5 +5,5 @@ node_modules/
|
||||
**/node_modules/
|
||||
*/build/
|
||||
package-lock.json
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
AGENTS.md
|
||||
.codex/
|
||||
|
||||
@ -25,6 +25,7 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
'slug',
|
||||
'background_image_url',
|
||||
'background_video_url',
|
||||
'background_embed_url',
|
||||
'background_audio_url',
|
||||
'ui_schema_json',
|
||||
];
|
||||
@ -72,6 +73,7 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
sort_order: data.sort_order || null,
|
||||
background_image_url: data.background_image_url || null,
|
||||
background_video_url: data.background_video_url || null,
|
||||
background_embed_url: data.background_embed_url || null,
|
||||
background_audio_url: data.background_audio_url || null,
|
||||
background_audio_autoplay:
|
||||
data.background_audio_autoplay !== undefined
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('tour_pages', 'background_embed_url', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn('tour_pages', 'background_embed_url');
|
||||
},
|
||||
};
|
||||
@ -62,6 +62,10 @@ module.exports = function (sequelize, DataTypes) {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
background_embed_url: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
background_audio_url: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
|
||||
@ -18,6 +18,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'sort_order',
|
||||
'background_image_url',
|
||||
'background_video_url',
|
||||
'background_embed_url',
|
||||
'background_audio_url',
|
||||
'background_loop',
|
||||
'requires_auth',
|
||||
|
||||
@ -448,6 +448,11 @@ class ProjectsService extends BaseProjectsService {
|
||||
assetPathMap.get(pageData.background_video_url) ||
|
||||
pageData.background_video_url;
|
||||
}
|
||||
if (pageData.background_embed_url) {
|
||||
pageData.background_embed_url =
|
||||
assetPathMap.get(pageData.background_embed_url) ||
|
||||
pageData.background_embed_url;
|
||||
}
|
||||
if (pageData.background_audio_url) {
|
||||
pageData.background_audio_url =
|
||||
assetPathMap.get(pageData.background_audio_url) ||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* BackgroundSettingsEditor Component
|
||||
*
|
||||
* Compact editor for background image, video, or audio settings.
|
||||
* Compact editor for background image, video, 360/embed, or audio settings.
|
||||
* Used in the element editor panel for background menu items.
|
||||
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
|
||||
*/
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
|
||||
interface BackgroundSettingsEditorProps {
|
||||
type: 'image' | 'video' | 'audio';
|
||||
type: 'image' | 'video' | 'embed' | 'audio';
|
||||
value: string;
|
||||
options: AssetOption[];
|
||||
durationNote?: string;
|
||||
@ -38,6 +38,7 @@ interface BackgroundSettingsEditorProps {
|
||||
const LABELS: Record<string, string> = {
|
||||
image: 'Background image',
|
||||
video: 'Background video',
|
||||
embed: 'Background 360',
|
||||
audio: 'Background audio',
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* CanvasBackground Component
|
||||
*
|
||||
* Background image, video, and audio for the constructor canvas.
|
||||
* Background image, video, 360/embed, and audio for the constructor canvas.
|
||||
* Handles blob URLs, Next.js Image optimization, and previous background overlay.
|
||||
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
||||
*/
|
||||
@ -18,6 +18,7 @@ import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayba
|
||||
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
|
||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||
import { baseURLApi } from '../../config';
|
||||
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
|
||||
|
||||
/**
|
||||
* Schedule a callback to run after the next browser paint.
|
||||
@ -45,6 +46,7 @@ interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
||||
interface CanvasBackgroundProps {
|
||||
backgroundImageUrl?: string;
|
||||
backgroundVideoUrl?: string;
|
||||
backgroundEmbedUrl?: string;
|
||||
backgroundAudioUrl?: string;
|
||||
previousBgImageUrl?: string;
|
||||
previousBgVideoUrl?: string;
|
||||
@ -76,6 +78,7 @@ interface CanvasBackgroundProps {
|
||||
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
backgroundImageUrl,
|
||||
backgroundVideoUrl,
|
||||
backgroundEmbedUrl,
|
||||
backgroundAudioUrl,
|
||||
previousBgImageUrl,
|
||||
previousBgVideoUrl,
|
||||
@ -96,6 +99,20 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
audioStoragePath,
|
||||
pauseAudio = false,
|
||||
}) => {
|
||||
const hasEmbedBackground = Boolean(backgroundEmbedUrl);
|
||||
const embedBackgroundSrc = useMemo(
|
||||
() =>
|
||||
backgroundEmbedUrl ? buildChromeFreeEmbedUrl(backgroundEmbedUrl) : '',
|
||||
[backgroundEmbedUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backgroundEmbedUrl) return;
|
||||
scheduleAfterPaint(() => {
|
||||
onBackgroundReady?.();
|
||||
});
|
||||
}, [backgroundEmbedUrl, onBackgroundReady]);
|
||||
|
||||
// During page switching with video paused, keep showing the previous video URL.
|
||||
// This prevents black flash when the video element would remount with a new URL.
|
||||
// The old video element stays visible (paused at frozen frame) until new page is ready.
|
||||
@ -141,7 +158,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
// Track video buffering via canplay/waiting events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!backgroundVideoUrl || !video) {
|
||||
if (!backgroundVideoUrl || hasEmbedBackground || !video) {
|
||||
setIsVideoBuffering(false);
|
||||
// CRITICAL: Also notify parent that buffering is done when there's no video
|
||||
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
|
||||
@ -170,7 +187,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('waiting', handleWaiting);
|
||||
};
|
||||
}, [backgroundVideoUrl, onVideoBufferStateChange]);
|
||||
}, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
|
||||
|
||||
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
|
||||
const videoSrc = useMemo(() => {
|
||||
@ -413,7 +430,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
{/* Background image - z-1 keeps it below backdrop blur layer (z-5).
|
||||
Image layer stays visible while video buffers (fallback behavior).
|
||||
When video is ready, image fades out via opacity transition. */}
|
||||
{backgroundImageUrl && (
|
||||
{backgroundEmbedUrl && (
|
||||
<iframe
|
||||
key={`bg_embed_${embedBackgroundSrc}`}
|
||||
src={embedBackgroundSrc}
|
||||
title='360 background'
|
||||
className='absolute inset-0 z-1 h-full w-full border-0'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
|
||||
/>
|
||||
)}
|
||||
|
||||
{backgroundImageUrl && !hasEmbedBackground && (
|
||||
<div
|
||||
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
|
||||
style={{
|
||||
@ -473,7 +500,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
webkit-playsinline is legacy attribute for older iOS versions.
|
||||
preload="metadata" is required for iOS Safari video initialization.
|
||||
Video fades in when ready (opacity transition from 0 to 1). */}
|
||||
{activeVideoUrl && (
|
||||
{activeVideoUrl && !hasEmbedBackground && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={`bg_video_${activeVideoUrl}`}
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
mdiMusicNote,
|
||||
mdiVideo,
|
||||
mdiInformationOutline,
|
||||
mdiPanoramaHorizontal,
|
||||
} from '@mdi/js';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import BaseButton from '../BaseButton';
|
||||
@ -186,6 +187,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
handleMenuAction(() => onSelectMenuItem('background_video'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiPanoramaHorizontal}
|
||||
label='Background 360'
|
||||
onClick={() =>
|
||||
handleMenuAction(() => onSelectMenuItem('background_embed'))
|
||||
}
|
||||
/>
|
||||
<MenuActionButton
|
||||
icon={mdiMusicNote}
|
||||
label='Background Audio'
|
||||
|
||||
@ -153,6 +153,7 @@ export function ElementEditorPanel({
|
||||
pageBackground,
|
||||
setBackgroundImageUrl,
|
||||
setBackgroundVideoUrl,
|
||||
setBackgroundEmbedUrl,
|
||||
setBackgroundAudioUrl,
|
||||
setBackgroundVideoSettings,
|
||||
setBackgroundAudioSettings,
|
||||
@ -208,7 +209,10 @@ export function ElementEditorPanel({
|
||||
options={assetOptions.backgroundImage}
|
||||
onChange={(value) => {
|
||||
setBackgroundImageUrl(value);
|
||||
if (value) setBackgroundVideoUrl('');
|
||||
if (value) {
|
||||
setBackgroundVideoUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -222,7 +226,10 @@ export function ElementEditorPanel({
|
||||
durationNote={durationNotes.backgroundVideo}
|
||||
onChange={(value) => {
|
||||
setBackgroundVideoUrl(value);
|
||||
if (value) setBackgroundImageUrl('');
|
||||
if (value) {
|
||||
setBackgroundImageUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
videoAutoplay={pageBackground.videoSettings.autoplay}
|
||||
videoLoop={pageBackground.videoSettings.loop}
|
||||
@ -233,6 +240,22 @@ export function ElementEditorPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background 360 Settings */}
|
||||
{selectedMenuItem === 'background_embed' && (
|
||||
<BackgroundSettingsEditor
|
||||
type='embed'
|
||||
value={pageBackground.embedUrl}
|
||||
options={assetOptions.embed}
|
||||
onChange={(value) => {
|
||||
setBackgroundEmbedUrl(value);
|
||||
if (value) {
|
||||
setBackgroundImageUrl('');
|
||||
setBackgroundVideoUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background Audio Settings */}
|
||||
{selectedMenuItem === 'background_audio' && (
|
||||
<BackgroundSettingsEditor
|
||||
@ -522,8 +545,11 @@ export function ElementEditorPanel({
|
||||
<InfoPanelSettingsSectionCompact
|
||||
element={selectedElement}
|
||||
imageAssetOptions={assetOptions.image}
|
||||
videoAssetOptions={assetOptions.video}
|
||||
iconAssetOptions={assetOptions.icon}
|
||||
embedAssetOptions={assetOptions.embed}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(prop, value) =>
|
||||
updateSelectedElement({ [prop]: value })
|
||||
}
|
||||
@ -1317,15 +1343,15 @@ export function ElementEditorPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images Section Styles */}
|
||||
{/* Media Section Styles */}
|
||||
<div className='rounded border border-white/10 p-2 space-y-2'>
|
||||
<p className='text-[10px] font-semibold text-white/80'>
|
||||
Images Section
|
||||
Media Section
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Preview Height
|
||||
Preview height
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
@ -1344,7 +1370,7 @@ export function ElementEditorPanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Thumbnail Size
|
||||
Thumbnail size
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
|
||||
@ -47,6 +47,7 @@ export interface TourPage {
|
||||
ui_schema_json?: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
|
||||
@ -90,7 +90,7 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
|
||||
detailCaptionFontFamily,
|
||||
// Section instances (order + per-section settings)
|
||||
infoPanelSections,
|
||||
// Images section settings
|
||||
// Media section settings
|
||||
infoPanelImagesPreviewHeight,
|
||||
infoPanelImagesThumbnailSize,
|
||||
// Handlers
|
||||
@ -783,13 +783,13 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images Section Layout */}
|
||||
{/* Media Section Layout */}
|
||||
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
|
||||
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
|
||||
Images Section Layout
|
||||
Media Section Layout
|
||||
</h3>
|
||||
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||
Settings for the inline image viewer (preview + thumbnail strip). Use
|
||||
Settings for the inline media viewer (preview + thumbnail strip). Use
|
||||
the "images" section type in Section Order to enable.
|
||||
</p>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
|
||||
@ -14,6 +14,9 @@ import type {
|
||||
InfoPanelSectionType,
|
||||
InfoPanelItemType,
|
||||
InfoPanelSectionInstance,
|
||||
InfoPanelImageClickAction,
|
||||
InfoPanelLinkClickAction,
|
||||
InfoPanelMediaOpenMode,
|
||||
} from '../../types/constructor';
|
||||
import {
|
||||
getInfoPanelSections,
|
||||
@ -24,8 +27,11 @@ import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
interface InfoPanelSettingsSectionCompactProps {
|
||||
element: CanvasElement;
|
||||
imageAssetOptions: AssetOption[];
|
||||
videoAssetOptions: AssetOption[];
|
||||
iconAssetOptions: AssetOption[];
|
||||
embedAssetOptions?: AssetOption[];
|
||||
pages: Array<{ id: string; slug?: string; name?: string }>;
|
||||
activePageId: string;
|
||||
onChange: (prop: string, value: string | number | boolean) => void;
|
||||
// Section operations (new per-instance API)
|
||||
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
|
||||
@ -85,13 +91,229 @@ const ALL_SECTION_TYPES: InfoPanelSectionType[] = [
|
||||
'images',
|
||||
];
|
||||
|
||||
const mediaTypePatch = (
|
||||
itemType: InfoPanelItemType,
|
||||
): Partial<InfoPanelImage> => {
|
||||
if (itemType === 'video') {
|
||||
return { itemType, imageUrl: '', embedUrl: '', iconUrl: '' };
|
||||
}
|
||||
if (itemType === '360') {
|
||||
return { itemType, imageUrl: '', videoUrl: '' };
|
||||
}
|
||||
return { itemType, videoUrl: '', embedUrl: '', iconUrl: '' };
|
||||
};
|
||||
|
||||
const targetPageOptions = (
|
||||
pages: Array<{ id: string; slug?: string; name?: string }>,
|
||||
activePageId: string,
|
||||
) =>
|
||||
pages
|
||||
.filter((page) => page.id !== activePageId && page.slug)
|
||||
.map((page, index) => ({
|
||||
value: page.slug || '',
|
||||
label: page.name || `Page ${index + 1}`,
|
||||
}));
|
||||
|
||||
const LinkDestinationFields: React.FC<{
|
||||
action?: InfoPanelLinkClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
pages: Array<{ id: string; slug?: string; name?: string }>;
|
||||
activePageId: string;
|
||||
onChange: (patch: {
|
||||
clickAction?: InfoPanelLinkClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
}) => void;
|
||||
}> = ({
|
||||
action,
|
||||
targetPageSlug,
|
||||
externalUrl,
|
||||
pages,
|
||||
activePageId,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className='space-y-1 rounded border border-white/10 p-2'>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Click action
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={action || ''}
|
||||
onChange={(e) => {
|
||||
const nextAction = e.target.value as InfoPanelLinkClickAction | '';
|
||||
onChange({
|
||||
clickAction: nextAction || undefined,
|
||||
targetPageSlug:
|
||||
nextAction === 'target_page' ? targetPageSlug || '' : undefined,
|
||||
externalUrl:
|
||||
nextAction === 'external_url' ? externalUrl || '' : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value=''>No click action</option>
|
||||
<option value='target_page'>Target page</option>
|
||||
<option value='external_url'>External URL</option>
|
||||
</select>
|
||||
|
||||
{action === 'target_page' && (
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={targetPageSlug || ''}
|
||||
onChange={(e) => onChange({ targetPageSlug: e.target.value })}
|
||||
>
|
||||
<option value=''>Select page...</option>
|
||||
{targetPageOptions(pages, activePageId).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{action === 'external_url' && (
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={externalUrl || ''}
|
||||
onChange={(e) => onChange({ externalUrl: e.target.value })}
|
||||
placeholder='https://example.com'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MediaOpenModeField: React.FC<{
|
||||
value?: InfoPanelMediaOpenMode;
|
||||
onChange: (mode: InfoPanelMediaOpenMode) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<div className='space-y-1 rounded border border-white/10 p-2'>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Media open mode
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={value || 'panel'}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value === 'fullscreen' ? 'fullscreen' : 'panel')
|
||||
}
|
||||
>
|
||||
<option value='panel'>Side preview panel</option>
|
||||
<option value='fullscreen'>Fullscreen gallery</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ImageDestinationFields: React.FC<{
|
||||
action?: InfoPanelImageClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
disabled?: boolean;
|
||||
pages: Array<{ id: string; slug?: string; name?: string }>;
|
||||
activePageId: string;
|
||||
onChange: (patch: {
|
||||
clickAction?: InfoPanelImageClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
}) => void;
|
||||
}> = ({
|
||||
action,
|
||||
targetPageSlug,
|
||||
externalUrl,
|
||||
disabled = false,
|
||||
pages,
|
||||
activePageId,
|
||||
onChange,
|
||||
}) => (
|
||||
<div
|
||||
className={`space-y-1 rounded border border-white/10 p-2 ${
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Destination override
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
|
||||
disabled={disabled}
|
||||
value={
|
||||
action === 'target_page' || action === 'external_url' ? action : ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const nextAction = e.target.value as
|
||||
| Extract<InfoPanelImageClickAction, 'target_page' | 'external_url'>
|
||||
| '';
|
||||
onChange({
|
||||
clickAction: nextAction || undefined,
|
||||
targetPageSlug:
|
||||
nextAction === 'target_page' ? targetPageSlug || '' : undefined,
|
||||
externalUrl:
|
||||
nextAction === 'external_url' ? externalUrl || '' : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value=''>Use section media mode</option>
|
||||
<option value='target_page'>Target page</option>
|
||||
<option value='external_url'>External URL</option>
|
||||
</select>
|
||||
|
||||
{action === 'target_page' && (
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
|
||||
disabled={disabled}
|
||||
value={targetPageSlug || ''}
|
||||
onChange={(e) => onChange({ targetPageSlug: e.target.value })}
|
||||
>
|
||||
<option value=''>Select page...</option>
|
||||
{targetPageOptions(pages, activePageId).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{action === 'external_url' && (
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs disabled:cursor-not-allowed'
|
||||
disabled={disabled}
|
||||
value={externalUrl || ''}
|
||||
onChange={(e) => onChange({ externalUrl: e.target.value })}
|
||||
placeholder='https://example.com'
|
||||
/>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className='text-[10px] text-white/50'>
|
||||
Disabled while this item is used as the screen background.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const UseAsBackgroundField: React.FC<{
|
||||
checked?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ checked, onChange }) => (
|
||||
<label className='flex cursor-pointer items-center gap-2 rounded border border-white/10 p-2 text-[11px] text-white/80'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
checked={Boolean(checked)}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
Use as screen background
|
||||
</label>
|
||||
);
|
||||
|
||||
const InfoPanelSettingsSectionCompact: React.FC<
|
||||
InfoPanelSettingsSectionCompactProps
|
||||
> = ({
|
||||
element,
|
||||
imageAssetOptions,
|
||||
videoAssetOptions,
|
||||
iconAssetOptions,
|
||||
embedAssetOptions = [],
|
||||
pages,
|
||||
activePageId,
|
||||
onChange,
|
||||
onMoveSection,
|
||||
onRemoveSection,
|
||||
@ -319,6 +541,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='Header text (shown if no image)'
|
||||
/>
|
||||
</div>
|
||||
<LinkDestinationFields
|
||||
action={section.clickAction}
|
||||
targetPageSlug={section.targetPageSlug}
|
||||
externalUrl={section.externalUrl}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) => onUpdateSection?.(section.id, patch)}
|
||||
/>
|
||||
<p className='text-[10px] text-white/50'>
|
||||
Styling configured in CSS tab.
|
||||
</p>
|
||||
@ -351,6 +581,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='Information'
|
||||
/>
|
||||
</div>
|
||||
<LinkDestinationFields
|
||||
action={section.clickAction}
|
||||
targetPageSlug={section.targetPageSlug}
|
||||
externalUrl={section.externalUrl}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) => onUpdateSection?.(section.id, patch)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -381,6 +619,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='Description text...'
|
||||
/>
|
||||
</div>
|
||||
<LinkDestinationFields
|
||||
action={section.clickAction}
|
||||
targetPageSlug={section.targetPageSlug}
|
||||
externalUrl={section.externalUrl}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) => onUpdateSection?.(section.id, patch)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -518,6 +764,16 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<LinkDestinationFields
|
||||
action={span.clickAction}
|
||||
targetPageSlug={span.targetPageSlug}
|
||||
externalUrl={span.externalUrl}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) =>
|
||||
onUpdateSpan?.(section.id, span.id, patch)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sectionSpans.length === 0 && (
|
||||
@ -614,6 +870,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='8'
|
||||
/>
|
||||
</div>
|
||||
<MediaOpenModeField
|
||||
value={section.mediaOpenMode}
|
||||
onChange={(mode) =>
|
||||
onUpdateSection?.(section.id, {
|
||||
mediaOpenMode: mode,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{sectionImages.map((image, imgIndex) => (
|
||||
<div
|
||||
key={image.id}
|
||||
@ -644,12 +908,17 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={image.itemType || 'image'}
|
||||
onChange={(e) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
itemType: e.target.value as 'image' | '360',
|
||||
})
|
||||
onUpdateImage?.(
|
||||
section.id,
|
||||
image.id,
|
||||
mediaTypePatch(
|
||||
e.target.value as InfoPanelItemType,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value='image'>Image</option>
|
||||
<option value='video'>Video</option>
|
||||
<option value='360'>360° Embed</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -664,6 +933,29 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
}
|
||||
placeholder='https://my.matterport.com/show/?m=...'
|
||||
/>
|
||||
) : image.itemType === 'video' ? (
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={image.videoUrl || ''}
|
||||
onChange={(e) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
videoUrl: e.target.value,
|
||||
imageUrl: '',
|
||||
embedUrl: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>Select video...</option>
|
||||
{addFallbackAssetOption(
|
||||
videoAssetOptions,
|
||||
image.videoUrl,
|
||||
`Current video`,
|
||||
).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
@ -696,6 +988,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
}
|
||||
placeholder='Image caption...'
|
||||
/>
|
||||
<UseAsBackgroundField
|
||||
checked={image.useAsBackground}
|
||||
onChange={(checked) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
useAsBackground: checked || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ImageDestinationFields
|
||||
action={image.clickAction}
|
||||
targetPageSlug={image.targetPageSlug}
|
||||
externalUrl={image.externalUrl}
|
||||
disabled={Boolean(image.useAsBackground)}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) =>
|
||||
onUpdateImage?.(section.id, image.id, patch)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sectionImages.length === 0 && (
|
||||
@ -793,41 +1104,31 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='8'
|
||||
/>
|
||||
</div>
|
||||
<MediaOpenModeField
|
||||
value={section.mediaOpenMode}
|
||||
onChange={(mode) =>
|
||||
onUpdateSection?.(section.id, {
|
||||
mediaOpenMode: mode,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Layout settings */}
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Preview Height
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
|
||||
value={element.infoPanelImagesPreviewHeight || ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
'infoPanelImagesPreviewHeight',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder='300 or auto'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Thumbnail Size
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
|
||||
value={element.infoPanelImagesThumbnailSize || ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
'infoPanelImagesThumbnailSize',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder='80'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Thumbnail Size
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
|
||||
value={element.infoPanelImagesThumbnailSize || ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
'infoPanelImagesThumbnailSize',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder='80'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Styling settings (uses card styling props) */}
|
||||
@ -918,11 +1219,12 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
const newType = e.target
|
||||
.value as InfoPanelItemType;
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
itemType: newType,
|
||||
...mediaTypePatch(newType),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value='image'>Image</option>
|
||||
<option value='video'>Video</option>
|
||||
<option value='360'>360° Trigger</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -939,6 +1241,9 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
onChange={(e) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
imageUrl: e.target.value,
|
||||
videoUrl: '',
|
||||
embedUrl: '',
|
||||
iconUrl: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
@ -957,6 +1262,37 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : currentItemType === 'video' ? (
|
||||
<div>
|
||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||
Video
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={image.videoUrl || ''}
|
||||
onChange={(e) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
videoUrl: e.target.value,
|
||||
imageUrl: '',
|
||||
embedUrl: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>Select video...</option>
|
||||
{addFallbackAssetOption(
|
||||
videoAssetOptions,
|
||||
image.videoUrl,
|
||||
`Current video`,
|
||||
).map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
@ -1036,6 +1372,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
||||
placeholder='Optional caption...'
|
||||
/>
|
||||
</div>
|
||||
<UseAsBackgroundField
|
||||
checked={image.useAsBackground}
|
||||
onChange={(checked) =>
|
||||
onUpdateImage?.(section.id, image.id, {
|
||||
useAsBackground: checked || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ImageDestinationFields
|
||||
action={image.clickAction}
|
||||
targetPageSlug={image.targetPageSlug}
|
||||
externalUrl={image.externalUrl}
|
||||
disabled={Boolean(image.useAsBackground)}
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onChange={(patch) =>
|
||||
onUpdateImage?.(section.id, image.id, patch)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -331,7 +331,7 @@ export interface InfoPanelSettingsSectionProps {
|
||||
detailCaptionFontFamily: string;
|
||||
// Section instances (order + per-section settings)
|
||||
infoPanelSections?: InfoPanelSectionInstance[];
|
||||
// Images section settings
|
||||
// Media section settings
|
||||
infoPanelImagesPreviewHeight?: string;
|
||||
infoPanelImagesThumbnailSize?: string;
|
||||
// Handlers
|
||||
|
||||
@ -40,11 +40,11 @@ interface RuntimeControlsProps {
|
||||
canvasWidth?: number;
|
||||
/** Canvas height in pixels (for positioning relative to canvas) */
|
||||
canvasHeight?: number;
|
||||
/** Whether to show the sound toggle button (video has sound enabled) */
|
||||
/** Whether to show the global sound toggle button */
|
||||
showSoundButton?: boolean;
|
||||
/** Current muted state of the video */
|
||||
/** Current muted state of presentation audio */
|
||||
isMuted?: boolean;
|
||||
/** Callback to toggle sound on/off */
|
||||
/** Callback to toggle all presentation sound on/off */
|
||||
onSoundToggle?: () => void;
|
||||
}
|
||||
|
||||
@ -146,6 +146,14 @@ function ControlButton({
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
const colors = buttonColors[color];
|
||||
const stopButtonEvent = (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.PointerEvent<HTMLButtonElement>
|
||||
| React.TouchEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const buttonStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
@ -167,7 +175,16 @@ function ControlButton({
|
||||
<button
|
||||
type='button'
|
||||
style={buttonStyle}
|
||||
onClick={onClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerDown={stopButtonEvent}
|
||||
onPointerDownCapture={stopButtonEvent}
|
||||
onMouseDown={stopButtonEvent}
|
||||
onMouseDownCapture={stopButtonEvent}
|
||||
onTouchEnd={stopButtonEvent}
|
||||
onTouchEndCapture={stopButtonEvent}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onMouseEnter={(e) => {
|
||||
@ -190,6 +207,15 @@ function ControlButton({
|
||||
* Delete button for removing offline data (smaller, subtle styling)
|
||||
*/
|
||||
function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||
const stopButtonEvent = (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.PointerEvent<HTMLButtonElement>
|
||||
| React.TouchEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const buttonStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
@ -207,7 +233,16 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||
<button
|
||||
type='button'
|
||||
style={buttonStyle}
|
||||
onClick={onClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerDown={stopButtonEvent}
|
||||
onPointerDownCapture={stopButtonEvent}
|
||||
onMouseDown={stopButtonEvent}
|
||||
onMouseDownCapture={stopButtonEvent}
|
||||
onTouchEnd={stopButtonEvent}
|
||||
onTouchEndCapture={stopButtonEvent}
|
||||
title='Remove offline data'
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#EF4444'; // text-red-500
|
||||
@ -442,10 +477,11 @@ export default function RuntimeControls({
|
||||
// Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%)
|
||||
// So we offset from viewport edge by (viewport - canvas) / 2 + padding
|
||||
const padding = 16;
|
||||
const rightSafetyInset = 48;
|
||||
const rightOffset =
|
||||
canvasWidth > 0 && viewport.width > 0
|
||||
? (viewport.width - canvasWidth) / 2 + padding
|
||||
: padding;
|
||||
? (viewport.width - canvasWidth) / 2 + padding + rightSafetyInset
|
||||
: padding + rightSafetyInset;
|
||||
const topOffset =
|
||||
canvasHeight > 0 && viewport.height > 0
|
||||
? (viewport.height - canvasHeight) / 2 + padding
|
||||
@ -464,8 +500,26 @@ export default function RuntimeControls({
|
||||
transformOrigin: 'top right',
|
||||
};
|
||||
|
||||
const stopControlEvent = (
|
||||
event:
|
||||
| React.MouseEvent<HTMLDivElement>
|
||||
| React.PointerEvent<HTMLDivElement>
|
||||
| React.TouchEvent<HTMLDivElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={stopControlEvent}
|
||||
onPointerDown={stopControlEvent}
|
||||
onPointerDownCapture={stopControlEvent}
|
||||
onMouseDown={stopControlEvent}
|
||||
onMouseDownCapture={stopControlEvent}
|
||||
onTouchEnd={stopControlEvent}
|
||||
onTouchEndCapture={stopControlEvent}
|
||||
>
|
||||
<OfflineControl
|
||||
projectId={projectId}
|
||||
projectSlug={projectSlug}
|
||||
|
||||
@ -100,6 +100,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
left: `${xPercent}%`,
|
||||
top: `${yPercent}%`,
|
||||
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
|
||||
// Add appear animation to outer div
|
||||
|
||||
@ -63,7 +63,11 @@ import {
|
||||
selectByProjectAndEnv as selectProjectTransitionSettings,
|
||||
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||
import type { TransitionPhase } from '../types/presentation';
|
||||
import type { CanvasElement, InfoPanelImage } from '../types/constructor';
|
||||
import type {
|
||||
CanvasElement,
|
||||
GalleryCarouselMediaItem,
|
||||
InfoPanelImage,
|
||||
} from '../types/constructor';
|
||||
import { isInfoPanelElementType } from '../lib/elementDefaults';
|
||||
import type { ElementTransitionSettings } from '../types/transition';
|
||||
import {
|
||||
@ -189,7 +193,11 @@ export default function RuntimePresentation({
|
||||
);
|
||||
const [activeDetailImage, setActiveDetailImage] =
|
||||
useState<InfoPanelImage | null>(null);
|
||||
// Track selected image in images section (runtime-only local state)
|
||||
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
|
||||
items: GalleryCarouselMediaItem[];
|
||||
initialIndex: number;
|
||||
} | null>(null);
|
||||
// Track selected image in media section (runtime-only local state)
|
||||
const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@ -279,6 +287,7 @@ export default function RuntimePresentation({
|
||||
const {
|
||||
currentImageUrl: navCurrentBgImageUrl,
|
||||
currentVideoUrl: navCurrentBgVideoUrl,
|
||||
currentEmbedUrl: navCurrentBgEmbedUrl,
|
||||
currentAudioUrl: navCurrentBgAudioUrl,
|
||||
previousImageUrl: navPreviousBgImageUrl,
|
||||
previousVideoUrl: navPreviousBgVideoUrl,
|
||||
@ -295,6 +304,7 @@ export default function RuntimePresentation({
|
||||
onVideoBufferStateChange,
|
||||
onTransitionEnded,
|
||||
navigateToPage: navNavigateToPage,
|
||||
setBackgroundDirectly: navSetBackgroundDirectly,
|
||||
resetToIdle: navResetToIdle,
|
||||
startTransition,
|
||||
} = navState;
|
||||
@ -437,7 +447,11 @@ export default function RuntimePresentation({
|
||||
useEffect(() => {
|
||||
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
|
||||
// Only initialize when backgrounds are empty (initial load)
|
||||
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
|
||||
if (
|
||||
!navCurrentBgImageUrl &&
|
||||
!navCurrentBgVideoUrl &&
|
||||
!navCurrentBgEmbedUrl
|
||||
) {
|
||||
lastInitializedPageIdRef.current = selectedPage.id;
|
||||
navNavigateToPage(selectedPage);
|
||||
}
|
||||
@ -446,6 +460,7 @@ export default function RuntimePresentation({
|
||||
selectedPage,
|
||||
navCurrentBgImageUrl,
|
||||
navCurrentBgVideoUrl,
|
||||
navCurrentBgEmbedUrl,
|
||||
navNavigateToPage,
|
||||
]);
|
||||
|
||||
@ -569,6 +584,107 @@ export default function RuntimePresentation({
|
||||
],
|
||||
);
|
||||
|
||||
const handleInfoPanelNavigateToPage = useCallback(
|
||||
(targetPageSlug: string) => {
|
||||
if (
|
||||
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPage = pages.find((page) => page.slug === targetPageSlug);
|
||||
if (!targetPage) return;
|
||||
|
||||
setActiveInfoPanel(null);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
setRuntimeSelectedImageId(null);
|
||||
navigateToPage(targetPage.id);
|
||||
},
|
||||
[navigateToPage, pages, transitionPhase, isBuffering],
|
||||
);
|
||||
|
||||
const handleInfoPanelOpenExternalUrl = useCallback((url: string) => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleInfoPanelUseAsBackground = useCallback(
|
||||
(item: InfoPanelImage) => {
|
||||
const mediaType =
|
||||
item.itemType === 'video'
|
||||
? 'video'
|
||||
: item.itemType === '360'
|
||||
? '360'
|
||||
: 'image';
|
||||
if (
|
||||
(mediaType === 'image' && !item.imageUrl) ||
|
||||
(mediaType === 'video' && !item.videoUrl) ||
|
||||
(mediaType === '360' && !item.embedUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
navSetBackgroundDirectly(
|
||||
mediaType === 'image' && item.imageUrl
|
||||
? resolveAssetPlaybackUrl(item.imageUrl)
|
||||
: '',
|
||||
mediaType === 'video' && item.videoUrl
|
||||
? resolveAssetPlaybackUrl(item.videoUrl)
|
||||
: '',
|
||||
mediaType === '360' && item.embedUrl
|
||||
? resolveAssetPlaybackUrl(item.embedUrl)
|
||||
: '',
|
||||
navCurrentBgAudioUrl,
|
||||
);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
},
|
||||
[navCurrentBgAudioUrl, navSetBackgroundDirectly],
|
||||
);
|
||||
|
||||
const handleInfoPanelOpenGallery = useCallback(
|
||||
(items: InfoPanelImage[], initialIndex: number) => {
|
||||
const activeItemId = items[initialIndex]?.id;
|
||||
const galleryItems = items
|
||||
.map<GalleryCarouselMediaItem | null>((item) => {
|
||||
const mediaType =
|
||||
item.itemType === 'video'
|
||||
? 'video'
|
||||
: item.itemType === '360'
|
||||
? '360'
|
||||
: 'image';
|
||||
if (mediaType === 'image' && !item.imageUrl) return null;
|
||||
if (mediaType === 'video' && !item.videoUrl) return null;
|
||||
if (mediaType === '360' && !item.embedUrl) return null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
imageUrl: item.imageUrl,
|
||||
videoUrl: item.videoUrl,
|
||||
embedUrl: item.embedUrl,
|
||||
caption: item.caption,
|
||||
title: item.caption,
|
||||
mediaType,
|
||||
};
|
||||
})
|
||||
.filter((item): item is GalleryCarouselMediaItem => Boolean(item));
|
||||
|
||||
if (galleryItems.length === 0) return;
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery({
|
||||
items: galleryItems,
|
||||
initialIndex: Math.max(
|
||||
0,
|
||||
galleryItems.findIndex((item) => item.id === activeItemId),
|
||||
),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Page loading state from unified navigation state machine
|
||||
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
|
||||
// navShowElements: true when phase is 'idle' or 'fading_in'
|
||||
@ -673,6 +789,7 @@ export default function RuntimePresentation({
|
||||
// Background URLs come directly from navigation state (already resolved)
|
||||
const backgroundImageUrl = navCurrentBgImageUrl;
|
||||
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
||||
const backgroundEmbedUrl = navCurrentBgEmbedUrl;
|
||||
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
||||
|
||||
// Background video playback settings from selected page
|
||||
@ -700,13 +817,47 @@ export default function RuntimePresentation({
|
||||
selectedPage?.background_audio_end_time != null
|
||||
? parseFloat(String(selectedPage.background_audio_end_time))
|
||||
: null;
|
||||
const hasElementAudio = useMemo(
|
||||
() =>
|
||||
pageElements.some((element) => {
|
||||
if (element.hoverAudioUrl || element.clickAudioUrl) return true;
|
||||
if (
|
||||
(element.type === 'audio_player' ||
|
||||
element.type === 'video_player') &&
|
||||
element.mediaUrl &&
|
||||
!element.mediaMuted
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
element.galleryCards?.some(
|
||||
(card: GalleryCarouselMediaItem) =>
|
||||
card.mediaType === 'video' || Boolean(card.videoUrl),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
element.infoPanelSections?.some((section) =>
|
||||
section.images?.some(
|
||||
(item: InfoPanelImage) =>
|
||||
item.itemType === 'video' || Boolean(item.videoUrl),
|
||||
),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
[pageElements],
|
||||
);
|
||||
|
||||
// Sound control hook for iOS autoplay compatibility
|
||||
// Videos start muted (for iOS autoplay), user can unmute via sound button
|
||||
// Global sound control starts muted for browser autoplay compatibility.
|
||||
const soundControl = useVideoSoundControl({
|
||||
pageHasSound: pageVideoMuted === false, // Show button when page allows sound
|
||||
pageHasSound: pageVideoMuted === false,
|
||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
||||
hasBackgroundAudio: Boolean(backgroundAudioUrl),
|
||||
hasElementAudio,
|
||||
});
|
||||
|
||||
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
|
||||
@ -843,6 +994,7 @@ export default function RuntimePresentation({
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageUrl}
|
||||
backgroundVideoUrl={backgroundVideoUrl}
|
||||
backgroundEmbedUrl={backgroundEmbedUrl}
|
||||
backgroundAudioUrl={backgroundAudioUrl}
|
||||
previousBgImageUrl={navPreviousBgImageUrl}
|
||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
||||
@ -885,7 +1037,7 @@ export default function RuntimePresentation({
|
||||
{navShowElements && (
|
||||
<div
|
||||
data-testid='page-elements-wrapper'
|
||||
className='absolute inset-0 z-[46]'
|
||||
className='pointer-events-none absolute inset-0 z-[46]'
|
||||
>
|
||||
{pageElements.map((element: CanvasElement) => (
|
||||
<RuntimeElement
|
||||
@ -1001,6 +1153,35 @@ export default function RuntimePresentation({
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeInfoPanelGallery && (
|
||||
<GalleryCarouselOverlay
|
||||
cards={activeInfoPanelGallery.items}
|
||||
initialIndex={activeInfoPanelGallery.initialIndex}
|
||||
onClose={() => setActiveInfoPanelGallery(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
prevIconUrl={activeInfoPanel?.galleryCarouselPrevIconUrl}
|
||||
nextIconUrl={activeInfoPanel?.galleryCarouselNextIconUrl}
|
||||
backIconUrl={activeInfoPanel?.galleryCarouselBackIconUrl}
|
||||
backLabel={activeInfoPanel?.galleryCarouselBackLabel || 'BACK'}
|
||||
prevX={activeInfoPanel?.galleryCarouselPrevX}
|
||||
prevY={activeInfoPanel?.galleryCarouselPrevY}
|
||||
nextX={activeInfoPanel?.galleryCarouselNextX}
|
||||
nextY={activeInfoPanel?.galleryCarouselNextY}
|
||||
backX={activeInfoPanel?.galleryCarouselBackX}
|
||||
backY={activeInfoPanel?.galleryCarouselBackY}
|
||||
prevWidth={activeInfoPanel?.galleryCarouselPrevWidth}
|
||||
prevHeight={activeInfoPanel?.galleryCarouselPrevHeight}
|
||||
nextWidth={activeInfoPanel?.galleryCarouselNextWidth}
|
||||
nextHeight={activeInfoPanel?.galleryCarouselNextHeight}
|
||||
backWidth={activeInfoPanel?.galleryCarouselBackWidth}
|
||||
backHeight={activeInfoPanel?.galleryCarouselBackHeight}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isEditMode={false}
|
||||
pageTransitionSettings={transitionSettings}
|
||||
galleryElement={activeInfoPanel || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Panel Overlay */}
|
||||
{activeInfoPanel && (
|
||||
<>
|
||||
@ -1016,14 +1197,20 @@ export default function RuntimePresentation({
|
||||
onClose={() => {
|
||||
setActiveInfoPanel(null);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
setRuntimeSelectedImageId(null);
|
||||
}}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
letterboxStyles={letterboxStyles}
|
||||
cssVars={cssVars}
|
||||
onImageClick={(image) => setActiveDetailImage(image)}
|
||||
onOpenGallery={handleInfoPanelOpenGallery}
|
||||
onUseAsBackground={handleInfoPanelUseAsBackground}
|
||||
onSelectImage={(imageId) =>
|
||||
setRuntimeSelectedImageId(imageId)
|
||||
}
|
||||
onNavigateToPage={handleInfoPanelNavigateToPage}
|
||||
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
|
||||
active360ItemId={
|
||||
activeDetailImage?.itemType === '360'
|
||||
? activeDetailImage.id
|
||||
@ -1037,6 +1224,7 @@ export default function RuntimePresentation({
|
||||
onClose={() => setActiveDetailImage(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
letterboxStyles={letterboxStyles}
|
||||
cssVars={cssVars}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -1047,19 +1235,21 @@ export default function RuntimePresentation({
|
||||
|
||||
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
|
||||
{/* Positioned outside canvas to avoid scaling with canvas transform */}
|
||||
<RuntimeControls
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
pages={pages}
|
||||
isFullscreen={isFullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
showSoundButton={soundControl.showSoundButton}
|
||||
isMuted={soundControl.isMuted}
|
||||
onSoundToggle={soundControl.toggleSound}
|
||||
/>
|
||||
{!activeGalleryCarousel && !activeInfoPanelGallery && (
|
||||
<RuntimeControls
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
pages={pages}
|
||||
isFullscreen={isFullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
showSoundButton={soundControl.showSoundButton}
|
||||
isMuted={soundControl.isMuted}
|
||||
onSoundToggle={soundControl.toggleSound}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast notifications for offline download status */}
|
||||
<ToastContainer
|
||||
|
||||
@ -9,7 +9,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Icon from '@mdi/react';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
|
||||
import type { GalleryCard, CanvasElement } from '../../types/constructor';
|
||||
import type {
|
||||
GalleryCard,
|
||||
GalleryCarouselMediaItem,
|
||||
CanvasElement,
|
||||
} from '../../types/constructor';
|
||||
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||
import {
|
||||
@ -17,9 +21,11 @@ import {
|
||||
extractGallerySlideOverride,
|
||||
} from '../../lib/resolveSlideTransition';
|
||||
import { useSlideTransition } from '../../hooks/useSlideTransition';
|
||||
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
|
||||
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
|
||||
|
||||
interface GalleryCarouselOverlayProps {
|
||||
cards: GalleryCard[];
|
||||
cards: Array<GalleryCard | GalleryCarouselMediaItem>;
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
@ -58,6 +64,36 @@ interface GalleryCarouselOverlayProps {
|
||||
galleryElement?: CanvasElement;
|
||||
}
|
||||
|
||||
const getMediaType = (
|
||||
card: GalleryCard | GalleryCarouselMediaItem | undefined,
|
||||
): 'image' | 'video' | '360' => {
|
||||
if (!card) return 'image';
|
||||
if ('mediaType' in card && card.mediaType) return card.mediaType;
|
||||
if ('videoUrl' in card && card.videoUrl) return 'video';
|
||||
if ('embedUrl' in card && card.embedUrl) return '360';
|
||||
return 'image';
|
||||
};
|
||||
|
||||
const getMediaTitle = (
|
||||
card: GalleryCard | GalleryCarouselMediaItem | undefined,
|
||||
): string => {
|
||||
if (!card) return '';
|
||||
return (
|
||||
('title' in card && card.title) ||
|
||||
('caption' in card && card.caption) ||
|
||||
('description' in card && card.description) ||
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
const getVideoUrl = (
|
||||
card: GalleryCard | GalleryCarouselMediaItem | undefined,
|
||||
): string => (card && 'videoUrl' in card && card.videoUrl ? card.videoUrl : '');
|
||||
|
||||
const getEmbedUrl = (
|
||||
card: GalleryCard | GalleryCarouselMediaItem | undefined,
|
||||
): string => (card && 'embedUrl' in card && card.embedUrl ? card.embedUrl : '');
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
@ -89,6 +125,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
galleryElement,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
|
||||
// Resolve slide transition with cascade
|
||||
const slideTransition = resolveSlideTransition(
|
||||
@ -385,12 +422,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
};
|
||||
|
||||
const currentCard = cards[displayIndex];
|
||||
const mediaType = getMediaType(currentCard);
|
||||
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
|
||||
const rawVideoUrl = getVideoUrl(currentCard);
|
||||
const videoUrl = rawVideoUrl ? resolve(rawVideoUrl) : '';
|
||||
const embedUrl = getEmbedUrl(currentCard);
|
||||
const embedSrc =
|
||||
embedUrl && isValidEmbedUrl(embedUrl)
|
||||
? buildChromeFreeEmbedUrl(embedUrl)
|
||||
: '';
|
||||
const mediaTitle = getMediaTitle(currentCard);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className='fixed inset-0 z-50 overflow-hidden bg-black'
|
||||
className='fixed inset-0 z-[120] overflow-hidden bg-black'
|
||||
onClick={(e) => {
|
||||
// Only close if clicking the background, not buttons
|
||||
if (e.target === overlayRef.current && !isEditMode) {
|
||||
@ -406,17 +452,44 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
className='overflow-hidden'
|
||||
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||
>
|
||||
{/* Fullscreen image */}
|
||||
{imageUrl && (
|
||||
{/* Fullscreen media */}
|
||||
{mediaType === 'image' && imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={currentCard?.title || ''}
|
||||
alt={mediaTitle}
|
||||
className='absolute inset-0 h-full w-full object-contain'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{mediaType === 'video' && videoUrl && (
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
className='absolute inset-0 h-full w-full object-contain'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
controls
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{mediaType === '360' && embedUrl && isValidEmbedUrl(embedUrl) && (
|
||||
<iframe
|
||||
key={embedSrc}
|
||||
src={embedSrc}
|
||||
title={mediaTitle || '360 view'}
|
||||
className='absolute inset-0 h-full w-full border-0'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; xr-spatial-tracking'
|
||||
/>
|
||||
)}
|
||||
{mediaType === '360' && embedUrl && !isValidEmbedUrl(embedUrl) && (
|
||||
<div className='absolute inset-0 flex items-center justify-center text-white/70'>
|
||||
Embed domain is not allowed
|
||||
</div>
|
||||
)}
|
||||
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
|
||||
{slideTransition.type === 'fade' && (
|
||||
<div
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ImageDetailPanel Component
|
||||
*
|
||||
* Displays enlarged image or 360/iframe embed.
|
||||
* Displays enlarged image, video, or 360/iframe embed.
|
||||
* Positioned absolutely within the canvas (not fullscreen).
|
||||
* Supports embed URL validation for security.
|
||||
*/
|
||||
@ -16,42 +16,8 @@ import React, {
|
||||
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||
import { getFontByKey, getFontStyle } from '../../lib/fonts';
|
||||
|
||||
/**
|
||||
* Allowed embed domains for security
|
||||
*/
|
||||
const ALLOWED_EMBED_DOMAINS = [
|
||||
'matterport.com',
|
||||
'my.matterport.com',
|
||||
'kuula.co',
|
||||
'roundme.com',
|
||||
'sketchfab.com',
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'vimeo.com',
|
||||
'player.vimeo.com',
|
||||
'google.com',
|
||||
'maps.google.com',
|
||||
'www.google.com',
|
||||
'docs.google.com',
|
||||
'drive.google.com',
|
||||
'360stories.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate if the URL is from an allowed embed domain
|
||||
*/
|
||||
const isValidEmbedUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ALLOWED_EMBED_DOMAINS.some(
|
||||
(domain) =>
|
||||
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
|
||||
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
|
||||
|
||||
interface ImageDetailPanelProps {
|
||||
element: CanvasElement;
|
||||
@ -59,6 +25,8 @@ interface ImageDetailPanelProps {
|
||||
onClose: () => void;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
letterboxStyles?: React.CSSProperties;
|
||||
/** CSS custom properties including --cu for canvas units */
|
||||
cssVars?: React.CSSProperties;
|
||||
isEditMode?: boolean;
|
||||
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
|
||||
}
|
||||
@ -69,10 +37,12 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
onClose,
|
||||
resolveUrl,
|
||||
letterboxStyles,
|
||||
cssVars,
|
||||
isEditMode = false,
|
||||
onDetailPositionChange,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@ -235,6 +205,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
const isEmbed = image?.itemType === '360' && image?.embedUrl;
|
||||
const embedUrl = image?.embedUrl ?? '';
|
||||
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
|
||||
const embedSrc = isValidEmbed ? buildChromeFreeEmbedUrl(embedUrl) : '';
|
||||
const isVideo = image?.itemType === 'video' && image?.videoUrl;
|
||||
const hasImage = !!image;
|
||||
|
||||
// Convert numeric values to canvas units for responsive scaling
|
||||
@ -387,7 +359,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='fixed inset-0 z-[54] overflow-hidden pointer-events-none'
|
||||
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||
style={{
|
||||
...cssVars,
|
||||
...(letterboxStyles || { position: 'absolute', inset: 0 }),
|
||||
}}
|
||||
>
|
||||
{/* Detail Panel */}
|
||||
<div
|
||||
@ -398,7 +373,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
...panelStyle,
|
||||
pointerEvents: 'auto', // Ensure panel receives events
|
||||
pointerEvents: isEditMode ? 'none' : 'auto',
|
||||
cursor:
|
||||
isEditMode && onDetailPositionChange
|
||||
? isDragging
|
||||
@ -407,12 +382,24 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
: undefined,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={
|
||||
isEditMode && onDetailPositionChange ? handleDragStart : undefined
|
||||
}
|
||||
>
|
||||
{/* Control buttons - only show for regular images (embeds have their own controls) */}
|
||||
{!isEmbed && image?.imageUrl && (
|
||||
{isEditMode && onDetailPositionChange && (
|
||||
<div
|
||||
className='absolute top-0 left-0 right-0 h-8 z-20 cursor-grab active:cursor-grabbing rounded-t-xl'
|
||||
style={{
|
||||
borderRadius: `${toCU(detailBorderRadius)} ${toCU(detailBorderRadius)} 0 0`,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className='flex items-center justify-center h-full gap-1'>
|
||||
<div className='w-8 h-1 rounded-full bg-white/30' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control buttons - embeds have their own controls */}
|
||||
{!isEmbed && (image?.imageUrl || image?.videoUrl) && (
|
||||
<>
|
||||
{/* Close button */}
|
||||
<button
|
||||
@ -519,7 +506,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
// Embed iframe
|
||||
isValidEmbed ? (
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
src={embedSrc}
|
||||
title={image?.caption || 'Embedded content'}
|
||||
className='w-full h-full border-0'
|
||||
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
|
||||
@ -555,6 +542,22 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : isVideo && image?.videoUrl ? (
|
||||
<video
|
||||
src={resolve(image.videoUrl)}
|
||||
className='w-full h-full'
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
opacity: isLoading ? 0 : 1,
|
||||
transition: 'opacity 200ms ease-out',
|
||||
}}
|
||||
controls
|
||||
autoPlay={!isEditMode}
|
||||
muted={isMuted}
|
||||
playsInline
|
||||
onLoadedData={handleImageLoad}
|
||||
onError={handleImageLoad}
|
||||
/>
|
||||
) : image?.imageUrl ? (
|
||||
// Regular image
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@ -17,6 +17,8 @@ import React, {
|
||||
import type {
|
||||
CanvasElement,
|
||||
InfoPanelImage,
|
||||
InfoPanelImageClickAction,
|
||||
InfoPanelLinkClickAction,
|
||||
InfoPanelSectionInstance,
|
||||
} from '../../types/constructor';
|
||||
import { getInfoPanelSections } from '../../types/constructor';
|
||||
@ -30,8 +32,6 @@ import {
|
||||
buildInfoPanelCardStyle,
|
||||
buildInfoPanelCardTitleStyle,
|
||||
buildInfoPanelCardGridStyleWithSection,
|
||||
buildInfoPanelWrapperStyle,
|
||||
buildImagesPreviewStyle,
|
||||
} from '../../lib/infoPanelSectionStyles';
|
||||
|
||||
interface InfoPanelOverlayProps {
|
||||
@ -43,8 +43,16 @@ interface InfoPanelOverlayProps {
|
||||
cssVars?: React.CSSProperties;
|
||||
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
|
||||
onImageClick: (image: InfoPanelImage | null) => void;
|
||||
/** Callback when an image is selected in the images section preview */
|
||||
/** Callback when an image/video/360 group should open in fullscreen gallery mode */
|
||||
onOpenGallery?: (items: InfoPanelImage[], initialIndex: number) => void;
|
||||
/** Callback when an image thumbnail is selected */
|
||||
onSelectImage?: (imageId: string) => void;
|
||||
/** Callback when an Info Panel item targets an internal page slug */
|
||||
onNavigateToPage?: (targetPageSlug: string) => void;
|
||||
/** Callback when an Info Panel item targets an external URL */
|
||||
onOpenExternalUrl?: (url: string) => void;
|
||||
/** Callback when a media item should replace the current screen background */
|
||||
onUseAsBackground?: (image: InfoPanelImage) => void;
|
||||
isEditMode?: boolean;
|
||||
/** Callback when panel position changes (edit mode only) */
|
||||
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
|
||||
@ -52,6 +60,74 @@ interface InfoPanelOverlayProps {
|
||||
active360ItemId?: string | null;
|
||||
}
|
||||
|
||||
const VideoThumbnail: React.FC<{
|
||||
src: string;
|
||||
caption?: string;
|
||||
}> = ({ src, caption }) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full overflow-hidden bg-black'>
|
||||
<video
|
||||
src={src}
|
||||
className='h-full w-full object-cover'
|
||||
muted
|
||||
playsInline
|
||||
preload='metadata'
|
||||
aria-label={caption || 'Video thumbnail'}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
opacity: isReady ? 1 : 0,
|
||||
transition: 'opacity 160ms ease-out',
|
||||
}}
|
||||
onLoadedData={(event) => {
|
||||
setIsReady(true);
|
||||
const video = event.currentTarget;
|
||||
if (video.duration > 0.25 && video.currentTime < 0.1) {
|
||||
try {
|
||||
video.currentTime = 0.1;
|
||||
} catch {
|
||||
// Some browsers disallow seeking before metadata is fully ready.
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={() => setIsReady(false)}
|
||||
/>
|
||||
{!isReady && (
|
||||
<div className='absolute inset-0 flex items-center justify-center text-white/40'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='h-7 w-7'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-black/10'>
|
||||
<span className='flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white shadow'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
className='ml-0.5 h-4 w-4'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path d='M8 5v14l11-7z' />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
element,
|
||||
onClose,
|
||||
@ -59,7 +135,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
letterboxStyles,
|
||||
cssVars,
|
||||
onImageClick,
|
||||
onOpenGallery,
|
||||
onSelectImage,
|
||||
onNavigateToPage,
|
||||
onOpenExternalUrl,
|
||||
onUseAsBackground,
|
||||
isEditMode = false,
|
||||
onPanelPositionChange,
|
||||
active360ItemId,
|
||||
@ -77,11 +157,6 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
panelY: number;
|
||||
} | null>(null);
|
||||
|
||||
// Track selected image per section (local UI state)
|
||||
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Section styles computed from element settings
|
||||
const headerStyle = useMemo(
|
||||
() => buildInfoPanelHeaderStyle(element),
|
||||
@ -98,18 +173,106 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
() => buildInfoPanelCardTitleStyle(element),
|
||||
[element],
|
||||
);
|
||||
const wrapperStyle = useMemo(
|
||||
() => buildInfoPanelWrapperStyle(element),
|
||||
[element],
|
||||
);
|
||||
const imagesPreviewStyle = useMemo(
|
||||
() => buildImagesPreviewStyle(element),
|
||||
[element],
|
||||
);
|
||||
|
||||
// Get section instances
|
||||
const sections = useMemo(() => getInfoPanelSections(element), [element]);
|
||||
|
||||
const openExternalUrl = useCallback(
|
||||
(url: string) => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
if (onOpenExternalUrl) {
|
||||
onOpenExternalUrl(trimmed);
|
||||
return;
|
||||
}
|
||||
const href = /^https?:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
[onOpenExternalUrl],
|
||||
);
|
||||
|
||||
const handleLinkDestination = useCallback(
|
||||
(target: {
|
||||
clickAction?: InfoPanelLinkClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
}): boolean => {
|
||||
if (target.clickAction === 'target_page' && target.targetPageSlug) {
|
||||
onNavigateToPage?.(target.targetPageSlug);
|
||||
return true;
|
||||
}
|
||||
if (target.clickAction === 'external_url' && target.externalUrl) {
|
||||
openExternalUrl(target.externalUrl);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[onNavigateToPage, openExternalUrl],
|
||||
);
|
||||
|
||||
const handleImageDestination = useCallback(
|
||||
(
|
||||
image: InfoPanelImage,
|
||||
section: InfoPanelSectionInstance,
|
||||
items?: InfoPanelImage[],
|
||||
) => {
|
||||
if (image.useAsBackground) {
|
||||
onUseAsBackground?.(image);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickAction: InfoPanelImageClickAction =
|
||||
image.clickAction || 'panel';
|
||||
|
||||
if (clickAction === 'target_page' && image.targetPageSlug) {
|
||||
onNavigateToPage?.(image.targetPageSlug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickAction === 'external_url' && image.externalUrl) {
|
||||
openExternalUrl(image.externalUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionMode =
|
||||
section.mediaOpenMode ||
|
||||
(clickAction === 'fullscreen' ? 'fullscreen' : 'panel');
|
||||
|
||||
if (sectionMode === 'fullscreen' && onOpenGallery && items?.length) {
|
||||
onOpenGallery(
|
||||
items,
|
||||
Math.max(
|
||||
0,
|
||||
items.findIndex((item) => item.id === image.id),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onImageClick(image);
|
||||
},
|
||||
[
|
||||
onNavigateToPage,
|
||||
onImageClick,
|
||||
onOpenGallery,
|
||||
onUseAsBackground,
|
||||
openExternalUrl,
|
||||
],
|
||||
);
|
||||
|
||||
const getClickableStyle = (
|
||||
target: {
|
||||
clickAction?: InfoPanelLinkClickAction;
|
||||
targetPageSlug?: string;
|
||||
externalUrl?: string;
|
||||
},
|
||||
baseStyle: React.CSSProperties,
|
||||
): React.CSSProperties => ({
|
||||
...baseStyle,
|
||||
cursor: target.clickAction ? 'pointer' : baseStyle.cursor,
|
||||
});
|
||||
|
||||
// Fade in animation
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
@ -334,7 +497,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
? 'grabbing'
|
||||
: 'grab'
|
||||
: undefined,
|
||||
pointerEvents: 'auto', // Ensure panel receives events
|
||||
pointerEvents: isEditMode ? 'none' : 'auto',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
@ -345,6 +508,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
|
||||
style={{
|
||||
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
@ -396,14 +560,30 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
|
||||
const headerText =
|
||||
section.headerText ?? element.infoPanelHeaderText;
|
||||
const headerClickProps = section.clickAction
|
||||
? {
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
onClick: () => handleLinkDestination(section),
|
||||
onKeyDown: (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleLinkDestination(section);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
// Image takes priority, otherwise render text
|
||||
if (headerImageUrl) {
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
{...headerClickProps}
|
||||
style={{
|
||||
...headerStyle,
|
||||
...getClickableStyle(section, headerStyle),
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
@ -424,7 +604,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
}
|
||||
if (headerText) {
|
||||
return (
|
||||
<div key={section.id} style={headerStyle}>
|
||||
<div
|
||||
key={section.id}
|
||||
{...headerClickProps}
|
||||
style={getClickableStyle(section, headerStyle)}
|
||||
>
|
||||
{headerText}
|
||||
</div>
|
||||
);
|
||||
@ -441,12 +625,24 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
key={section.id}
|
||||
id='info-panel-title'
|
||||
style={{
|
||||
...titleStyle,
|
||||
...getClickableStyle(section, titleStyle),
|
||||
marginTop:
|
||||
isEditMode && onPanelPositionChange
|
||||
? '16px'
|
||||
: undefined,
|
||||
}}
|
||||
role={section.clickAction ? 'button' : undefined}
|
||||
tabIndex={section.clickAction ? 0 : undefined}
|
||||
onClick={() => handleLinkDestination(section)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
section.clickAction &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleLinkDestination(section);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{titleContent}
|
||||
</h2>
|
||||
@ -458,7 +654,22 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
const textContent = section.text ?? panelText;
|
||||
if (!textContent) return null;
|
||||
return (
|
||||
<p key={section.id} style={textStyle}>
|
||||
<p
|
||||
key={section.id}
|
||||
style={getClickableStyle(section, textStyle)}
|
||||
role={section.clickAction ? 'button' : undefined}
|
||||
tabIndex={section.clickAction ? 0 : undefined}
|
||||
onClick={() => handleLinkDestination(section)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
section.clickAction &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleLinkDestination(section);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{textContent}
|
||||
</p>
|
||||
);
|
||||
@ -478,7 +689,18 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
return (
|
||||
<div key={section.id} style={spanGridStyle}>
|
||||
{sectionSpans.map((span) => (
|
||||
<div key={span.id} style={spanStyle}>
|
||||
<button
|
||||
key={span.id}
|
||||
type='button'
|
||||
style={{
|
||||
...spanStyle,
|
||||
border: 'none',
|
||||
cursor: span.clickAction ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => handleLinkDestination(span)}
|
||||
disabled={!span.clickAction}
|
||||
className='focus:outline-none disabled:cursor-default'
|
||||
>
|
||||
{span.iconUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@ -494,7 +716,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
) : (
|
||||
span.text
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -527,7 +749,13 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
overflow: 'hidden', // Respect borderRadius on children
|
||||
}}
|
||||
className='focus:outline-none'
|
||||
onClick={() => onImageClick(image)}
|
||||
onClick={() =>
|
||||
handleImageDestination(
|
||||
image,
|
||||
section,
|
||||
sectionImages,
|
||||
)
|
||||
}
|
||||
aria-label={image.caption || 'View image'}
|
||||
>
|
||||
{image.itemType === '360' ? (
|
||||
@ -549,6 +777,29 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
</svg>
|
||||
<span className='text-xs'>360/Embed</span>
|
||||
</div>
|
||||
) : image.itemType === 'video' && image.videoUrl ? (
|
||||
<VideoThumbnail
|
||||
src={resolve(image.videoUrl)}
|
||||
caption={image.caption}
|
||||
/>
|
||||
) : image.itemType === 'video' ? (
|
||||
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-8 h-8 mb-1'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs'>Video</span>
|
||||
</div>
|
||||
) : image.imageUrl ? (
|
||||
// Regular image thumbnail
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@ -606,35 +857,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
// Use section-level images
|
||||
const sectionImages = section.images || [];
|
||||
|
||||
// Filter to get only regular images (not 360°)
|
||||
const imageItems = sectionImages.filter(
|
||||
(img) => img.itemType !== '360',
|
||||
);
|
||||
// Get 360° items for trigger buttons
|
||||
const triggerItems = sectionImages.filter(
|
||||
(img) => img.itemType === '360',
|
||||
);
|
||||
|
||||
// Nothing to show if no items
|
||||
if (imageItems.length === 0 && triggerItems.length === 0)
|
||||
return null;
|
||||
|
||||
// Use per-section selected state, fallback to first image
|
||||
const selectedId =
|
||||
selectedImagePerSection[section.id] || imageItems[0]?.id;
|
||||
const selectedImage = imageItems.find(
|
||||
(img) => img.id === selectedId,
|
||||
);
|
||||
|
||||
// Handler to update selected image for this section
|
||||
const handleSelectImage = (imageId: string) => {
|
||||
setSelectedImagePerSection((prev) => ({
|
||||
...prev,
|
||||
[section.id]: imageId,
|
||||
}));
|
||||
// Also call the optional external handler
|
||||
onSelectImage?.(imageId);
|
||||
};
|
||||
if (sectionImages.length === 0) return null;
|
||||
|
||||
// Build grid style with per-section settings
|
||||
const gridStyle = buildInfoPanelCardGridStyleWithSection(
|
||||
@ -663,140 +887,137 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
|
||||
}}
|
||||
>
|
||||
{/* Large Preview - always visible */}
|
||||
<div
|
||||
style={{
|
||||
...imagesPreviewStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{selectedImage?.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(selectedImage.imageUrl)}
|
||||
alt={selectedImage.caption || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-white/40 text-sm'>
|
||||
{imageItems.length === 0
|
||||
? 'No images added'
|
||||
: 'No image selected'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Grid */}
|
||||
<div style={gridStyle}>
|
||||
{/* Image thumbnails - click to select for preview */}
|
||||
{imageItems.map((img) => (
|
||||
<button
|
||||
key={img.id}
|
||||
type='button'
|
||||
style={{
|
||||
...thumbnailStyle,
|
||||
opacity: img.id === selectedId ? 1 : 0.6,
|
||||
}}
|
||||
className='transition-opacity hover:opacity-100 focus:outline-none'
|
||||
onClick={() => handleSelectImage(img.id)}
|
||||
aria-label={img.caption || 'Select image'}
|
||||
aria-pressed={img.id === selectedId}
|
||||
>
|
||||
{img.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(img.imageUrl)}
|
||||
alt=''
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center text-white/40'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-6 h-6'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{sectionImages.map((img) => {
|
||||
const itemType = img.itemType || 'image';
|
||||
const is360 = itemType === '360';
|
||||
const isVideo = itemType === 'video';
|
||||
|
||||
{/* 360° trigger buttons - click to open 360° view */}
|
||||
{triggerItems.map((img) => (
|
||||
<button
|
||||
key={img.id}
|
||||
type='button'
|
||||
style={thumbnailStyle}
|
||||
className='trigger-360 focus:outline-none'
|
||||
onClick={() => {
|
||||
// Toggle behavior: if already open, close; otherwise open
|
||||
if (active360ItemId === img.id) {
|
||||
onImageClick(null);
|
||||
} else {
|
||||
onImageClick(img);
|
||||
return (
|
||||
<button
|
||||
key={img.id}
|
||||
type='button'
|
||||
style={thumbnailStyle}
|
||||
className={
|
||||
is360
|
||||
? 'trigger-360 focus:outline-none'
|
||||
: isVideo
|
||||
? 'trigger-video focus:outline-none'
|
||||
: 'transition-opacity hover:opacity-100 focus:outline-none'
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
active360ItemId === img.id
|
||||
? 'Close 360° view'
|
||||
: 'Open 360° view'
|
||||
}
|
||||
aria-pressed={active360ItemId === img.id}
|
||||
>
|
||||
{img.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(img.iconUrl)}
|
||||
alt='360°'
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-8 h-8 mb-1'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
|
||||
onClick={() => {
|
||||
if (!isVideo && !is360) {
|
||||
onSelectImage?.(img.id);
|
||||
}
|
||||
handleImageDestination(
|
||||
img,
|
||||
section,
|
||||
sectionImages,
|
||||
);
|
||||
}}
|
||||
aria-label={
|
||||
is360
|
||||
? active360ItemId === img.id
|
||||
? 'Close 360° view'
|
||||
: 'Open 360° view'
|
||||
: img.caption ||
|
||||
(isVideo ? 'Open video' : 'Select image')
|
||||
}
|
||||
aria-pressed={
|
||||
is360 ? active360ItemId === img.id : undefined
|
||||
}
|
||||
>
|
||||
{is360 ? (
|
||||
img.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(img.iconUrl)}
|
||||
alt='360°'
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs'>360°</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-8 h-8 mb-1'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs'>360°</span>
|
||||
</div>
|
||||
)
|
||||
) : isVideo ? (
|
||||
img.videoUrl ? (
|
||||
<VideoThumbnail
|
||||
src={resolve(img.videoUrl)}
|
||||
caption={img.caption}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-8 h-8 mb-1'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
||||
/>
|
||||
</svg>
|
||||
<span className='text-xs'>Video</span>
|
||||
</div>
|
||||
)
|
||||
) : img.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(img.imageUrl)}
|
||||
alt=''
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center text-white/40'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth={1.5}
|
||||
stroke='currentColor'
|
||||
className='w-6 h-6'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ import type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
|
||||
|
||||
interface AudioPlayerElementProps {
|
||||
element: CanvasElement;
|
||||
@ -26,6 +27,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
|
||||
// Ducking: pause background audio when this player plays
|
||||
useEffect(() => {
|
||||
@ -67,7 +69,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
muted={Boolean(element.mediaMuted) || isMuted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@ import type { CanvasElement } from '../../../types/constructor';
|
||||
import type { PreloadCacheProvider } from '../../../hooks/video';
|
||||
import { useVideoPlayer } from '../../../hooks/video';
|
||||
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
|
||||
|
||||
interface VideoPlayerElementProps {
|
||||
element: CanvasElement;
|
||||
@ -25,12 +26,15 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
const effectiveMuted = Boolean(element.mediaMuted) || isMuted;
|
||||
|
||||
const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({
|
||||
sourceUrl: element.mediaUrl,
|
||||
preloadCache,
|
||||
autoplay: Boolean(element.mediaAutoplay),
|
||||
loop: Boolean(element.mediaLoop),
|
||||
muted: Boolean(element.mediaMuted),
|
||||
muted: effectiveMuted,
|
||||
trackBuffering: true,
|
||||
});
|
||||
|
||||
@ -80,7 +84,7 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
muted={effectiveMuted}
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -120,6 +120,7 @@ export interface ConstructorContextValue {
|
||||
// Background convenience setters
|
||||
setBackgroundImageUrl: (url: string) => void;
|
||||
setBackgroundVideoUrl: (url: string) => void;
|
||||
setBackgroundEmbedUrl: (url: string) => void;
|
||||
setBackgroundAudioUrl: (url: string) => void;
|
||||
setBackgroundVideoSettings: (
|
||||
settings: Partial<PageBackgroundVideoSettings>,
|
||||
@ -316,6 +317,7 @@ export function useConstructorBackground() {
|
||||
updateBackgroundFromPage: ctx.updateBackgroundFromPage,
|
||||
setBackgroundImageUrl: ctx.setBackgroundImageUrl,
|
||||
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
|
||||
setBackgroundEmbedUrl: ctx.setBackgroundEmbedUrl,
|
||||
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
|
||||
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
|
||||
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
|
||||
@ -326,6 +328,7 @@ export function useConstructorBackground() {
|
||||
ctx.updateBackgroundFromPage,
|
||||
ctx.setBackgroundImageUrl,
|
||||
ctx.setBackgroundVideoUrl,
|
||||
ctx.setBackgroundEmbedUrl,
|
||||
ctx.setBackgroundAudioUrl,
|
||||
ctx.setBackgroundVideoSettings,
|
||||
ctx.setBackgroundAudioSettings,
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||
|
||||
/**
|
||||
* Fetch audio file with credentials and return a blob URL.
|
||||
@ -79,10 +80,13 @@ export function useAudioEffects({
|
||||
}: UseAudioEffectsOptions): UseAudioEffectsResult {
|
||||
const hoverAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const clickAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
const hoverBlobUrlRef = useRef<string | null>(null);
|
||||
const clickBlobUrlRef = useRef<string | null>(null);
|
||||
const wasHoveredRef = useRef(false);
|
||||
const wasActiveRef = useRef(false);
|
||||
const hoverStateInitializedRef = useRef(false);
|
||||
const activeStateInitializedRef = useRef(false);
|
||||
|
||||
// Track which URLs we've already fetched to prevent duplicate fetches
|
||||
const lastFetchedHoverUrlRef = useRef<string | null>(null);
|
||||
@ -240,45 +244,61 @@ export function useAudioEffects({
|
||||
// Play hover audio when hover starts (plays to completion, not interrupted)
|
||||
// Sound effects layer over background audio (no ducking)
|
||||
useEffect(() => {
|
||||
if (!hoverStateInitializedRef.current || isMuted) {
|
||||
hoverStateInitializedRef.current = true;
|
||||
wasHoveredRef.current = isHovered;
|
||||
return;
|
||||
}
|
||||
|
||||
const justEntered = isHovered && !wasHoveredRef.current;
|
||||
wasHoveredRef.current = isHovered;
|
||||
|
||||
if (
|
||||
isHovered &&
|
||||
!wasHoveredRef.current &&
|
||||
justEntered &&
|
||||
hoverAudioRef.current &&
|
||||
hoverAudioReady &&
|
||||
backgroundAudioController.hasInteracted()
|
||||
backgroundAudioController.hasInteracted() &&
|
||||
!isMuted
|
||||
) {
|
||||
const audio = hoverAudioRef.current;
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => undefined);
|
||||
}
|
||||
wasHoveredRef.current = isHovered;
|
||||
}, [isHovered, hoverAudioReady]);
|
||||
}, [isHovered, hoverAudioReady, isMuted]);
|
||||
|
||||
// Play click audio when active state begins
|
||||
// Sound effects layer over background audio (no ducking)
|
||||
useEffect(() => {
|
||||
if (!activeStateInitializedRef.current || isMuted) {
|
||||
activeStateInitializedRef.current = true;
|
||||
wasActiveRef.current = isActive;
|
||||
return;
|
||||
}
|
||||
|
||||
const justActivated = isActive && !wasActiveRef.current;
|
||||
wasActiveRef.current = isActive;
|
||||
|
||||
if (
|
||||
isActive &&
|
||||
!wasActiveRef.current &&
|
||||
justActivated &&
|
||||
clickAudioRef.current &&
|
||||
clickAudioReady &&
|
||||
backgroundAudioController.hasInteracted()
|
||||
backgroundAudioController.hasInteracted() &&
|
||||
!isMuted
|
||||
) {
|
||||
const audio = clickAudioRef.current;
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => undefined);
|
||||
}
|
||||
wasActiveRef.current = isActive;
|
||||
}, [isActive, clickAudioReady]);
|
||||
}, [isActive, clickAudioReady, isMuted]);
|
||||
|
||||
// Manual click audio trigger
|
||||
const playClickAudio = useCallback(() => {
|
||||
if (clickAudioRef.current && clickAudioReady) {
|
||||
if (clickAudioRef.current && clickAudioReady && !isMuted) {
|
||||
clickAudioRef.current.currentTime = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
clickAudioRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [clickAudioReady]);
|
||||
}, [clickAudioReady, isMuted]);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
if (hoverAudioRef.current) {
|
||||
@ -296,7 +316,15 @@ export function useAudioEffects({
|
||||
stopAll();
|
||||
wasHoveredRef.current = false;
|
||||
wasActiveRef.current = false;
|
||||
hoverStateInitializedRef.current = false;
|
||||
activeStateInitializedRef.current = false;
|
||||
}, [resetKey, stopAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMuted) {
|
||||
stopAll();
|
||||
}
|
||||
}, [isMuted, stopAll]);
|
||||
|
||||
return { playClickAudio, stopAll };
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { useEffect, useRef, useCallback, type RefObject } from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
import { useAudioEventManager } from './audio/useAudioEventManager';
|
||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||
|
||||
// Session-scoped tracking of audio that has finished playing (when loop=false)
|
||||
// Key: audioUrl, cleared on browser refresh
|
||||
@ -66,6 +67,8 @@ export function useBackgroundAudioPlayback({
|
||||
paused = false,
|
||||
}: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
const effectivePaused = paused || isMuted;
|
||||
|
||||
// Use storage path for tracking (stable across blob URL changes)
|
||||
// Falls back to audioUrl if no storage path provided
|
||||
@ -87,9 +90,9 @@ export function useBackgroundAudioPlayback({
|
||||
startTimeRef.current = startTime;
|
||||
endTimeRef.current = endTime;
|
||||
loopRef.current = loop;
|
||||
pausedRef.current = paused;
|
||||
pausedRef.current = effectivePaused;
|
||||
trackingKeyRef.current = trackingKey;
|
||||
}, [startTime, endTime, loop, paused, trackingKey]);
|
||||
}, [startTime, endTime, loop, effectivePaused, trackingKey]);
|
||||
|
||||
// Register audio element with background audio controller for ducking
|
||||
useEffect(() => {
|
||||
@ -180,7 +183,7 @@ export function useBackgroundAudioPlayback({
|
||||
if (!audio || !audioUrl) return;
|
||||
|
||||
// External pause takes precedence over autoplay
|
||||
if (paused) {
|
||||
if (effectivePaused) {
|
||||
audio.pause();
|
||||
backgroundAudioController.setWaitingForInteraction(false);
|
||||
return;
|
||||
@ -194,7 +197,7 @@ export function useBackgroundAudioPlayback({
|
||||
audio.pause();
|
||||
backgroundAudioController.setWaitingForInteraction(false);
|
||||
}
|
||||
}, [audioUrl, paused, shouldBlockAutoplay]);
|
||||
}, [audioUrl, effectivePaused, shouldBlockAutoplay]);
|
||||
|
||||
// Session-scoped "play once" behavior when loop is disabled
|
||||
// Audio that has already played stops on revisit
|
||||
|
||||
@ -526,9 +526,13 @@ export function useConstructorElements({
|
||||
...(sectionType === 'cards' && {
|
||||
columns: 2,
|
||||
gap: '8',
|
||||
mediaOpenMode: 'panel',
|
||||
images: [],
|
||||
}),
|
||||
...(sectionType === 'images' && {
|
||||
mediaOpenMode: 'panel',
|
||||
images: [],
|
||||
}),
|
||||
...(sectionType === 'images' && { images: [] }),
|
||||
};
|
||||
return { infoPanelSections: [...currentSections, newSection] };
|
||||
});
|
||||
|
||||
@ -24,6 +24,7 @@ interface TourPage {
|
||||
ui_schema_json?: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
@ -128,6 +129,7 @@ export function useConstructorPageActions({
|
||||
const {
|
||||
imageUrl: backgroundImageUrl,
|
||||
videoUrl: backgroundVideoUrl,
|
||||
embedUrl: backgroundEmbedUrl,
|
||||
audioUrl: backgroundAudioUrl,
|
||||
videoSettings: {
|
||||
autoplay: backgroundVideoAutoplay,
|
||||
@ -209,6 +211,7 @@ export function useConstructorPageActions({
|
||||
ui_schema_json: schemaToSave,
|
||||
background_image_url: backgroundImageUrl,
|
||||
background_video_url: backgroundVideoUrl,
|
||||
background_embed_url: backgroundEmbedUrl,
|
||||
background_audio_url: backgroundAudioUrl,
|
||||
background_loop: Boolean(backgroundAudioUrl),
|
||||
background_video_autoplay: backgroundVideoAutoplay,
|
||||
@ -335,6 +338,7 @@ export function useConstructorPageActions({
|
||||
sort_order: maxSortOrder + 1,
|
||||
background_image_url: '',
|
||||
background_video_url: '',
|
||||
background_embed_url: '',
|
||||
background_audio_url: '',
|
||||
background_loop: false,
|
||||
requires_auth: false,
|
||||
|
||||
27
frontend/src/hooks/useGlobalAudioMute.ts
Normal file
27
frontend/src/hooks/useGlobalAudioMute.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||
|
||||
export function useGlobalAudioMute() {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => backgroundAudioController.subscribe(listener),
|
||||
[],
|
||||
);
|
||||
|
||||
const isMuted = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => backgroundAudioController.isMuted(),
|
||||
() => true,
|
||||
);
|
||||
|
||||
const setMuted = useCallback((muted: boolean) => {
|
||||
backgroundAudioController.setMuted(muted);
|
||||
}, []);
|
||||
|
||||
const toggleMuted = useCallback(() => {
|
||||
backgroundAudioController.toggleMuted();
|
||||
}, []);
|
||||
|
||||
return { isMuted, setMuted, toggleMuted };
|
||||
}
|
||||
|
||||
export default useGlobalAudioMute;
|
||||
@ -7,6 +7,7 @@
|
||||
* Replaces:
|
||||
* - backgroundImageUrl, setBackgroundImageUrl
|
||||
* - backgroundVideoUrl, setBackgroundVideoUrl
|
||||
* - backgroundEmbedUrl, setBackgroundEmbedUrl
|
||||
* - backgroundAudioUrl, setBackgroundAudioUrl
|
||||
* - backgroundVideoAutoplay, setBackgroundVideoAutoplay
|
||||
* - backgroundVideoLoop, setBackgroundVideoLoop
|
||||
@ -29,6 +30,7 @@ import {
|
||||
interface TourPageData {
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
@ -59,6 +61,7 @@ export interface UsePageBackgroundResult {
|
||||
/** Update individual URL */
|
||||
setImageUrl: (url: string) => void;
|
||||
setVideoUrl: (url: string) => void;
|
||||
setEmbedUrl: (url: string) => void;
|
||||
setAudioUrl: (url: string) => void;
|
||||
|
||||
/** Update video settings */
|
||||
@ -74,6 +77,7 @@ export interface UsePageBackgroundResult {
|
||||
// These allow gradual migration of components
|
||||
backgroundImageUrl: string;
|
||||
backgroundVideoUrl: string;
|
||||
backgroundEmbedUrl: string;
|
||||
backgroundAudioUrl: string;
|
||||
backgroundVideoAutoplay: boolean;
|
||||
backgroundVideoLoop: boolean;
|
||||
@ -137,6 +141,13 @@ export function usePageBackground(
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setEmbedUrl = useCallback((url: string) => {
|
||||
setBackground((prev) => ({
|
||||
...prev,
|
||||
embedUrl: url,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setAudioUrl = useCallback((url: string) => {
|
||||
setBackground((prev) => ({
|
||||
...prev,
|
||||
@ -181,6 +192,7 @@ export function usePageBackground(
|
||||
updateFromPage,
|
||||
setImageUrl,
|
||||
setVideoUrl,
|
||||
setEmbedUrl,
|
||||
setAudioUrl,
|
||||
setVideoSettings,
|
||||
setAudioSettings,
|
||||
@ -189,6 +201,7 @@ export function usePageBackground(
|
||||
// Legacy compatibility: flat values for gradual migration
|
||||
backgroundImageUrl: background.imageUrl,
|
||||
backgroundVideoUrl: background.videoUrl,
|
||||
backgroundEmbedUrl: background.embedUrl,
|
||||
backgroundAudioUrl: background.audioUrl,
|
||||
backgroundVideoAutoplay: background.videoSettings.autoplay,
|
||||
backgroundVideoLoop: background.videoSettings.loop,
|
||||
|
||||
@ -60,6 +60,7 @@ export interface NavigablePage {
|
||||
id: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
}
|
||||
|
||||
@ -81,6 +82,7 @@ interface NavigationState {
|
||||
// Current page URLs (resolved for display)
|
||||
currentImageUrl: string;
|
||||
currentVideoUrl: string;
|
||||
currentEmbedUrl: string;
|
||||
currentAudioUrl: string;
|
||||
|
||||
// Previous page URLs (for overlay during transition)
|
||||
@ -124,6 +126,7 @@ type NavigationAction =
|
||||
payload: {
|
||||
imageUrl: string;
|
||||
videoUrl: string;
|
||||
embedUrl: string;
|
||||
audioUrl: string;
|
||||
};
|
||||
}
|
||||
@ -137,6 +140,7 @@ type NavigationAction =
|
||||
payload: {
|
||||
imageUrl: string;
|
||||
videoUrl: string;
|
||||
embedUrl: string;
|
||||
audioUrl: string;
|
||||
};
|
||||
}
|
||||
@ -153,6 +157,7 @@ const initialState: NavigationState = {
|
||||
phase: 'idle',
|
||||
currentImageUrl: '',
|
||||
currentVideoUrl: '',
|
||||
currentEmbedUrl: '',
|
||||
currentAudioUrl: '',
|
||||
previousImageUrl: '',
|
||||
previousVideoUrl: '',
|
||||
@ -205,6 +210,7 @@ function navigationReducer(
|
||||
...state,
|
||||
currentImageUrl: action.payload.imageUrl,
|
||||
currentVideoUrl: action.payload.videoUrl,
|
||||
currentEmbedUrl: action.payload.embedUrl,
|
||||
currentAudioUrl: action.payload.audioUrl,
|
||||
};
|
||||
|
||||
@ -258,6 +264,7 @@ function navigationReducer(
|
||||
phase: 'idle',
|
||||
currentImageUrl: action.payload.imageUrl,
|
||||
currentVideoUrl: action.payload.videoUrl,
|
||||
currentEmbedUrl: action.payload.embedUrl,
|
||||
currentAudioUrl: action.payload.audioUrl,
|
||||
previousImageUrl: '',
|
||||
previousVideoUrl: '',
|
||||
@ -320,6 +327,7 @@ export interface UsePageNavigationStateResult {
|
||||
// Current page URLs (for display)
|
||||
currentImageUrl: string;
|
||||
currentVideoUrl: string;
|
||||
currentEmbedUrl: string;
|
||||
currentAudioUrl: string;
|
||||
|
||||
// Previous page URLs (for overlay)
|
||||
@ -374,6 +382,7 @@ export interface UsePageNavigationStateResult {
|
||||
setBackgroundDirectly: (
|
||||
imageUrl: string,
|
||||
videoUrl: string,
|
||||
embedUrl: string,
|
||||
audioUrl: string,
|
||||
) => void;
|
||||
|
||||
@ -662,23 +671,27 @@ export function usePageNavigationState(
|
||||
});
|
||||
|
||||
// Resolve URLs (may be async)
|
||||
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
|
||||
const [imageUrl, videoUrl, embedUrl, audioUrl] = await Promise.all([
|
||||
resolveToDisplayUrl(targetPage.background_image_url),
|
||||
resolveMediaUrl(targetPage.background_video_url),
|
||||
resolveMediaUrl(targetPage.background_embed_url),
|
||||
resolveMediaUrl(targetPage.background_audio_url),
|
||||
]);
|
||||
|
||||
// Update current URLs
|
||||
dispatch({
|
||||
type: 'URLS_RESOLVED',
|
||||
payload: { imageUrl, videoUrl, audioUrl },
|
||||
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
||||
});
|
||||
|
||||
// Notify caller
|
||||
onSwitched?.();
|
||||
|
||||
// For blob URLs, decode image before marking ready
|
||||
if (!hasTransition && (imageUrl.startsWith('blob:') || !imageUrl)) {
|
||||
if (
|
||||
!hasTransition &&
|
||||
(embedUrl || imageUrl.startsWith('blob:') || !imageUrl)
|
||||
) {
|
||||
decodeImage(imageUrl).then(() => {
|
||||
dispatch({ type: 'BACKGROUND_READY' });
|
||||
});
|
||||
@ -705,10 +718,15 @@ export function usePageNavigationState(
|
||||
}, []);
|
||||
|
||||
const setBackgroundDirectly = useCallback(
|
||||
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
||||
(
|
||||
imageUrl: string,
|
||||
videoUrl: string,
|
||||
embedUrl: string,
|
||||
audioUrl: string,
|
||||
) => {
|
||||
dispatch({
|
||||
type: 'SET_BACKGROUND_DIRECTLY',
|
||||
payload: { imageUrl, videoUrl, audioUrl },
|
||||
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
||||
});
|
||||
},
|
||||
[],
|
||||
@ -834,6 +852,7 @@ export function usePageNavigationState(
|
||||
// Current page URLs
|
||||
currentImageUrl: state.currentImageUrl,
|
||||
currentVideoUrl: state.currentVideoUrl,
|
||||
currentEmbedUrl: state.currentEmbedUrl,
|
||||
currentAudioUrl: state.currentAudioUrl,
|
||||
|
||||
// Previous page URLs
|
||||
|
||||
@ -1,26 +1,29 @@
|
||||
/**
|
||||
* useVideoSoundControl Hook
|
||||
*
|
||||
* Manages video sound state with iOS autoplay compatibility.
|
||||
* Videos start muted to ensure autoplay works on iOS WebKit browsers,
|
||||
* then can be unmuted by user interaction.
|
||||
* Manages global presentation sound state with autoplay compatibility.
|
||||
* Presentation audio starts muted so browsers can autoplay visual media,
|
||||
* then can be unmuted by user interaction from RuntimeControls.
|
||||
*
|
||||
* iOS WebKit autoplay policy:
|
||||
* - Videos CAN autoplay if they have `playsinline` AND `muted` attributes
|
||||
* - Unmuted videos require user interaction → native play button appears
|
||||
* - By forcing muted start + custom sound button, we avoid native controls
|
||||
* Browser autoplay policy:
|
||||
* - Muted media can autoplay in most browsers
|
||||
* - Unmuted audio/video usually requires user interaction
|
||||
* - A custom sound button provides that interaction and keeps media controls stable
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
import { useCallback } from 'react';
|
||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||
|
||||
export interface UseVideoSoundControlOptions {
|
||||
/** Whether page settings allow sound (background_video_muted === false) */
|
||||
pageHasSound: boolean;
|
||||
/** Whether page has a background video */
|
||||
hasBackgroundVideo: boolean;
|
||||
/** Current video URL - used to detect page changes (optional) */
|
||||
videoUrl?: string;
|
||||
/** Whether page has background/ambient audio */
|
||||
hasBackgroundAudio?: boolean;
|
||||
/** Whether page elements have hover/click audio effects or media player sound */
|
||||
hasElementAudio?: boolean;
|
||||
}
|
||||
|
||||
export interface UseVideoSoundControlResult {
|
||||
@ -35,7 +38,7 @@ export interface UseVideoSoundControlResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing video sound state with iOS autoplay compatibility.
|
||||
* Hook for managing global presentation sound state with autoplay compatibility.
|
||||
*
|
||||
* @example
|
||||
* const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({
|
||||
@ -50,50 +53,29 @@ export interface UseVideoSoundControlResult {
|
||||
* onSoundToggle={toggleSound}
|
||||
* />
|
||||
*
|
||||
* // Pass to CanvasBackground (always muted for autoplay)
|
||||
* // Pass to CanvasBackground and native media elements
|
||||
* <CanvasBackground videoMuted={isMuted} />
|
||||
*/
|
||||
export function useVideoSoundControl({
|
||||
pageHasSound,
|
||||
hasBackgroundVideo,
|
||||
videoUrl,
|
||||
hasBackgroundAudio = false,
|
||||
hasElementAudio = false,
|
||||
}: UseVideoSoundControlOptions): UseVideoSoundControlResult {
|
||||
// Always start muted for iOS autoplay compatibility
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
|
||||
// Track previous video URL to detect page changes
|
||||
const prevVideoUrl = useRef(videoUrl);
|
||||
|
||||
// Reset to muted when video changes (page navigation)
|
||||
// This ensures iOS autoplay works on every page - new videos always start muted
|
||||
useEffect(() => {
|
||||
// Only reset if there's a new video (URL changed and we have a video)
|
||||
if (prevVideoUrl.current !== videoUrl && videoUrl) {
|
||||
setIsMuted(true);
|
||||
logger.debug('[useVideoSoundControl] Video changed, resetting to muted', {
|
||||
from: prevVideoUrl.current?.slice(-20),
|
||||
to: videoUrl?.slice(-20),
|
||||
});
|
||||
}
|
||||
prevVideoUrl.current = videoUrl;
|
||||
}, [videoUrl]);
|
||||
const { isMuted } = useGlobalAudioMute();
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
setIsMuted((prev) => {
|
||||
logger.debug('[useVideoSoundControl] Toggle sound:', {
|
||||
from: prev,
|
||||
to: !prev,
|
||||
});
|
||||
return !prev;
|
||||
});
|
||||
backgroundAudioController.toggleMuted();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isMuted,
|
||||
// Show button only if page allows sound AND has a background video
|
||||
showSoundButton: pageHasSound && hasBackgroundVideo,
|
||||
showSoundButton:
|
||||
(pageHasSound && hasBackgroundVideo) ||
|
||||
hasBackgroundAudio ||
|
||||
hasElementAudio,
|
||||
toggleSound,
|
||||
setMuted: setIsMuted,
|
||||
setMuted: (muted) => backgroundAudioController.setMuted(muted),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -19,15 +19,29 @@ class BackgroundAudioController {
|
||||
|
||||
private waitingForInteraction = false;
|
||||
private hasUserInteracted = false;
|
||||
private muted = true;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
private emit(): void {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
register(audio: HTMLAudioElement | null): void {
|
||||
this.audioElement = audio;
|
||||
this.waitingForInteraction = false;
|
||||
if (audio && this.muted) {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
setWaitingForInteraction(waiting: boolean): void {
|
||||
this.waitingForInteraction = waiting;
|
||||
if (waiting && this.hasUserInteracted && this.audioElement) {
|
||||
if (
|
||||
waiting &&
|
||||
this.hasUserInteracted &&
|
||||
!this.muted &&
|
||||
this.audioElement
|
||||
) {
|
||||
this.audioElement.play().catch(() => undefined);
|
||||
this.waitingForInteraction = false;
|
||||
}
|
||||
@ -37,7 +51,7 @@ class BackgroundAudioController {
|
||||
if (this.hasUserInteracted) return;
|
||||
this.hasUserInteracted = true;
|
||||
|
||||
if (this.waitingForInteraction && this.audioElement) {
|
||||
if (this.waitingForInteraction && !this.muted && this.audioElement) {
|
||||
this.audioElement.play().catch(() => undefined);
|
||||
this.waitingForInteraction = false;
|
||||
}
|
||||
@ -61,11 +75,48 @@ class BackgroundAudioController {
|
||||
if (this.foregroundCount > 0) {
|
||||
this.foregroundCount--;
|
||||
}
|
||||
if (this.foregroundCount === 0 && this.audioElement && !this.wasPaused) {
|
||||
if (
|
||||
this.foregroundCount === 0 &&
|
||||
this.audioElement &&
|
||||
!this.wasPaused &&
|
||||
!this.muted
|
||||
) {
|
||||
this.audioElement.play().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (this.muted === muted) return;
|
||||
this.muted = muted;
|
||||
if (muted) {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.pause();
|
||||
}
|
||||
} else {
|
||||
this.notifyUserInteraction();
|
||||
if (this.waitingForInteraction && this.audioElement) {
|
||||
this.audioElement.play().catch(() => undefined);
|
||||
this.waitingForInteraction = false;
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
toggleMuted(): void {
|
||||
this.setMuted(!this.muted);
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.foregroundCount = 0;
|
||||
this.wasPaused = false;
|
||||
|
||||
@ -11,9 +11,16 @@ import type {
|
||||
GalleryCard,
|
||||
GalleryInfoSpan,
|
||||
CarouselSlide,
|
||||
InfoPanelImageClickAction,
|
||||
InfoPanelImage,
|
||||
InfoPanelInfoSpan,
|
||||
InfoPanelLinkClickAction,
|
||||
InfoPanelMediaOpenMode,
|
||||
InfoPanelSectionInstance,
|
||||
InfoPanelSectionType,
|
||||
NavigationButtonKind,
|
||||
} from '../types/constructor';
|
||||
import { DEFAULT_INFO_PANEL_SECTIONS } from '../types/constructor';
|
||||
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
||||
import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles';
|
||||
|
||||
@ -249,6 +256,18 @@ export const createDefaultElement = (
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Info Panel with local section IDs from the first creation step.
|
||||
if (type === 'info_panel') {
|
||||
return {
|
||||
...base,
|
||||
...typeDefaults,
|
||||
infoPanelSections: normalizeInfoPanelSections(
|
||||
DEFAULT_INFO_PANEL_SECTIONS,
|
||||
{ regenerateIds: true },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...typeDefaults,
|
||||
@ -294,13 +313,172 @@ export const normalizeCarouselSlide = (
|
||||
*/
|
||||
export const normalizeInfoPanelImage = (
|
||||
image: Record<string, unknown>,
|
||||
): InfoPanelImage => ({
|
||||
id: String(image?.id || createLocalId()),
|
||||
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
|
||||
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
|
||||
caption: image?.caption ? String(image.caption) : undefined,
|
||||
itemType: (image?.itemType as 'image' | '360') || 'image',
|
||||
});
|
||||
): InfoPanelImage => {
|
||||
const clickAction =
|
||||
image.clickAction === 'target_page' ||
|
||||
image.clickAction === 'external_url' ||
|
||||
image.clickAction === 'panel' ||
|
||||
image.clickAction === 'fullscreen'
|
||||
? (image.clickAction as InfoPanelImageClickAction)
|
||||
: image.clickAction === 'overview'
|
||||
? 'fullscreen'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: String(image?.id || createLocalId()),
|
||||
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
|
||||
videoUrl: image?.videoUrl ? String(image.videoUrl) : undefined,
|
||||
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
|
||||
caption: image?.caption ? String(image.caption) : undefined,
|
||||
itemType:
|
||||
image?.itemType === 'video' || image?.itemType === '360'
|
||||
? image.itemType
|
||||
: 'image',
|
||||
iconUrl: image?.iconUrl ? String(image.iconUrl) : undefined,
|
||||
clickAction,
|
||||
targetPageSlug: image?.targetPageSlug
|
||||
? String(image.targetPageSlug)
|
||||
: undefined,
|
||||
externalUrl: image?.externalUrl ? String(image.externalUrl) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize an info panel info span from unknown input
|
||||
*/
|
||||
export const normalizeInfoPanelInfoSpan = (
|
||||
span: Record<string, unknown>,
|
||||
): InfoPanelInfoSpan => {
|
||||
const clickAction =
|
||||
span.clickAction === 'target_page' || span.clickAction === 'external_url'
|
||||
? (span.clickAction as InfoPanelLinkClickAction)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: String(span?.id || createLocalId()),
|
||||
text: String(span?.text ?? ''),
|
||||
iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined,
|
||||
clickAction,
|
||||
targetPageSlug: span?.targetPageSlug
|
||||
? String(span.targetPageSlug)
|
||||
: undefined,
|
||||
externalUrl: span?.externalUrl ? String(span.externalUrl) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const INFO_PANEL_SECTION_TYPES = new Set<InfoPanelSectionType>([
|
||||
'header',
|
||||
'title',
|
||||
'text',
|
||||
'spans',
|
||||
'cards',
|
||||
'images',
|
||||
]);
|
||||
|
||||
const isInfoPanelSectionType = (
|
||||
value: unknown,
|
||||
): value is InfoPanelSectionType =>
|
||||
typeof value === 'string' &&
|
||||
INFO_PANEL_SECTION_TYPES.has(value as InfoPanelSectionType);
|
||||
|
||||
const normalizeInfoPanelColumns = (value: unknown): number | undefined => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const parsed =
|
||||
typeof value === 'string' ? Number(value.trim()) : Number(value);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return clamp(Math.round(parsed), 1, 6);
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize an info panel section instance from unknown input.
|
||||
* regenerateIds is used when section templates come from defaults for a new element.
|
||||
*/
|
||||
export const normalizeInfoPanelSection = (
|
||||
section: Record<string, unknown>,
|
||||
options?: { regenerateIds?: boolean },
|
||||
): InfoPanelSectionInstance => {
|
||||
const type = isInfoPanelSectionType(section?.type) ? section.type : 'text';
|
||||
|
||||
const normalized: InfoPanelSectionInstance = {
|
||||
id:
|
||||
options?.regenerateIds || !section?.id
|
||||
? createLocalId()
|
||||
: String(section.id),
|
||||
type,
|
||||
};
|
||||
|
||||
const columns = normalizeInfoPanelColumns(section.columns);
|
||||
if (columns !== undefined) {
|
||||
normalized.columns = columns;
|
||||
}
|
||||
|
||||
if (section.gap !== undefined && section.gap !== null) {
|
||||
normalized.gap = String(section.gap);
|
||||
}
|
||||
|
||||
if (
|
||||
section.mediaOpenMode === 'panel' ||
|
||||
section.mediaOpenMode === 'fullscreen'
|
||||
) {
|
||||
normalized.mediaOpenMode = section.mediaOpenMode as InfoPanelMediaOpenMode;
|
||||
}
|
||||
|
||||
if (Array.isArray(section.spans)) {
|
||||
normalized.spans = section.spans.map((span) =>
|
||||
normalizeInfoPanelInfoSpan(span as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(section.images)) {
|
||||
normalized.images = section.images.map((image) =>
|
||||
normalizeInfoPanelImage(image as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
if (section.text !== undefined && section.text !== null) {
|
||||
normalized.text = String(section.text);
|
||||
}
|
||||
|
||||
if (section.title !== undefined && section.title !== null) {
|
||||
normalized.title = String(section.title);
|
||||
}
|
||||
|
||||
if (section.headerImageUrl) {
|
||||
normalized.headerImageUrl = String(section.headerImageUrl);
|
||||
}
|
||||
|
||||
if (section.headerText !== undefined && section.headerText !== null) {
|
||||
normalized.headerText = String(section.headerText);
|
||||
}
|
||||
|
||||
if (
|
||||
section.clickAction === 'target_page' ||
|
||||
section.clickAction === 'external_url'
|
||||
) {
|
||||
normalized.clickAction = section.clickAction as InfoPanelLinkClickAction;
|
||||
}
|
||||
|
||||
if (section.targetPageSlug) {
|
||||
normalized.targetPageSlug = String(section.targetPageSlug);
|
||||
}
|
||||
|
||||
if (section.externalUrl) {
|
||||
normalized.externalUrl = String(section.externalUrl);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeInfoPanelSections = (
|
||||
sections: unknown,
|
||||
options?: { regenerateIds?: boolean },
|
||||
): InfoPanelSectionInstance[] => {
|
||||
if (!Array.isArray(sections)) return [];
|
||||
|
||||
return sections.map((section) =>
|
||||
normalizeInfoPanelSection(section as Record<string, unknown>, options),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge an element with project/global defaults.
|
||||
@ -419,6 +597,27 @@ export const mergeElementWithDefaults = (
|
||||
);
|
||||
}
|
||||
|
||||
// Handle info panel section instances
|
||||
if (isInfoPanelElementType(merged.type)) {
|
||||
const elementHasSections = Array.isArray(element.infoPanelSections);
|
||||
const defaultsHasSections = Array.isArray(defaults.infoPanelSections);
|
||||
const sections = preferElementValues
|
||||
? elementHasSections
|
||||
? element.infoPanelSections
|
||||
: defaultsHasSections
|
||||
? defaults.infoPanelSections
|
||||
: DEFAULT_INFO_PANEL_SECTIONS
|
||||
: defaultsHasSections
|
||||
? defaults.infoPanelSections
|
||||
: elementHasSections
|
||||
? element.infoPanelSections
|
||||
: DEFAULT_INFO_PANEL_SECTIONS;
|
||||
|
||||
merged.infoPanelSections = normalizeInfoPanelSections(sections, {
|
||||
regenerateIds: !preferElementValues || !elementHasSections,
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
@ -464,6 +663,12 @@ export const parseElementSettings = (
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.infoPanelSections)) {
|
||||
settings.infoPanelSections = normalizeInfoPanelSections(
|
||||
settings.infoPanelSections,
|
||||
);
|
||||
}
|
||||
|
||||
return settings as Partial<CanvasElement>;
|
||||
};
|
||||
|
||||
@ -711,6 +916,11 @@ export const buildElementSettings = (
|
||||
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
|
||||
addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
|
||||
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
|
||||
if (Array.isArray(element.infoPanelSections)) {
|
||||
settings.infoPanelSections = normalizeInfoPanelSections(
|
||||
element.infoPanelSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
|
||||
52
frontend/src/lib/embedUrl.ts
Normal file
52
frontend/src/lib/embedUrl.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Helpers for trusted third-party embed URLs.
|
||||
*/
|
||||
|
||||
const ALLOWED_EMBED_DOMAINS = [
|
||||
'matterport.com',
|
||||
'my.matterport.com',
|
||||
'kuula.co',
|
||||
'roundme.com',
|
||||
'sketchfab.com',
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'vimeo.com',
|
||||
'player.vimeo.com',
|
||||
'google.com',
|
||||
'maps.google.com',
|
||||
'www.google.com',
|
||||
'docs.google.com',
|
||||
'drive.google.com',
|
||||
'360stories.com',
|
||||
];
|
||||
|
||||
export const isValidEmbedUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ALLOWED_EMBED_DOMAINS.some(
|
||||
(domain) =>
|
||||
parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildChromeFreeEmbedUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.replace(/^www\./, '');
|
||||
|
||||
if (hostname === 'kuula.co' || hostname.endsWith('.kuula.co')) {
|
||||
parsed.searchParams.set('logo', '-1');
|
||||
parsed.searchParams.set('info', '0');
|
||||
parsed.searchParams.set('fs', '0');
|
||||
parsed.searchParams.set('vr', '0');
|
||||
parsed.searchParams.set('thumbs', '-1');
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
@ -63,6 +63,61 @@ function extractAssetFields(
|
||||
}
|
||||
});
|
||||
|
||||
const infoPanelSections = Array.isArray(element.infoPanelSections)
|
||||
? (element.infoPanelSections as Record<string, unknown>[])
|
||||
: [];
|
||||
const infoPanelSectionAssets = infoPanelSections
|
||||
.map((section) => {
|
||||
const sectionContent: Record<string, unknown> = {};
|
||||
(nestedUrlFields as readonly string[]).forEach((urlField) => {
|
||||
if (section[urlField] !== undefined && section[urlField] !== '') {
|
||||
sectionContent[urlField] = section[urlField];
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
section.headerImageUrl !== undefined &&
|
||||
section.headerImageUrl !== ''
|
||||
) {
|
||||
sectionContent.headerImageUrl = section.headerImageUrl;
|
||||
}
|
||||
|
||||
if (Array.isArray(section.spans)) {
|
||||
const spans = (section.spans as Record<string, unknown>[])
|
||||
.map((span) => {
|
||||
const spanContent: Record<string, unknown> = {};
|
||||
if (span.iconUrl !== undefined && span.iconUrl !== '') {
|
||||
spanContent.iconUrl = span.iconUrl;
|
||||
}
|
||||
return Object.keys(spanContent).length > 0 ? spanContent : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (spans.length > 0) sectionContent.spans = spans;
|
||||
}
|
||||
|
||||
if (Array.isArray(section.images)) {
|
||||
const images = (section.images as Record<string, unknown>[])
|
||||
.map((image) => {
|
||||
const imageContent: Record<string, unknown> = {};
|
||||
(nestedUrlFields as readonly string[]).forEach((urlField) => {
|
||||
if (image[urlField] !== undefined && image[urlField] !== '') {
|
||||
imageContent[urlField] = image[urlField];
|
||||
}
|
||||
});
|
||||
return Object.keys(imageContent).length > 0 ? imageContent : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (images.length > 0) sectionContent.images = images;
|
||||
}
|
||||
|
||||
return Object.keys(sectionContent).length > 0 ? sectionContent : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (infoPanelSectionAssets.length > 0) {
|
||||
contentObj.infoPanelSections = infoPanelSectionAssets;
|
||||
}
|
||||
|
||||
return contentObj;
|
||||
}
|
||||
|
||||
@ -82,6 +137,84 @@ function parseUiSchema(
|
||||
}
|
||||
}
|
||||
|
||||
const getString = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : '';
|
||||
|
||||
function collectTargetPageSlugs(element: Record<string, unknown>): string[] {
|
||||
const slugs = new Set<string>();
|
||||
const addSlug = (value: unknown) => {
|
||||
const slug = getString(value);
|
||||
if (slug) slugs.add(slug);
|
||||
};
|
||||
|
||||
addSlug(element.targetPageSlug);
|
||||
|
||||
const sections = Array.isArray(element.infoPanelSections)
|
||||
? (element.infoPanelSections as Record<string, unknown>[])
|
||||
: [];
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (section.clickAction === 'target_page') {
|
||||
addSlug(section.targetPageSlug);
|
||||
}
|
||||
|
||||
if (Array.isArray(section.spans)) {
|
||||
(section.spans as Record<string, unknown>[]).forEach((span) => {
|
||||
if (span.clickAction === 'target_page') {
|
||||
addSlug(span.targetPageSlug);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(section.images)) {
|
||||
(section.images as Record<string, unknown>[]).forEach((image) => {
|
||||
if (image.clickAction === 'target_page') {
|
||||
addSlug(image.targetPageSlug);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(slugs);
|
||||
}
|
||||
|
||||
function addPageLink(
|
||||
pageLinks: PreloadPageLink[],
|
||||
page: PageWithSchema,
|
||||
element: Record<string, unknown>,
|
||||
targetSlug: string,
|
||||
slugToIdMap: Map<string, string>,
|
||||
suffix: string,
|
||||
): void {
|
||||
const resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
||||
if (!resolvedTargetPageId || resolvedTargetPageId === page.id) return;
|
||||
|
||||
const hasTransitionVideo =
|
||||
element.transitionVideoUrl &&
|
||||
typeof element.transitionVideoUrl === 'string';
|
||||
const hasReverseVideo =
|
||||
element.reverseVideoUrl && typeof element.reverseVideoUrl === 'string';
|
||||
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${element.id || 'element'}-${suffix}`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: resolvedTargetPageId,
|
||||
is_active: true,
|
||||
transition:
|
||||
hasTransitionVideo || hasReverseVideo
|
||||
? {
|
||||
id: `transition-${element.id || 'element'}-${suffix}`,
|
||||
video_url: hasTransitionVideo
|
||||
? (element.transitionVideoUrl as string)
|
||||
: undefined,
|
||||
reverse_video_url: hasReverseVideo
|
||||
? (element.reverseVideoUrl as string)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract page links and preload elements from pages' ui_schema_json.
|
||||
*
|
||||
@ -140,50 +273,46 @@ export function extractPageLinksAndElements(
|
||||
});
|
||||
}
|
||||
|
||||
// Build synthetic page link for navigation elements
|
||||
const targetSlug =
|
||||
el.targetPageSlug && typeof el.targetPageSlug === 'string'
|
||||
? el.targetPageSlug
|
||||
: '';
|
||||
// Build synthetic page links for navigation elements and Info Panel targets
|
||||
const targetSlugs = collectTargetPageSlugs(el);
|
||||
const legacyTargetId =
|
||||
el.targetPageId && typeof el.targetPageId === 'string'
|
||||
? el.targetPageId
|
||||
: '';
|
||||
|
||||
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
|
||||
let resolvedTargetPageId = '';
|
||||
if (targetSlug) {
|
||||
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
||||
} else if (legacyTargetId) {
|
||||
targetSlugs.forEach((targetSlug, index) => {
|
||||
addPageLink(
|
||||
pageLinks,
|
||||
page,
|
||||
el,
|
||||
targetSlug,
|
||||
slugToIdMap,
|
||||
`${preloadElements.length}-${index}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (targetSlugs.length === 0 && legacyTargetId) {
|
||||
// Legacy: targetPageId might be a slug or an ID
|
||||
resolvedTargetPageId =
|
||||
slugToIdMap.get(legacyTargetId) || legacyTargetId;
|
||||
}
|
||||
|
||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
||||
const hasTransitionVideo =
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
||||
const hasReverseVideo =
|
||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
||||
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: resolvedTargetPageId,
|
||||
is_active: true,
|
||||
transition:
|
||||
hasTransitionVideo || hasReverseVideo
|
||||
? {
|
||||
id: `transition-${el.id || preloadElements.length}`,
|
||||
video_url: hasTransitionVideo
|
||||
? (el.transitionVideoUrl as string)
|
||||
: undefined,
|
||||
reverse_video_url: hasReverseVideo
|
||||
? (el.reverseVideoUrl as string)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const legacySlug = slugToIdMap.has(legacyTargetId)
|
||||
? legacyTargetId
|
||||
: '';
|
||||
if (legacySlug) {
|
||||
addPageLink(
|
||||
pageLinks,
|
||||
page,
|
||||
el,
|
||||
legacySlug,
|
||||
slugToIdMap,
|
||||
`${preloadElements.length}-legacy`,
|
||||
);
|
||||
} else if (legacyTargetId && legacyTargetId !== page.id) {
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || preloadElements.length}-legacy`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: legacyTargetId,
|
||||
is_active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -226,50 +355,32 @@ export function extractPageLinksOnly(
|
||||
: [];
|
||||
|
||||
pageElements.forEach((el) => {
|
||||
// Build synthetic page link for navigation elements
|
||||
const targetSlug =
|
||||
el.targetPageSlug && typeof el.targetPageSlug === 'string'
|
||||
? el.targetPageSlug
|
||||
: '';
|
||||
// Build synthetic page links for navigation elements and Info Panel targets
|
||||
const targetSlugs = collectTargetPageSlugs(el);
|
||||
const legacyTargetId =
|
||||
el.targetPageId && typeof el.targetPageId === 'string'
|
||||
? el.targetPageId
|
||||
: '';
|
||||
|
||||
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
|
||||
let resolvedTargetPageId = '';
|
||||
if (targetSlug) {
|
||||
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
||||
} else if (legacyTargetId) {
|
||||
targetSlugs.forEach((targetSlug, index) => {
|
||||
addPageLink(pageLinks, page, el, targetSlug, slugToIdMap, `${index}`);
|
||||
});
|
||||
|
||||
if (targetSlugs.length === 0 && legacyTargetId) {
|
||||
// Legacy: targetPageId might be a slug or an ID
|
||||
resolvedTargetPageId =
|
||||
slugToIdMap.get(legacyTargetId) || legacyTargetId;
|
||||
}
|
||||
|
||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
||||
const hasTransitionVideo =
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
||||
const hasReverseVideo =
|
||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
||||
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: resolvedTargetPageId,
|
||||
is_active: true,
|
||||
transition:
|
||||
hasTransitionVideo || hasReverseVideo
|
||||
? {
|
||||
id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
|
||||
video_url: hasTransitionVideo
|
||||
? (el.transitionVideoUrl as string)
|
||||
: undefined,
|
||||
reverse_video_url: hasReverseVideo
|
||||
? (el.reverseVideoUrl as string)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const legacySlug = slugToIdMap.has(legacyTargetId)
|
||||
? legacyTargetId
|
||||
: '';
|
||||
if (legacySlug) {
|
||||
addPageLink(pageLinks, page, el, legacySlug, slugToIdMap, 'legacy');
|
||||
} else if (legacyTargetId && legacyTargetId !== page.id) {
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || 'legacy'}-legacy`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: legacyTargetId,
|
||||
is_active: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -163,6 +163,28 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
if (Array.isArray(item.spans)) {
|
||||
item.spans.forEach((span: Record<string, unknown>) => {
|
||||
const value = span.iconUrl;
|
||||
if (typeof value === 'string' && value) {
|
||||
const url = resolveAssetPlaybackUrl(value);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(item.images)) {
|
||||
item.images.forEach((image: Record<string, unknown>) => {
|
||||
nestedImageFields.forEach((field) => {
|
||||
const value = image[field];
|
||||
if (typeof value === 'string' && value) {
|
||||
const url = resolveAssetPlaybackUrl(value);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -673,7 +673,7 @@ export function getInfoPanelGridColumns(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSS style object for images section preview area
|
||||
* Build CSS style object for media section preview area
|
||||
*/
|
||||
export function buildImagesPreviewStyle(
|
||||
element: Partial<CanvasElement>,
|
||||
@ -710,7 +710,7 @@ export function buildImagesPreviewStyle(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSS style object for images section thumbnail strip container
|
||||
* Build CSS style object for media section thumbnail strip container
|
||||
*/
|
||||
export function buildImagesThumbnailStripStyle(
|
||||
element: Partial<CanvasElement>,
|
||||
@ -733,7 +733,7 @@ export function buildImagesThumbnailStripStyle(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSS style object for images section thumbnail strip container with per-section settings.
|
||||
* Build CSS style object for media section thumbnail strip container with per-section settings.
|
||||
* Uses section instance settings (columns, gap) if available, falls back to element settings.
|
||||
* When columns is set, uses grid layout; otherwise uses flex layout.
|
||||
*/
|
||||
@ -768,7 +768,7 @@ export function buildImagesThumbnailStripStyleWithSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSS style object for images section thumbnail buttons
|
||||
* Build CSS style object for media section thumbnail buttons
|
||||
*/
|
||||
export function buildImagesThumbnailStyle(
|
||||
element: Partial<CanvasElement>,
|
||||
@ -872,7 +872,7 @@ export const INFO_PANEL_SECTION_STYLE_PROPS = [
|
||||
'panelPadding',
|
||||
'panelBorderRadius',
|
||||
'panelBackdropBlur',
|
||||
// Images section
|
||||
// Media section
|
||||
'infoPanelSelectedImageId',
|
||||
'infoPanelImagesPreviewHeight',
|
||||
'infoPanelImagesThumbnailSize',
|
||||
|
||||
@ -76,6 +76,7 @@ import type {
|
||||
GalleryInfoSpan,
|
||||
CarouselSlide,
|
||||
InfoPanelImage,
|
||||
GalleryCarouselMediaItem,
|
||||
} from '../types/constructor';
|
||||
import type { TourPage } from '../types/entities';
|
||||
|
||||
@ -208,12 +209,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
updateFromPage: updateBackgroundFromPage,
|
||||
setImageUrl: setBackgroundImageUrl,
|
||||
setVideoUrl: setBackgroundVideoUrl,
|
||||
setEmbedUrl: setBackgroundEmbedUrl,
|
||||
setAudioUrl: setBackgroundAudioUrl,
|
||||
setVideoSettings: setBackgroundVideoSettings,
|
||||
setAudioSettings: setBackgroundAudioSettings,
|
||||
// Legacy compatibility values for components that expect flat props
|
||||
backgroundImageUrl,
|
||||
backgroundVideoUrl,
|
||||
backgroundEmbedUrl,
|
||||
backgroundAudioUrl,
|
||||
backgroundVideoAutoplay,
|
||||
backgroundVideoLoop,
|
||||
@ -225,12 +228,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
backgroundAudioEndTime,
|
||||
} = usePageBackground();
|
||||
|
||||
// Sound control hook for iOS autoplay compatibility
|
||||
// Videos start muted (for iOS autoplay), can be controlled via page settings
|
||||
// Global sound control starts muted for browser autoplay compatibility.
|
||||
const soundControl = useVideoSoundControl({
|
||||
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound
|
||||
pageHasSound: backgroundVideoMuted === false,
|
||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
||||
hasBackgroundAudio: Boolean(backgroundAudioUrl),
|
||||
});
|
||||
|
||||
// Network-aware transitions: skip video on slow networks, use CSS fade instead
|
||||
@ -292,6 +294,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
} | null>(null);
|
||||
const [activeDetailImage, setActiveDetailImage] =
|
||||
useState<InfoPanelImage | null>(null);
|
||||
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
|
||||
items: GalleryCarouselMediaItem[];
|
||||
initialIndex: number;
|
||||
} | null>(null);
|
||||
// Current element transition settings (for CSS transitions when no video)
|
||||
const [
|
||||
currentElementTransitionSettings,
|
||||
@ -369,15 +375,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
return selectedElement;
|
||||
}, [isConstructorEditMode, selectedElement]);
|
||||
|
||||
// Determine which info panel element to use for overlay rendering
|
||||
const infoPanelElementToRender =
|
||||
activeInfoPanelElement || editModeInfoPanelElement;
|
||||
// In edit mode the overlay is only a selected-element preview. Do not keep
|
||||
// runtime-open Info Panel state above the canvas after selection is cleared.
|
||||
const infoPanelElementToRender = isConstructorEditMode
|
||||
? editModeInfoPanelElement
|
||||
: activeInfoPanelElement;
|
||||
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
|
||||
|
||||
// Reset info panel state when switching between edit and interact modes
|
||||
useEffect(() => {
|
||||
setActiveInfoPanel(null);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
}, [isConstructorEditMode]);
|
||||
|
||||
// Draggable panels using useDraggable hook
|
||||
@ -470,6 +479,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
id: p.id,
|
||||
background_image_url: p.background_image_url,
|
||||
background_video_url: p.background_video_url,
|
||||
background_embed_url: p.background_embed_url,
|
||||
background_audio_url: p.background_audio_url,
|
||||
})),
|
||||
pageLinks,
|
||||
@ -506,6 +516,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const {
|
||||
currentImageUrl: navCurrentBgImageUrl,
|
||||
currentVideoUrl: navCurrentBgVideoUrl,
|
||||
currentEmbedUrl: navCurrentBgEmbedUrl,
|
||||
currentAudioUrl: navCurrentBgAudioUrl,
|
||||
previousImageUrl: navPreviousBgImageUrl,
|
||||
previousVideoUrl: navPreviousBgVideoUrl,
|
||||
@ -522,6 +533,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onVideoBufferStateChange,
|
||||
onTransitionEnded,
|
||||
navigateToPage: navNavigateToPage,
|
||||
setBackgroundDirectly: navSetBackgroundDirectly,
|
||||
resetToIdle: navResetToIdle,
|
||||
startTransition,
|
||||
} = navState;
|
||||
@ -560,6 +572,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
id: page.id,
|
||||
background_image_url: page.background_image_url,
|
||||
background_video_url: page.background_video_url,
|
||||
background_embed_url: page.background_embed_url,
|
||||
background_audio_url: page.background_audio_url,
|
||||
}
|
||||
: null,
|
||||
@ -578,6 +591,105 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
[navNavigateToPage, applyPageSelection],
|
||||
);
|
||||
|
||||
const handleInfoPanelNavigateToPage = useCallback(
|
||||
(targetPageSlug: string) => {
|
||||
const targetPage =
|
||||
pages.find((page) => page.slug === targetPageSlug) || null;
|
||||
if (!targetPage) return;
|
||||
|
||||
setActiveInfoPanel(null);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
switchToPage(targetPage).then(() => {
|
||||
clearSelection();
|
||||
setSelectedMenuItem('none');
|
||||
setErrorMessage('');
|
||||
});
|
||||
},
|
||||
[pages, switchToPage, clearSelection],
|
||||
);
|
||||
|
||||
const handleInfoPanelOpenExternalUrl = useCallback((url: string) => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return;
|
||||
const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleInfoPanelUseAsBackground = useCallback(
|
||||
(item: InfoPanelImage) => {
|
||||
const mediaType =
|
||||
item.itemType === 'video'
|
||||
? 'video'
|
||||
: item.itemType === '360'
|
||||
? '360'
|
||||
: 'image';
|
||||
if (
|
||||
(mediaType === 'image' && !item.imageUrl) ||
|
||||
(mediaType === 'video' && !item.videoUrl) ||
|
||||
(mediaType === '360' && !item.embedUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
navSetBackgroundDirectly(
|
||||
mediaType === 'image' && item.imageUrl
|
||||
? resolveAssetPlaybackUrl(item.imageUrl)
|
||||
: '',
|
||||
mediaType === 'video' && item.videoUrl
|
||||
? resolveAssetPlaybackUrl(item.videoUrl)
|
||||
: '',
|
||||
mediaType === '360' && item.embedUrl
|
||||
? resolveAssetPlaybackUrl(item.embedUrl)
|
||||
: '',
|
||||
navCurrentBgAudioUrl,
|
||||
);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
},
|
||||
[navCurrentBgAudioUrl, navSetBackgroundDirectly],
|
||||
);
|
||||
|
||||
const handleInfoPanelOpenGallery = useCallback(
|
||||
(items: InfoPanelImage[], initialIndex: number) => {
|
||||
const activeItemId = items[initialIndex]?.id;
|
||||
const galleryItems = items
|
||||
.map<GalleryCarouselMediaItem | null>((item) => {
|
||||
const mediaType =
|
||||
item.itemType === 'video'
|
||||
? 'video'
|
||||
: item.itemType === '360'
|
||||
? '360'
|
||||
: 'image';
|
||||
if (mediaType === 'image' && !item.imageUrl) return null;
|
||||
if (mediaType === 'video' && !item.videoUrl) return null;
|
||||
if (mediaType === '360' && !item.embedUrl) return null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
imageUrl: item.imageUrl,
|
||||
videoUrl: item.videoUrl,
|
||||
embedUrl: item.embedUrl,
|
||||
caption: item.caption,
|
||||
title: item.caption,
|
||||
mediaType,
|
||||
};
|
||||
})
|
||||
.filter((item): item is GalleryCarouselMediaItem => Boolean(item));
|
||||
|
||||
if (galleryItems.length === 0) return;
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery({
|
||||
items: galleryItems,
|
||||
initialIndex: Math.max(
|
||||
0,
|
||||
galleryItems.findIndex((item) => item.id === activeItemId),
|
||||
),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
isBuffering: isTransitionBuffering,
|
||||
isVideoReady: isTransitionVideoReady,
|
||||
@ -1154,6 +1266,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
id: activePage.id,
|
||||
background_image_url: activePage.background_image_url,
|
||||
background_video_url: activePage.background_video_url,
|
||||
background_embed_url: activePage.background_embed_url,
|
||||
background_audio_url: activePage.background_audio_url,
|
||||
});
|
||||
}, [activePage, navNavigateToPage]);
|
||||
@ -1427,12 +1540,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
);
|
||||
|
||||
// Handler for info panel clicks
|
||||
const handleInfoPanelClick = useCallback((element: CanvasElement) => {
|
||||
if (isInfoPanelElementType(element.type)) {
|
||||
setActiveInfoPanel({ elementId: element.id });
|
||||
setActiveDetailImage(null);
|
||||
}
|
||||
}, []);
|
||||
const handleInfoPanelClick = useCallback(
|
||||
(element: CanvasElement) => {
|
||||
if (isConstructorEditMode) return;
|
||||
if (isInfoPanelElementType(element.type)) {
|
||||
setActiveInfoPanel({ elementId: element.id });
|
||||
setActiveDetailImage(null);
|
||||
}
|
||||
},
|
||||
[isConstructorEditMode],
|
||||
);
|
||||
|
||||
// Handler for gallery carousel button position changes (constructor only)
|
||||
const handleGalleryCarouselButtonPositionChange = useCallback(
|
||||
@ -1557,6 +1674,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
resolveUrlWithBlob,
|
||||
]);
|
||||
|
||||
const backgroundEmbedSrc = useMemo(() => {
|
||||
if (isConstructorEditMode && backgroundEmbedUrl) {
|
||||
return resolveUrlWithBlob(backgroundEmbedUrl);
|
||||
}
|
||||
return navCurrentBgEmbedUrl;
|
||||
}, [
|
||||
isConstructorEditMode,
|
||||
backgroundEmbedUrl,
|
||||
navCurrentBgEmbedUrl,
|
||||
resolveUrlWithBlob,
|
||||
]);
|
||||
|
||||
const backgroundAudioSrc = useMemo(() => {
|
||||
if (isConstructorEditMode && backgroundAudioUrl) {
|
||||
return resolveUrlWithBlob(backgroundAudioUrl);
|
||||
@ -1577,9 +1706,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
? 'Background image'
|
||||
: selectedMenuItem === 'background_video'
|
||||
? 'Background video'
|
||||
: selectedMenuItem === 'background_audio'
|
||||
? 'Background audio'
|
||||
: selectedElement?.label || 'Element editor';
|
||||
: selectedMenuItem === 'background_embed'
|
||||
? 'Background 360'
|
||||
: selectedMenuItem === 'background_audio'
|
||||
? 'Background audio'
|
||||
: selectedElement?.label || 'Element editor';
|
||||
|
||||
// Background image is rendered by CanvasBackground component (same as runtime)
|
||||
// No CSS background-image needed on canvas div
|
||||
@ -1621,6 +1752,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Background convenience setters
|
||||
setBackgroundImageUrl,
|
||||
setBackgroundVideoUrl,
|
||||
setBackgroundEmbedUrl,
|
||||
setBackgroundAudioUrl,
|
||||
setBackgroundVideoSettings,
|
||||
setBackgroundAudioSettings,
|
||||
@ -1696,6 +1828,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
updateBackgroundFromPage,
|
||||
setBackgroundImageUrl,
|
||||
setBackgroundVideoUrl,
|
||||
setBackgroundEmbedUrl,
|
||||
setBackgroundAudioUrl,
|
||||
setBackgroundVideoSettings,
|
||||
elements,
|
||||
@ -1842,6 +1975,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageSrc}
|
||||
backgroundVideoUrl={backgroundVideoSrc}
|
||||
backgroundEmbedUrl={backgroundEmbedSrc}
|
||||
backgroundAudioUrl={backgroundAudioSrc}
|
||||
previousBgImageUrl={navPreviousBgImageUrl}
|
||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
||||
@ -2026,6 +2160,37 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeInfoPanelGallery && (
|
||||
<GalleryCarouselOverlay
|
||||
cards={activeInfoPanelGallery.items}
|
||||
initialIndex={activeInfoPanelGallery.initialIndex}
|
||||
onClose={() => setActiveInfoPanelGallery(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
prevIconUrl={infoPanelElementToRender?.galleryCarouselPrevIconUrl}
|
||||
nextIconUrl={infoPanelElementToRender?.galleryCarouselNextIconUrl}
|
||||
backIconUrl={infoPanelElementToRender?.galleryCarouselBackIconUrl}
|
||||
backLabel={
|
||||
infoPanelElementToRender?.galleryCarouselBackLabel || 'BACK'
|
||||
}
|
||||
prevX={infoPanelElementToRender?.galleryCarouselPrevX}
|
||||
prevY={infoPanelElementToRender?.galleryCarouselPrevY}
|
||||
nextX={infoPanelElementToRender?.galleryCarouselNextX}
|
||||
nextY={infoPanelElementToRender?.galleryCarouselNextY}
|
||||
backX={infoPanelElementToRender?.galleryCarouselBackX}
|
||||
backY={infoPanelElementToRender?.galleryCarouselBackY}
|
||||
prevWidth={infoPanelElementToRender?.galleryCarouselPrevWidth}
|
||||
prevHeight={infoPanelElementToRender?.galleryCarouselPrevHeight}
|
||||
nextWidth={infoPanelElementToRender?.galleryCarouselNextWidth}
|
||||
nextHeight={infoPanelElementToRender?.galleryCarouselNextHeight}
|
||||
backWidth={infoPanelElementToRender?.galleryCarouselBackWidth}
|
||||
backHeight={infoPanelElementToRender?.galleryCarouselBackHeight}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isEditMode={false}
|
||||
pageTransitionSettings={transitionSettings}
|
||||
galleryElement={infoPanelElementToRender || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Panel Overlay */}
|
||||
{shouldShowInfoPanelOverlays && infoPanelElementToRender && (
|
||||
<>
|
||||
@ -2034,11 +2199,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onClose={() => {
|
||||
setActiveInfoPanel(null);
|
||||
setActiveDetailImage(null);
|
||||
setActiveInfoPanelGallery(null);
|
||||
}}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
letterboxStyles={letterboxStyles}
|
||||
cssVars={canvasCssVars}
|
||||
onImageClick={(image) => setActiveDetailImage(image)}
|
||||
onOpenGallery={handleInfoPanelOpenGallery}
|
||||
onUseAsBackground={handleInfoPanelUseAsBackground}
|
||||
onNavigateToPage={handleInfoPanelNavigateToPage}
|
||||
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
|
||||
onSelectImage={
|
||||
isConstructorEditMode
|
||||
? (imageId) => {
|
||||
@ -2073,6 +2243,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onClose={() => setActiveDetailImage(null)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
letterboxStyles={letterboxStyles}
|
||||
cssVars={canvasCssVars}
|
||||
isEditMode={isConstructorEditMode}
|
||||
onDetailPositionChange={
|
||||
isConstructorEditMode
|
||||
|
||||
@ -39,6 +39,7 @@ export type EditorMenuItem =
|
||||
| 'none'
|
||||
| 'background_image'
|
||||
| 'background_video'
|
||||
| 'background_embed'
|
||||
| 'background_audio';
|
||||
|
||||
/**
|
||||
@ -68,6 +69,19 @@ export interface GalleryCard {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type GalleryCarouselMediaType = 'image' | 'video' | '360';
|
||||
|
||||
export interface GalleryCarouselMediaItem {
|
||||
id: string;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
embedUrl?: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
mediaType?: GalleryCarouselMediaType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery info span (brief note badge)
|
||||
*/
|
||||
@ -87,11 +101,19 @@ export interface CarouselSlide {
|
||||
}
|
||||
|
||||
/**
|
||||
* Info panel item type for images section
|
||||
* Info panel item type for media section
|
||||
* - 'image': Regular image displayed in inline preview
|
||||
* - '360': 360° embed trigger that opens ImageDetailPanel
|
||||
* - 'video': Video asset displayed in fullscreen gallery
|
||||
* - '360': 360° embed trigger displayed in fullscreen gallery
|
||||
*/
|
||||
export type InfoPanelItemType = 'image' | '360';
|
||||
export type InfoPanelItemType = 'image' | 'video' | '360';
|
||||
export type InfoPanelMediaOpenMode = 'panel' | 'fullscreen';
|
||||
export type InfoPanelImageClickAction =
|
||||
| 'panel'
|
||||
| 'fullscreen'
|
||||
| 'target_page'
|
||||
| 'external_url';
|
||||
export type InfoPanelLinkClickAction = 'target_page' | 'external_url';
|
||||
|
||||
/**
|
||||
* Info panel image/embed item
|
||||
@ -100,12 +122,21 @@ export type InfoPanelItemType = 'image' | '360';
|
||||
export interface InfoPanelImage {
|
||||
id: string;
|
||||
imageUrl?: string; // Regular image URL (storage key)
|
||||
videoUrl?: string; // Video URL/storage key
|
||||
embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...)
|
||||
caption?: string;
|
||||
/** Item type: 'image' for inline preview, '360' for embed trigger */
|
||||
itemType?: InfoPanelItemType;
|
||||
/** Custom icon URL for 360° trigger button */
|
||||
iconUrl?: string;
|
||||
/** Click destination for thumbnail/card items. Defaults to 'panel'. */
|
||||
clickAction?: InfoPanelImageClickAction;
|
||||
/** Target page slug when clickAction is 'target_page'. */
|
||||
targetPageSlug?: string;
|
||||
/** External URL when clickAction is 'external_url'. */
|
||||
externalUrl?: string;
|
||||
/** When true, clicking this media item replaces the current screen background. */
|
||||
useAsBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,12 +146,18 @@ export interface InfoPanelInfoSpan {
|
||||
id: string;
|
||||
text: string;
|
||||
iconUrl?: string; // Renders icon instead of text when set
|
||||
/** Click destination for the span. */
|
||||
clickAction?: InfoPanelLinkClickAction;
|
||||
/** Target page slug when clickAction is 'target_page'. */
|
||||
targetPageSlug?: string;
|
||||
/** External URL when clickAction is 'external_url'. */
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Info Panel section types for dynamic ordering
|
||||
* - 'cards': Legacy card grid (click opens ImageDetailPanel)
|
||||
* - 'images': New inline image viewer with preview + thumbnail strip
|
||||
* - 'cards': Card grid for media items
|
||||
* - 'images': Media viewer with preview + thumbnail strip
|
||||
*/
|
||||
export type InfoPanelSectionType =
|
||||
| 'header'
|
||||
@ -145,6 +182,8 @@ export interface InfoPanelSectionInstance {
|
||||
columns?: number;
|
||||
/** Gap between items */
|
||||
gap?: string;
|
||||
/** Media click rendering mode for cards/media sections */
|
||||
mediaOpenMode?: InfoPanelMediaOpenMode;
|
||||
|
||||
// Per-instance data (each section has its OWN items)
|
||||
/** For 'spans' type sections */
|
||||
@ -159,6 +198,12 @@ export interface InfoPanelSectionInstance {
|
||||
headerImageUrl?: string;
|
||||
/** For 'header' type sections - text content */
|
||||
headerText?: string;
|
||||
/** Click destination for header/title/text sections. */
|
||||
clickAction?: InfoPanelLinkClickAction;
|
||||
/** Target page slug when clickAction is 'target_page'. */
|
||||
targetPageSlug?: string;
|
||||
/** External URL when clickAction is 'external_url'. */
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -169,7 +214,7 @@ export const DEFAULT_INFO_PANEL_SECTIONS: InfoPanelSectionInstance[] = [
|
||||
{ id: 'default-title', type: 'title' },
|
||||
{ id: 'default-text', type: 'text' },
|
||||
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
|
||||
{ id: 'default-images', type: 'images', images: [] },
|
||||
{ id: 'default-images', type: 'images', mediaOpenMode: 'panel', images: [] },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -181,7 +226,7 @@ export const INFO_PANEL_SECTION_LABELS: Record<InfoPanelSectionType, string> = {
|
||||
text: 'Text',
|
||||
spans: 'Info Spans',
|
||||
cards: 'Cards',
|
||||
images: 'Images',
|
||||
images: 'Media',
|
||||
};
|
||||
|
||||
/**
|
||||
@ -503,12 +548,12 @@ export interface CanvasElement extends BaseCanvasElement {
|
||||
/** Section instances with per-section columns, gap, spans, and images */
|
||||
infoPanelSections?: InfoPanelSectionInstance[];
|
||||
|
||||
// Images section (inline image viewer)
|
||||
/** Currently selected image ID in the images section preview */
|
||||
// Media section (inline media viewer)
|
||||
/** Currently selected image ID in the media section preview */
|
||||
infoPanelSelectedImageId?: string;
|
||||
/** Preview area height for images section (e.g., '300', 'auto') */
|
||||
/** Preview area height for media section (e.g., '300', 'auto') */
|
||||
infoPanelImagesPreviewHeight?: string;
|
||||
/** Thumbnail size for images section (e.g., '80') */
|
||||
/** Thumbnail size for media section (e.g., '80') */
|
||||
infoPanelImagesThumbnailSize?: string;
|
||||
|
||||
// Component 3: Image Detail Panel
|
||||
@ -696,6 +741,7 @@ export interface EditorElementProps {
|
||||
| 'none'
|
||||
| 'background_image'
|
||||
| 'background_video'
|
||||
| 'background_embed'
|
||||
| 'background_audio';
|
||||
onRemoveElement: () => void;
|
||||
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
||||
@ -815,6 +861,8 @@ export interface PageBackgroundState {
|
||||
imageUrl: string;
|
||||
/** Storage key or URL for background video */
|
||||
videoUrl: string;
|
||||
/** Storage key or URL for background 360/embed */
|
||||
embedUrl: string;
|
||||
/** Storage key or URL for background audio */
|
||||
audioUrl: string;
|
||||
/** Video playback settings */
|
||||
@ -849,6 +897,7 @@ export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
|
||||
export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
|
||||
imageUrl: '',
|
||||
videoUrl: '',
|
||||
embedUrl: '',
|
||||
audioUrl: '',
|
||||
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
|
||||
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
|
||||
@ -861,6 +910,7 @@ export function createPageBackgroundFromPage(
|
||||
page: {
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
@ -880,6 +930,7 @@ export function createPageBackgroundFromPage(
|
||||
return {
|
||||
imageUrl: page.background_image_url || '',
|
||||
videoUrl: page.background_video_url || '',
|
||||
embedUrl: page.background_embed_url || '',
|
||||
audioUrl: page.background_audio_url || '',
|
||||
videoSettings: {
|
||||
autoplay: page.background_video_autoplay ?? true,
|
||||
|
||||
@ -115,6 +115,7 @@ export interface TourPage extends BaseEntity {
|
||||
// Background URL fields (direct storage paths)
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_embed_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user