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/
|
**/node_modules/
|
||||||
*/build/
|
*/build/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
CLAUDE.md
|
AGENTS.md
|
||||||
.claude/
|
.codex/
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class Tour_pagesDBApi extends GenericDBApi {
|
|||||||
'slug',
|
'slug',
|
||||||
'background_image_url',
|
'background_image_url',
|
||||||
'background_video_url',
|
'background_video_url',
|
||||||
|
'background_embed_url',
|
||||||
'background_audio_url',
|
'background_audio_url',
|
||||||
'ui_schema_json',
|
'ui_schema_json',
|
||||||
];
|
];
|
||||||
@ -72,6 +73,7 @@ class Tour_pagesDBApi extends GenericDBApi {
|
|||||||
sort_order: data.sort_order || null,
|
sort_order: data.sort_order || null,
|
||||||
background_image_url: data.background_image_url || null,
|
background_image_url: data.background_image_url || null,
|
||||||
background_video_url: data.background_video_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_url: data.background_audio_url || null,
|
||||||
background_audio_autoplay:
|
background_audio_autoplay:
|
||||||
data.background_audio_autoplay !== undefined
|
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,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
background_embed_url: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
|
||||||
background_audio_url: {
|
background_audio_url: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
|||||||
'sort_order',
|
'sort_order',
|
||||||
'background_image_url',
|
'background_image_url',
|
||||||
'background_video_url',
|
'background_video_url',
|
||||||
|
'background_embed_url',
|
||||||
'background_audio_url',
|
'background_audio_url',
|
||||||
'background_loop',
|
'background_loop',
|
||||||
'requires_auth',
|
'requires_auth',
|
||||||
|
|||||||
@ -448,6 +448,11 @@ class ProjectsService extends BaseProjectsService {
|
|||||||
assetPathMap.get(pageData.background_video_url) ||
|
assetPathMap.get(pageData.background_video_url) ||
|
||||||
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) {
|
if (pageData.background_audio_url) {
|
||||||
pageData.background_audio_url =
|
pageData.background_audio_url =
|
||||||
assetPathMap.get(pageData.background_audio_url) ||
|
assetPathMap.get(pageData.background_audio_url) ||
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* BackgroundSettingsEditor Component
|
* 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.
|
* Used in the element editor panel for background menu items.
|
||||||
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
|
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
|
||||||
*/
|
*/
|
||||||
@ -15,7 +15,7 @@ import type {
|
|||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
|
||||||
interface BackgroundSettingsEditorProps {
|
interface BackgroundSettingsEditorProps {
|
||||||
type: 'image' | 'video' | 'audio';
|
type: 'image' | 'video' | 'embed' | 'audio';
|
||||||
value: string;
|
value: string;
|
||||||
options: AssetOption[];
|
options: AssetOption[];
|
||||||
durationNote?: string;
|
durationNote?: string;
|
||||||
@ -38,6 +38,7 @@ interface BackgroundSettingsEditorProps {
|
|||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
image: 'Background image',
|
image: 'Background image',
|
||||||
video: 'Background video',
|
video: 'Background video',
|
||||||
|
embed: 'Background 360',
|
||||||
audio: 'Background audio',
|
audio: 'Background audio',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* CanvasBackground Component
|
* 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.
|
* Handles blob URLs, Next.js Image optimization, and previous background overlay.
|
||||||
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
* 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 { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
|
||||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||||
import { baseURLApi } from '../../config';
|
import { baseURLApi } from '../../config';
|
||||||
|
import { buildChromeFreeEmbedUrl } from '../../lib/embedUrl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a callback to run after the next browser paint.
|
* Schedule a callback to run after the next browser paint.
|
||||||
@ -45,6 +46,7 @@ interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
|||||||
interface CanvasBackgroundProps {
|
interface CanvasBackgroundProps {
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
backgroundVideoUrl?: string;
|
backgroundVideoUrl?: string;
|
||||||
|
backgroundEmbedUrl?: string;
|
||||||
backgroundAudioUrl?: string;
|
backgroundAudioUrl?: string;
|
||||||
previousBgImageUrl?: string;
|
previousBgImageUrl?: string;
|
||||||
previousBgVideoUrl?: string;
|
previousBgVideoUrl?: string;
|
||||||
@ -76,6 +78,7 @@ interface CanvasBackgroundProps {
|
|||||||
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||||
backgroundImageUrl,
|
backgroundImageUrl,
|
||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
|
backgroundEmbedUrl,
|
||||||
backgroundAudioUrl,
|
backgroundAudioUrl,
|
||||||
previousBgImageUrl,
|
previousBgImageUrl,
|
||||||
previousBgVideoUrl,
|
previousBgVideoUrl,
|
||||||
@ -96,6 +99,20 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
audioStoragePath,
|
audioStoragePath,
|
||||||
pauseAudio = false,
|
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.
|
// 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.
|
// 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.
|
// 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
|
// Track video buffering via canplay/waiting events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!backgroundVideoUrl || !video) {
|
if (!backgroundVideoUrl || hasEmbedBackground || !video) {
|
||||||
setIsVideoBuffering(false);
|
setIsVideoBuffering(false);
|
||||||
// CRITICAL: Also notify parent that buffering is done when there's no video
|
// 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
|
// 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('canplay', handleCanPlay);
|
||||||
video.removeEventListener('waiting', handleWaiting);
|
video.removeEventListener('waiting', handleWaiting);
|
||||||
};
|
};
|
||||||
}, [backgroundVideoUrl, onVideoBufferStateChange]);
|
}, [backgroundVideoUrl, hasEmbedBackground, onVideoBufferStateChange]);
|
||||||
|
|
||||||
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
|
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
|
||||||
const videoSrc = useMemo(() => {
|
const videoSrc = useMemo(() => {
|
||||||
@ -413,7 +430,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
{/* Background image - z-1 keeps it below backdrop blur layer (z-5).
|
{/* Background image - z-1 keeps it below backdrop blur layer (z-5).
|
||||||
Image layer stays visible while video buffers (fallback behavior).
|
Image layer stays visible while video buffers (fallback behavior).
|
||||||
When video is ready, image fades out via opacity transition. */}
|
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
|
<div
|
||||||
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
|
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
|
||||||
style={{
|
style={{
|
||||||
@ -473,7 +500,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
webkit-playsinline is legacy attribute for older iOS versions.
|
webkit-playsinline is legacy attribute for older iOS versions.
|
||||||
preload="metadata" is required for iOS Safari video initialization.
|
preload="metadata" is required for iOS Safari video initialization.
|
||||||
Video fades in when ready (opacity transition from 0 to 1). */}
|
Video fades in when ready (opacity transition from 0 to 1). */}
|
||||||
{activeVideoUrl && (
|
{activeVideoUrl && !hasEmbedBackground && (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
key={`bg_video_${activeVideoUrl}`}
|
key={`bg_video_${activeVideoUrl}`}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
mdiMusicNote,
|
mdiMusicNote,
|
||||||
mdiVideo,
|
mdiVideo,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
|
mdiPanoramaHorizontal,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import BaseIcon from '../BaseIcon';
|
import BaseIcon from '../BaseIcon';
|
||||||
import BaseButton from '../BaseButton';
|
import BaseButton from '../BaseButton';
|
||||||
@ -186,6 +187,13 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|||||||
handleMenuAction(() => onSelectMenuItem('background_video'))
|
handleMenuAction(() => onSelectMenuItem('background_video'))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<MenuActionButton
|
||||||
|
icon={mdiPanoramaHorizontal}
|
||||||
|
label='Background 360'
|
||||||
|
onClick={() =>
|
||||||
|
handleMenuAction(() => onSelectMenuItem('background_embed'))
|
||||||
|
}
|
||||||
|
/>
|
||||||
<MenuActionButton
|
<MenuActionButton
|
||||||
icon={mdiMusicNote}
|
icon={mdiMusicNote}
|
||||||
label='Background Audio'
|
label='Background Audio'
|
||||||
|
|||||||
@ -153,6 +153,7 @@ export function ElementEditorPanel({
|
|||||||
pageBackground,
|
pageBackground,
|
||||||
setBackgroundImageUrl,
|
setBackgroundImageUrl,
|
||||||
setBackgroundVideoUrl,
|
setBackgroundVideoUrl,
|
||||||
|
setBackgroundEmbedUrl,
|
||||||
setBackgroundAudioUrl,
|
setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
setBackgroundAudioSettings,
|
setBackgroundAudioSettings,
|
||||||
@ -208,7 +209,10 @@ export function ElementEditorPanel({
|
|||||||
options={assetOptions.backgroundImage}
|
options={assetOptions.backgroundImage}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setBackgroundImageUrl(value);
|
setBackgroundImageUrl(value);
|
||||||
if (value) setBackgroundVideoUrl('');
|
if (value) {
|
||||||
|
setBackgroundVideoUrl('');
|
||||||
|
setBackgroundEmbedUrl('');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -222,7 +226,10 @@ export function ElementEditorPanel({
|
|||||||
durationNote={durationNotes.backgroundVideo}
|
durationNote={durationNotes.backgroundVideo}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setBackgroundVideoUrl(value);
|
setBackgroundVideoUrl(value);
|
||||||
if (value) setBackgroundImageUrl('');
|
if (value) {
|
||||||
|
setBackgroundImageUrl('');
|
||||||
|
setBackgroundEmbedUrl('');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
videoAutoplay={pageBackground.videoSettings.autoplay}
|
videoAutoplay={pageBackground.videoSettings.autoplay}
|
||||||
videoLoop={pageBackground.videoSettings.loop}
|
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 */}
|
{/* Background Audio Settings */}
|
||||||
{selectedMenuItem === 'background_audio' && (
|
{selectedMenuItem === 'background_audio' && (
|
||||||
<BackgroundSettingsEditor
|
<BackgroundSettingsEditor
|
||||||
@ -522,8 +545,11 @@ export function ElementEditorPanel({
|
|||||||
<InfoPanelSettingsSectionCompact
|
<InfoPanelSettingsSectionCompact
|
||||||
element={selectedElement}
|
element={selectedElement}
|
||||||
imageAssetOptions={assetOptions.image}
|
imageAssetOptions={assetOptions.image}
|
||||||
|
videoAssetOptions={assetOptions.video}
|
||||||
iconAssetOptions={assetOptions.icon}
|
iconAssetOptions={assetOptions.icon}
|
||||||
embedAssetOptions={assetOptions.embed}
|
embedAssetOptions={assetOptions.embed}
|
||||||
|
pages={pages}
|
||||||
|
activePageId={activePageId}
|
||||||
onChange={(prop, value) =>
|
onChange={(prop, value) =>
|
||||||
updateSelectedElement({ [prop]: value })
|
updateSelectedElement({ [prop]: value })
|
||||||
}
|
}
|
||||||
@ -1317,15 +1343,15 @@ export function ElementEditorPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images Section Styles */}
|
{/* Media Section Styles */}
|
||||||
<div className='rounded border border-white/10 p-2 space-y-2'>
|
<div className='rounded border border-white/10 p-2 space-y-2'>
|
||||||
<p className='text-[10px] font-semibold text-white/80'>
|
<p className='text-[10px] font-semibold text-white/80'>
|
||||||
Images Section
|
Media Section
|
||||||
</p>
|
</p>
|
||||||
<div className='grid grid-cols-2 gap-2'>
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[10px] text-white/70'>
|
<label className='mb-1 block text-[10px] text-white/70'>
|
||||||
Preview Height
|
Preview height
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
@ -1344,7 +1370,7 @@ export function ElementEditorPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[10px] text-white/70'>
|
<label className='mb-1 block text-[10px] text-white/70'>
|
||||||
Thumbnail Size
|
Thumbnail size
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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;
|
ui_schema_json?: string;
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_loop?: boolean;
|
background_loop?: boolean;
|
||||||
// Background video playback settings
|
// Background video playback settings
|
||||||
|
|||||||
@ -90,7 +90,7 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
|
|||||||
detailCaptionFontFamily,
|
detailCaptionFontFamily,
|
||||||
// Section instances (order + per-section settings)
|
// Section instances (order + per-section settings)
|
||||||
infoPanelSections,
|
infoPanelSections,
|
||||||
// Images section settings
|
// Media section settings
|
||||||
infoPanelImagesPreviewHeight,
|
infoPanelImagesPreviewHeight,
|
||||||
infoPanelImagesThumbnailSize,
|
infoPanelImagesThumbnailSize,
|
||||||
// Handlers
|
// Handlers
|
||||||
@ -783,13 +783,13 @@ const InfoPanelSettingsSection: React.FC<InfoPanelSettingsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images Section Layout */}
|
{/* Media Section Layout */}
|
||||||
<div className='rounded-lg border border-gray-200 p-4 dark:border-dark-700'>
|
<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'>
|
<h3 className='mb-4 text-sm font-semibold text-gray-900 dark:text-white'>
|
||||||
Images Section Layout
|
Media Section Layout
|
||||||
</h3>
|
</h3>
|
||||||
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
<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.
|
the "images" section type in Section Order to enable.
|
||||||
</p>
|
</p>
|
||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import type {
|
|||||||
InfoPanelSectionType,
|
InfoPanelSectionType,
|
||||||
InfoPanelItemType,
|
InfoPanelItemType,
|
||||||
InfoPanelSectionInstance,
|
InfoPanelSectionInstance,
|
||||||
|
InfoPanelImageClickAction,
|
||||||
|
InfoPanelLinkClickAction,
|
||||||
|
InfoPanelMediaOpenMode,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
import {
|
import {
|
||||||
getInfoPanelSections,
|
getInfoPanelSections,
|
||||||
@ -24,8 +27,11 @@ import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
|||||||
interface InfoPanelSettingsSectionCompactProps {
|
interface InfoPanelSettingsSectionCompactProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
imageAssetOptions: AssetOption[];
|
imageAssetOptions: AssetOption[];
|
||||||
|
videoAssetOptions: AssetOption[];
|
||||||
iconAssetOptions: AssetOption[];
|
iconAssetOptions: AssetOption[];
|
||||||
embedAssetOptions?: AssetOption[];
|
embedAssetOptions?: AssetOption[];
|
||||||
|
pages: Array<{ id: string; slug?: string; name?: string }>;
|
||||||
|
activePageId: string;
|
||||||
onChange: (prop: string, value: string | number | boolean) => void;
|
onChange: (prop: string, value: string | number | boolean) => void;
|
||||||
// Section operations (new per-instance API)
|
// Section operations (new per-instance API)
|
||||||
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
|
onMoveSection?: (sectionId: string, direction: 'up' | 'down') => void;
|
||||||
@ -85,13 +91,229 @@ const ALL_SECTION_TYPES: InfoPanelSectionType[] = [
|
|||||||
'images',
|
'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<
|
const InfoPanelSettingsSectionCompact: React.FC<
|
||||||
InfoPanelSettingsSectionCompactProps
|
InfoPanelSettingsSectionCompactProps
|
||||||
> = ({
|
> = ({
|
||||||
element,
|
element,
|
||||||
imageAssetOptions,
|
imageAssetOptions,
|
||||||
|
videoAssetOptions,
|
||||||
iconAssetOptions,
|
iconAssetOptions,
|
||||||
embedAssetOptions = [],
|
embedAssetOptions = [],
|
||||||
|
pages,
|
||||||
|
activePageId,
|
||||||
onChange,
|
onChange,
|
||||||
onMoveSection,
|
onMoveSection,
|
||||||
onRemoveSection,
|
onRemoveSection,
|
||||||
@ -319,6 +541,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='Header text (shown if no image)'
|
placeholder='Header text (shown if no image)'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'>
|
<p className='text-[10px] text-white/50'>
|
||||||
Styling configured in CSS tab.
|
Styling configured in CSS tab.
|
||||||
</p>
|
</p>
|
||||||
@ -351,6 +581,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='Information'
|
placeholder='Information'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<LinkDestinationFields
|
||||||
|
action={section.clickAction}
|
||||||
|
targetPageSlug={section.targetPageSlug}
|
||||||
|
externalUrl={section.externalUrl}
|
||||||
|
pages={pages}
|
||||||
|
activePageId={activePageId}
|
||||||
|
onChange={(patch) => onUpdateSection?.(section.id, patch)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -381,6 +619,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='Description text...'
|
placeholder='Description text...'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<LinkDestinationFields
|
||||||
|
action={section.clickAction}
|
||||||
|
targetPageSlug={section.targetPageSlug}
|
||||||
|
externalUrl={section.externalUrl}
|
||||||
|
pages={pages}
|
||||||
|
activePageId={activePageId}
|
||||||
|
onChange={(patch) => onUpdateSection?.(section.id, patch)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -518,6 +764,16 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<LinkDestinationFields
|
||||||
|
action={span.clickAction}
|
||||||
|
targetPageSlug={span.targetPageSlug}
|
||||||
|
externalUrl={span.externalUrl}
|
||||||
|
pages={pages}
|
||||||
|
activePageId={activePageId}
|
||||||
|
onChange={(patch) =>
|
||||||
|
onUpdateSpan?.(section.id, span.id, patch)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sectionSpans.length === 0 && (
|
{sectionSpans.length === 0 && (
|
||||||
@ -614,6 +870,14 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='8'
|
placeholder='8'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<MediaOpenModeField
|
||||||
|
value={section.mediaOpenMode}
|
||||||
|
onChange={(mode) =>
|
||||||
|
onUpdateSection?.(section.id, {
|
||||||
|
mediaOpenMode: mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
{sectionImages.map((image, imgIndex) => (
|
{sectionImages.map((image, imgIndex) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
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'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={image.itemType || 'image'}
|
value={image.itemType || 'image'}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onUpdateImage?.(section.id, image.id, {
|
onUpdateImage?.(
|
||||||
itemType: e.target.value as 'image' | '360',
|
section.id,
|
||||||
})
|
image.id,
|
||||||
|
mediaTypePatch(
|
||||||
|
e.target.value as InfoPanelItemType,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value='image'>Image</option>
|
<option value='image'>Image</option>
|
||||||
|
<option value='video'>Video</option>
|
||||||
<option value='360'>360° Embed</option>
|
<option value='360'>360° Embed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -664,6 +933,29 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
}
|
}
|
||||||
placeholder='https://my.matterport.com/show/?m=...'
|
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
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
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...'
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sectionImages.length === 0 && (
|
{sectionImages.length === 0 && (
|
||||||
@ -793,41 +1104,31 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='8'
|
placeholder='8'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<MediaOpenModeField
|
||||||
|
value={section.mediaOpenMode}
|
||||||
|
onChange={(mode) =>
|
||||||
|
onUpdateSection?.(section.id, {
|
||||||
|
mediaOpenMode: mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Layout settings */}
|
{/* Layout settings */}
|
||||||
<div className='grid grid-cols-2 gap-2'>
|
<div>
|
||||||
<div>
|
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
||||||
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
|
Thumbnail Size
|
||||||
Preview Height
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
|
||||||
className='w-full rounded border border-gray-300 px-2 py-0.5 text-xs'
|
value={element.infoPanelImagesThumbnailSize || ''}
|
||||||
value={element.infoPanelImagesPreviewHeight || ''}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
onChange(
|
||||||
onChange(
|
'infoPanelImagesThumbnailSize',
|
||||||
'infoPanelImagesPreviewHeight',
|
e.target.value,
|
||||||
e.target.value,
|
)
|
||||||
)
|
}
|
||||||
}
|
placeholder='80'
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Styling settings (uses card styling props) */}
|
{/* Styling settings (uses card styling props) */}
|
||||||
@ -918,11 +1219,12 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
const newType = e.target
|
const newType = e.target
|
||||||
.value as InfoPanelItemType;
|
.value as InfoPanelItemType;
|
||||||
onUpdateImage?.(section.id, image.id, {
|
onUpdateImage?.(section.id, image.id, {
|
||||||
itemType: newType,
|
...mediaTypePatch(newType),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value='image'>Image</option>
|
<option value='image'>Image</option>
|
||||||
|
<option value='video'>Video</option>
|
||||||
<option value='360'>360° Trigger</option>
|
<option value='360'>360° Trigger</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -939,6 +1241,9 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onUpdateImage?.(section.id, image.id, {
|
onUpdateImage?.(section.id, image.id, {
|
||||||
imageUrl: e.target.value,
|
imageUrl: e.target.value,
|
||||||
|
videoUrl: '',
|
||||||
|
embedUrl: '',
|
||||||
|
iconUrl: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -957,6 +1262,37 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
@ -1036,6 +1372,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
|
|||||||
placeholder='Optional caption...'
|
placeholder='Optional caption...'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -331,7 +331,7 @@ export interface InfoPanelSettingsSectionProps {
|
|||||||
detailCaptionFontFamily: string;
|
detailCaptionFontFamily: string;
|
||||||
// Section instances (order + per-section settings)
|
// Section instances (order + per-section settings)
|
||||||
infoPanelSections?: InfoPanelSectionInstance[];
|
infoPanelSections?: InfoPanelSectionInstance[];
|
||||||
// Images section settings
|
// Media section settings
|
||||||
infoPanelImagesPreviewHeight?: string;
|
infoPanelImagesPreviewHeight?: string;
|
||||||
infoPanelImagesThumbnailSize?: string;
|
infoPanelImagesThumbnailSize?: string;
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|||||||
@ -40,11 +40,11 @@ interface RuntimeControlsProps {
|
|||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
/** Canvas height in pixels (for positioning relative to canvas) */
|
/** Canvas height in pixels (for positioning relative to canvas) */
|
||||||
canvasHeight?: number;
|
canvasHeight?: number;
|
||||||
/** Whether to show the sound toggle button (video has sound enabled) */
|
/** Whether to show the global sound toggle button */
|
||||||
showSoundButton?: boolean;
|
showSoundButton?: boolean;
|
||||||
/** Current muted state of the video */
|
/** Current muted state of presentation audio */
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
/** Callback to toggle sound on/off */
|
/** Callback to toggle all presentation sound on/off */
|
||||||
onSoundToggle?: () => void;
|
onSoundToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,6 +146,14 @@ function ControlButton({
|
|||||||
spinning?: boolean;
|
spinning?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const colors = buttonColors[color];
|
const colors = buttonColors[color];
|
||||||
|
const stopButtonEvent = (
|
||||||
|
event:
|
||||||
|
| React.MouseEvent<HTMLButtonElement>
|
||||||
|
| React.PointerEvent<HTMLButtonElement>
|
||||||
|
| React.TouchEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const buttonStyle: CSSProperties = {
|
const buttonStyle: CSSProperties = {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
@ -167,7 +175,16 @@ function ControlButton({
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
onClick={onClick}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
onPointerDown={stopButtonEvent}
|
||||||
|
onPointerDownCapture={stopButtonEvent}
|
||||||
|
onMouseDown={stopButtonEvent}
|
||||||
|
onMouseDownCapture={stopButtonEvent}
|
||||||
|
onTouchEnd={stopButtonEvent}
|
||||||
|
onTouchEndCapture={stopButtonEvent}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={title}
|
title={title}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@ -190,6 +207,15 @@ function ControlButton({
|
|||||||
* Delete button for removing offline data (smaller, subtle styling)
|
* Delete button for removing offline data (smaller, subtle styling)
|
||||||
*/
|
*/
|
||||||
function DeleteButton({ onClick }: { onClick: () => void }) {
|
function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const stopButtonEvent = (
|
||||||
|
event:
|
||||||
|
| React.MouseEvent<HTMLButtonElement>
|
||||||
|
| React.PointerEvent<HTMLButtonElement>
|
||||||
|
| React.TouchEvent<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
const buttonStyle: CSSProperties = {
|
const buttonStyle: CSSProperties = {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -207,7 +233,16 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
style={buttonStyle}
|
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'
|
title='Remove offline data'
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.color = '#EF4444'; // text-red-500
|
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%)
|
// Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%)
|
||||||
// So we offset from viewport edge by (viewport - canvas) / 2 + padding
|
// So we offset from viewport edge by (viewport - canvas) / 2 + padding
|
||||||
const padding = 16;
|
const padding = 16;
|
||||||
|
const rightSafetyInset = 48;
|
||||||
const rightOffset =
|
const rightOffset =
|
||||||
canvasWidth > 0 && viewport.width > 0
|
canvasWidth > 0 && viewport.width > 0
|
||||||
? (viewport.width - canvasWidth) / 2 + padding
|
? (viewport.width - canvasWidth) / 2 + padding + rightSafetyInset
|
||||||
: padding;
|
: padding + rightSafetyInset;
|
||||||
const topOffset =
|
const topOffset =
|
||||||
canvasHeight > 0 && viewport.height > 0
|
canvasHeight > 0 && viewport.height > 0
|
||||||
? (viewport.height - canvasHeight) / 2 + padding
|
? (viewport.height - canvasHeight) / 2 + padding
|
||||||
@ -464,8 +500,26 @@ export default function RuntimeControls({
|
|||||||
transformOrigin: 'top right',
|
transformOrigin: 'top right',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopControlEvent = (
|
||||||
|
event:
|
||||||
|
| React.MouseEvent<HTMLDivElement>
|
||||||
|
| React.PointerEvent<HTMLDivElement>
|
||||||
|
| React.TouchEvent<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={stopControlEvent}
|
||||||
|
onPointerDown={stopControlEvent}
|
||||||
|
onPointerDownCapture={stopControlEvent}
|
||||||
|
onMouseDown={stopControlEvent}
|
||||||
|
onMouseDownCapture={stopControlEvent}
|
||||||
|
onTouchEnd={stopControlEvent}
|
||||||
|
onTouchEndCapture={stopControlEvent}
|
||||||
|
>
|
||||||
<OfflineControl
|
<OfflineControl
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
projectSlug={projectSlug}
|
projectSlug={projectSlug}
|
||||||
|
|||||||
@ -100,6 +100,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
left: `${xPercent}%`,
|
left: `${xPercent}%`,
|
||||||
top: `${yPercent}%`,
|
top: `${yPercent}%`,
|
||||||
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
||||||
|
pointerEvents: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add appear animation to outer div
|
// Add appear animation to outer div
|
||||||
|
|||||||
@ -63,7 +63,11 @@ import {
|
|||||||
selectByProjectAndEnv as selectProjectTransitionSettings,
|
selectByProjectAndEnv as selectProjectTransitionSettings,
|
||||||
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||||
import type { TransitionPhase } from '../types/presentation';
|
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 { isInfoPanelElementType } from '../lib/elementDefaults';
|
||||||
import type { ElementTransitionSettings } from '../types/transition';
|
import type { ElementTransitionSettings } from '../types/transition';
|
||||||
import {
|
import {
|
||||||
@ -189,7 +193,11 @@ export default function RuntimePresentation({
|
|||||||
);
|
);
|
||||||
const [activeDetailImage, setActiveDetailImage] =
|
const [activeDetailImage, setActiveDetailImage] =
|
||||||
useState<InfoPanelImage | null>(null);
|
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<
|
const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
@ -279,6 +287,7 @@ export default function RuntimePresentation({
|
|||||||
const {
|
const {
|
||||||
currentImageUrl: navCurrentBgImageUrl,
|
currentImageUrl: navCurrentBgImageUrl,
|
||||||
currentVideoUrl: navCurrentBgVideoUrl,
|
currentVideoUrl: navCurrentBgVideoUrl,
|
||||||
|
currentEmbedUrl: navCurrentBgEmbedUrl,
|
||||||
currentAudioUrl: navCurrentBgAudioUrl,
|
currentAudioUrl: navCurrentBgAudioUrl,
|
||||||
previousImageUrl: navPreviousBgImageUrl,
|
previousImageUrl: navPreviousBgImageUrl,
|
||||||
previousVideoUrl: navPreviousBgVideoUrl,
|
previousVideoUrl: navPreviousBgVideoUrl,
|
||||||
@ -295,6 +304,7 @@ export default function RuntimePresentation({
|
|||||||
onVideoBufferStateChange,
|
onVideoBufferStateChange,
|
||||||
onTransitionEnded,
|
onTransitionEnded,
|
||||||
navigateToPage: navNavigateToPage,
|
navigateToPage: navNavigateToPage,
|
||||||
|
setBackgroundDirectly: navSetBackgroundDirectly,
|
||||||
resetToIdle: navResetToIdle,
|
resetToIdle: navResetToIdle,
|
||||||
startTransition,
|
startTransition,
|
||||||
} = navState;
|
} = navState;
|
||||||
@ -437,7 +447,11 @@ export default function RuntimePresentation({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
|
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
|
||||||
// Only initialize when backgrounds are empty (initial load)
|
// Only initialize when backgrounds are empty (initial load)
|
||||||
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
|
if (
|
||||||
|
!navCurrentBgImageUrl &&
|
||||||
|
!navCurrentBgVideoUrl &&
|
||||||
|
!navCurrentBgEmbedUrl
|
||||||
|
) {
|
||||||
lastInitializedPageIdRef.current = selectedPage.id;
|
lastInitializedPageIdRef.current = selectedPage.id;
|
||||||
navNavigateToPage(selectedPage);
|
navNavigateToPage(selectedPage);
|
||||||
}
|
}
|
||||||
@ -446,6 +460,7 @@ export default function RuntimePresentation({
|
|||||||
selectedPage,
|
selectedPage,
|
||||||
navCurrentBgImageUrl,
|
navCurrentBgImageUrl,
|
||||||
navCurrentBgVideoUrl,
|
navCurrentBgVideoUrl,
|
||||||
|
navCurrentBgEmbedUrl,
|
||||||
navNavigateToPage,
|
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
|
// Page loading state from unified navigation state machine
|
||||||
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
|
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
|
||||||
// navShowElements: true when phase is 'idle' or 'fading_in'
|
// 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)
|
// Background URLs come directly from navigation state (already resolved)
|
||||||
const backgroundImageUrl = navCurrentBgImageUrl;
|
const backgroundImageUrl = navCurrentBgImageUrl;
|
||||||
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
||||||
|
const backgroundEmbedUrl = navCurrentBgEmbedUrl;
|
||||||
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
||||||
|
|
||||||
// Background video playback settings from selected page
|
// Background video playback settings from selected page
|
||||||
@ -700,13 +817,47 @@ export default function RuntimePresentation({
|
|||||||
selectedPage?.background_audio_end_time != null
|
selectedPage?.background_audio_end_time != null
|
||||||
? parseFloat(String(selectedPage.background_audio_end_time))
|
? parseFloat(String(selectedPage.background_audio_end_time))
|
||||||
: null;
|
: 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
|
// Global sound control starts muted for browser autoplay compatibility.
|
||||||
// Videos start muted (for iOS autoplay), user can unmute via sound button
|
|
||||||
const soundControl = useVideoSoundControl({
|
const soundControl = useVideoSoundControl({
|
||||||
pageHasSound: pageVideoMuted === false, // Show button when page allows sound
|
pageHasSound: pageVideoMuted === false,
|
||||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
hasBackgroundAudio: Boolean(backgroundAudioUrl),
|
||||||
|
hasElementAudio,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
|
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
|
||||||
@ -843,6 +994,7 @@ export default function RuntimePresentation({
|
|||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundImageUrl={backgroundImageUrl}
|
backgroundImageUrl={backgroundImageUrl}
|
||||||
backgroundVideoUrl={backgroundVideoUrl}
|
backgroundVideoUrl={backgroundVideoUrl}
|
||||||
|
backgroundEmbedUrl={backgroundEmbedUrl}
|
||||||
backgroundAudioUrl={backgroundAudioUrl}
|
backgroundAudioUrl={backgroundAudioUrl}
|
||||||
previousBgImageUrl={navPreviousBgImageUrl}
|
previousBgImageUrl={navPreviousBgImageUrl}
|
||||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
previousBgVideoUrl={navPreviousBgVideoUrl}
|
||||||
@ -885,7 +1037,7 @@ export default function RuntimePresentation({
|
|||||||
{navShowElements && (
|
{navShowElements && (
|
||||||
<div
|
<div
|
||||||
data-testid='page-elements-wrapper'
|
data-testid='page-elements-wrapper'
|
||||||
className='absolute inset-0 z-[46]'
|
className='pointer-events-none absolute inset-0 z-[46]'
|
||||||
>
|
>
|
||||||
{pageElements.map((element: CanvasElement) => (
|
{pageElements.map((element: CanvasElement) => (
|
||||||
<RuntimeElement
|
<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 */}
|
{/* Info Panel Overlay */}
|
||||||
{activeInfoPanel && (
|
{activeInfoPanel && (
|
||||||
<>
|
<>
|
||||||
@ -1016,14 +1197,20 @@ export default function RuntimePresentation({
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setActiveInfoPanel(null);
|
setActiveInfoPanel(null);
|
||||||
setActiveDetailImage(null);
|
setActiveDetailImage(null);
|
||||||
|
setActiveInfoPanelGallery(null);
|
||||||
setRuntimeSelectedImageId(null);
|
setRuntimeSelectedImageId(null);
|
||||||
}}
|
}}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
cssVars={cssVars}
|
||||||
onImageClick={(image) => setActiveDetailImage(image)}
|
onImageClick={(image) => setActiveDetailImage(image)}
|
||||||
|
onOpenGallery={handleInfoPanelOpenGallery}
|
||||||
|
onUseAsBackground={handleInfoPanelUseAsBackground}
|
||||||
onSelectImage={(imageId) =>
|
onSelectImage={(imageId) =>
|
||||||
setRuntimeSelectedImageId(imageId)
|
setRuntimeSelectedImageId(imageId)
|
||||||
}
|
}
|
||||||
|
onNavigateToPage={handleInfoPanelNavigateToPage}
|
||||||
|
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
|
||||||
active360ItemId={
|
active360ItemId={
|
||||||
activeDetailImage?.itemType === '360'
|
activeDetailImage?.itemType === '360'
|
||||||
? activeDetailImage.id
|
? activeDetailImage.id
|
||||||
@ -1037,6 +1224,7 @@ export default function RuntimePresentation({
|
|||||||
onClose={() => setActiveDetailImage(null)}
|
onClose={() => setActiveDetailImage(null)}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
cssVars={cssVars}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -1047,19 +1235,21 @@ export default function RuntimePresentation({
|
|||||||
|
|
||||||
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
|
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
|
||||||
{/* Positioned outside canvas to avoid scaling with canvas transform */}
|
{/* Positioned outside canvas to avoid scaling with canvas transform */}
|
||||||
<RuntimeControls
|
{!activeGalleryCarousel && !activeInfoPanelGallery && (
|
||||||
projectId={project?.id || null}
|
<RuntimeControls
|
||||||
projectSlug={projectSlug}
|
projectId={project?.id || null}
|
||||||
projectName={project?.name}
|
projectSlug={projectSlug}
|
||||||
pages={pages}
|
projectName={project?.name}
|
||||||
isFullscreen={isFullscreen}
|
pages={pages}
|
||||||
toggleFullscreen={toggleFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
canvasWidth={canvasWidth}
|
toggleFullscreen={toggleFullscreen}
|
||||||
canvasHeight={canvasHeight}
|
canvasWidth={canvasWidth}
|
||||||
showSoundButton={soundControl.showSoundButton}
|
canvasHeight={canvasHeight}
|
||||||
isMuted={soundControl.isMuted}
|
showSoundButton={soundControl.showSoundButton}
|
||||||
onSoundToggle={soundControl.toggleSound}
|
isMuted={soundControl.isMuted}
|
||||||
/>
|
onSoundToggle={soundControl.toggleSound}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toast notifications for offline download status */}
|
{/* Toast notifications for offline download status */}
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import Icon from '@mdi/react';
|
import Icon from '@mdi/react';
|
||||||
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
|
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 type { ResolvedTransitionSettings } from '../../types/transition';
|
||||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||||
import {
|
import {
|
||||||
@ -17,9 +21,11 @@ import {
|
|||||||
extractGallerySlideOverride,
|
extractGallerySlideOverride,
|
||||||
} from '../../lib/resolveSlideTransition';
|
} from '../../lib/resolveSlideTransition';
|
||||||
import { useSlideTransition } from '../../hooks/useSlideTransition';
|
import { useSlideTransition } from '../../hooks/useSlideTransition';
|
||||||
|
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
|
||||||
|
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
|
||||||
|
|
||||||
interface GalleryCarouselOverlayProps {
|
interface GalleryCarouselOverlayProps {
|
||||||
cards: GalleryCard[];
|
cards: Array<GalleryCard | GalleryCarouselMediaItem>;
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
@ -58,6 +64,36 @@ interface GalleryCarouselOverlayProps {
|
|||||||
galleryElement?: CanvasElement;
|
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) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
Math.min(Math.max(value, min), max);
|
Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
@ -89,6 +125,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
galleryElement,
|
galleryElement,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
|
|
||||||
// Resolve slide transition with cascade
|
// Resolve slide transition with cascade
|
||||||
const slideTransition = resolveSlideTransition(
|
const slideTransition = resolveSlideTransition(
|
||||||
@ -385,12 +422,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentCard = cards[displayIndex];
|
const currentCard = cards[displayIndex];
|
||||||
|
const mediaType = getMediaType(currentCard);
|
||||||
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className='fixed inset-0 z-50 overflow-hidden bg-black'
|
className='fixed inset-0 z-[120] overflow-hidden bg-black'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// Only close if clicking the background, not buttons
|
// Only close if clicking the background, not buttons
|
||||||
if (e.target === overlayRef.current && !isEditMode) {
|
if (e.target === overlayRef.current && !isEditMode) {
|
||||||
@ -406,17 +452,44 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
|||||||
className='overflow-hidden'
|
className='overflow-hidden'
|
||||||
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||||
>
|
>
|
||||||
{/* Fullscreen image */}
|
{/* Fullscreen media */}
|
||||||
{imageUrl && (
|
{mediaType === 'image' && imageUrl && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={currentCard?.title || ''}
|
alt={mediaTitle}
|
||||||
className='absolute inset-0 h-full w-full object-contain'
|
className='absolute inset-0 h-full w-full object-contain'
|
||||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||||
draggable={false}
|
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) */}
|
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
|
||||||
{slideTransition.type === 'fade' && (
|
{slideTransition.type === 'fade' && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ImageDetailPanel Component
|
* ImageDetailPanel Component
|
||||||
*
|
*
|
||||||
* Displays enlarged image or 360/iframe embed.
|
* Displays enlarged image, video, or 360/iframe embed.
|
||||||
* Positioned absolutely within the canvas (not fullscreen).
|
* Positioned absolutely within the canvas (not fullscreen).
|
||||||
* Supports embed URL validation for security.
|
* Supports embed URL validation for security.
|
||||||
*/
|
*/
|
||||||
@ -16,42 +16,8 @@ import React, {
|
|||||||
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
|
import type { CanvasElement, InfoPanelImage } from '../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||||
import { getFontByKey, getFontStyle } from '../../lib/fonts';
|
import { getFontByKey, getFontStyle } from '../../lib/fonts';
|
||||||
|
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
|
||||||
/**
|
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ImageDetailPanelProps {
|
interface ImageDetailPanelProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -59,6 +25,8 @@ interface ImageDetailPanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
letterboxStyles?: React.CSSProperties;
|
letterboxStyles?: React.CSSProperties;
|
||||||
|
/** CSS custom properties including --cu for canvas units */
|
||||||
|
cssVars?: React.CSSProperties;
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
|
onDetailPositionChange?: (xPercent: number, yPercent: number) => void;
|
||||||
}
|
}
|
||||||
@ -69,10 +37,12 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
resolveUrl,
|
resolveUrl,
|
||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
|
cssVars,
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onDetailPositionChange,
|
onDetailPositionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@ -235,6 +205,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
const isEmbed = image?.itemType === '360' && image?.embedUrl;
|
const isEmbed = image?.itemType === '360' && image?.embedUrl;
|
||||||
const embedUrl = image?.embedUrl ?? '';
|
const embedUrl = image?.embedUrl ?? '';
|
||||||
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
|
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
|
||||||
|
const embedSrc = isValidEmbed ? buildChromeFreeEmbedUrl(embedUrl) : '';
|
||||||
|
const isVideo = image?.itemType === 'video' && image?.videoUrl;
|
||||||
const hasImage = !!image;
|
const hasImage = !!image;
|
||||||
|
|
||||||
// Convert numeric values to canvas units for responsive scaling
|
// Convert numeric values to canvas units for responsive scaling
|
||||||
@ -387,7 +359,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className='fixed inset-0 z-[54] overflow-hidden pointer-events-none'
|
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 */}
|
{/* Detail Panel */}
|
||||||
<div
|
<div
|
||||||
@ -398,7 +373,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{
|
style={{
|
||||||
...panelStyle,
|
...panelStyle,
|
||||||
pointerEvents: 'auto', // Ensure panel receives events
|
pointerEvents: isEditMode ? 'none' : 'auto',
|
||||||
cursor:
|
cursor:
|
||||||
isEditMode && onDetailPositionChange
|
isEditMode && onDetailPositionChange
|
||||||
? isDragging
|
? isDragging
|
||||||
@ -407,12 +382,24 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={
|
|
||||||
isEditMode && onDetailPositionChange ? handleDragStart : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Control buttons - only show for regular images (embeds have their own controls) */}
|
{isEditMode && onDetailPositionChange && (
|
||||||
{!isEmbed && image?.imageUrl && (
|
<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 */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
@ -519,7 +506,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
// Embed iframe
|
// Embed iframe
|
||||||
isValidEmbed ? (
|
isValidEmbed ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={embedUrl}
|
src={embedSrc}
|
||||||
title={image?.caption || 'Embedded content'}
|
title={image?.caption || 'Embedded content'}
|
||||||
className='w-full h-full border-0'
|
className='w-full h-full border-0'
|
||||||
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
|
sandbox='allow-scripts allow-same-origin allow-presentation allow-popups'
|
||||||
@ -555,6 +542,22 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
) : image?.imageUrl ? (
|
||||||
// Regular image
|
// Regular image
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import React, {
|
|||||||
import type {
|
import type {
|
||||||
CanvasElement,
|
CanvasElement,
|
||||||
InfoPanelImage,
|
InfoPanelImage,
|
||||||
|
InfoPanelImageClickAction,
|
||||||
|
InfoPanelLinkClickAction,
|
||||||
InfoPanelSectionInstance,
|
InfoPanelSectionInstance,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
import { getInfoPanelSections } from '../../types/constructor';
|
import { getInfoPanelSections } from '../../types/constructor';
|
||||||
@ -30,8 +32,6 @@ import {
|
|||||||
buildInfoPanelCardStyle,
|
buildInfoPanelCardStyle,
|
||||||
buildInfoPanelCardTitleStyle,
|
buildInfoPanelCardTitleStyle,
|
||||||
buildInfoPanelCardGridStyleWithSection,
|
buildInfoPanelCardGridStyleWithSection,
|
||||||
buildInfoPanelWrapperStyle,
|
|
||||||
buildImagesPreviewStyle,
|
|
||||||
} from '../../lib/infoPanelSectionStyles';
|
} from '../../lib/infoPanelSectionStyles';
|
||||||
|
|
||||||
interface InfoPanelOverlayProps {
|
interface InfoPanelOverlayProps {
|
||||||
@ -43,8 +43,16 @@ interface InfoPanelOverlayProps {
|
|||||||
cssVars?: React.CSSProperties;
|
cssVars?: React.CSSProperties;
|
||||||
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
|
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
|
||||||
onImageClick: (image: InfoPanelImage | null) => void;
|
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;
|
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;
|
isEditMode?: boolean;
|
||||||
/** Callback when panel position changes (edit mode only) */
|
/** Callback when panel position changes (edit mode only) */
|
||||||
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
|
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
|
||||||
@ -52,6 +60,74 @@ interface InfoPanelOverlayProps {
|
|||||||
active360ItemId?: string | null;
|
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> = ({
|
const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||||
element,
|
element,
|
||||||
onClose,
|
onClose,
|
||||||
@ -59,7 +135,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
letterboxStyles,
|
letterboxStyles,
|
||||||
cssVars,
|
cssVars,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
|
onOpenGallery,
|
||||||
onSelectImage,
|
onSelectImage,
|
||||||
|
onNavigateToPage,
|
||||||
|
onOpenExternalUrl,
|
||||||
|
onUseAsBackground,
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onPanelPositionChange,
|
onPanelPositionChange,
|
||||||
active360ItemId,
|
active360ItemId,
|
||||||
@ -77,11 +157,6 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
panelY: number;
|
panelY: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Track selected image per section (local UI state)
|
|
||||||
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// Section styles computed from element settings
|
// Section styles computed from element settings
|
||||||
const headerStyle = useMemo(
|
const headerStyle = useMemo(
|
||||||
() => buildInfoPanelHeaderStyle(element),
|
() => buildInfoPanelHeaderStyle(element),
|
||||||
@ -98,18 +173,106 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
() => buildInfoPanelCardTitleStyle(element),
|
() => buildInfoPanelCardTitleStyle(element),
|
||||||
[element],
|
[element],
|
||||||
);
|
);
|
||||||
const wrapperStyle = useMemo(
|
|
||||||
() => buildInfoPanelWrapperStyle(element),
|
|
||||||
[element],
|
|
||||||
);
|
|
||||||
const imagesPreviewStyle = useMemo(
|
|
||||||
() => buildImagesPreviewStyle(element),
|
|
||||||
[element],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get section instances
|
// Get section instances
|
||||||
const sections = useMemo(() => getInfoPanelSections(element), [element]);
|
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
|
// Fade in animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestAnimationFrame(() => setIsVisible(true));
|
requestAnimationFrame(() => setIsVisible(true));
|
||||||
@ -334,7 +497,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
? 'grabbing'
|
? 'grabbing'
|
||||||
: 'grab'
|
: 'grab'
|
||||||
: undefined,
|
: undefined,
|
||||||
pointerEvents: 'auto', // Ensure panel receives events
|
pointerEvents: isEditMode ? 'none' : 'auto',
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onTouchEnd={(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'
|
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
|
||||||
style={{
|
style={{
|
||||||
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
|
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
|
||||||
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
@ -396,14 +560,30 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
|
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
|
||||||
const headerText =
|
const headerText =
|
||||||
section.headerText ?? element.infoPanelHeaderText;
|
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
|
// Image takes priority, otherwise render text
|
||||||
if (headerImageUrl) {
|
if (headerImageUrl) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={section.id}
|
key={section.id}
|
||||||
|
{...headerClickProps}
|
||||||
style={{
|
style={{
|
||||||
...headerStyle,
|
...getClickableStyle(section, headerStyle),
|
||||||
padding: 0,
|
padding: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
@ -424,7 +604,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
if (headerText) {
|
if (headerText) {
|
||||||
return (
|
return (
|
||||||
<div key={section.id} style={headerStyle}>
|
<div
|
||||||
|
key={section.id}
|
||||||
|
{...headerClickProps}
|
||||||
|
style={getClickableStyle(section, headerStyle)}
|
||||||
|
>
|
||||||
{headerText}
|
{headerText}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -441,12 +625,24 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
key={section.id}
|
key={section.id}
|
||||||
id='info-panel-title'
|
id='info-panel-title'
|
||||||
style={{
|
style={{
|
||||||
...titleStyle,
|
...getClickableStyle(section, titleStyle),
|
||||||
marginTop:
|
marginTop:
|
||||||
isEditMode && onPanelPositionChange
|
isEditMode && onPanelPositionChange
|
||||||
? '16px'
|
? '16px'
|
||||||
: undefined,
|
: 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}
|
{titleContent}
|
||||||
</h2>
|
</h2>
|
||||||
@ -458,7 +654,22 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
const textContent = section.text ?? panelText;
|
const textContent = section.text ?? panelText;
|
||||||
if (!textContent) return null;
|
if (!textContent) return null;
|
||||||
return (
|
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}
|
{textContent}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@ -478,7 +689,18 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div key={section.id} style={spanGridStyle}>
|
<div key={section.id} style={spanGridStyle}>
|
||||||
{sectionSpans.map((span) => (
|
{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 ? (
|
{span.iconUrl ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
@ -494,7 +716,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
span.text
|
span.text
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -527,7 +749,13 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
overflow: 'hidden', // Respect borderRadius on children
|
overflow: 'hidden', // Respect borderRadius on children
|
||||||
}}
|
}}
|
||||||
className='focus:outline-none'
|
className='focus:outline-none'
|
||||||
onClick={() => onImageClick(image)}
|
onClick={() =>
|
||||||
|
handleImageDestination(
|
||||||
|
image,
|
||||||
|
section,
|
||||||
|
sectionImages,
|
||||||
|
)
|
||||||
|
}
|
||||||
aria-label={image.caption || 'View image'}
|
aria-label={image.caption || 'View image'}
|
||||||
>
|
>
|
||||||
{image.itemType === '360' ? (
|
{image.itemType === '360' ? (
|
||||||
@ -549,6 +777,29 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className='text-xs'>360/Embed</span>
|
<span className='text-xs'>360/Embed</span>
|
||||||
</div>
|
</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 ? (
|
) : image.imageUrl ? (
|
||||||
// Regular image thumbnail
|
// Regular image thumbnail
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@ -606,35 +857,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
// Use section-level images
|
// Use section-level images
|
||||||
const sectionImages = section.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
|
// Nothing to show if no items
|
||||||
if (imageItems.length === 0 && triggerItems.length === 0)
|
if (sectionImages.length === 0) return null;
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build grid style with per-section settings
|
// Build grid style with per-section settings
|
||||||
const gridStyle = buildInfoPanelCardGridStyleWithSection(
|
const gridStyle = buildInfoPanelCardGridStyleWithSection(
|
||||||
@ -663,140 +887,137 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
|
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 */}
|
{/* Thumbnail Grid */}
|
||||||
<div style={gridStyle}>
|
<div style={gridStyle}>
|
||||||
{/* Image thumbnails - click to select for preview */}
|
{sectionImages.map((img) => {
|
||||||
{imageItems.map((img) => (
|
const itemType = img.itemType || 'image';
|
||||||
<button
|
const is360 = itemType === '360';
|
||||||
key={img.id}
|
const isVideo = itemType === 'video';
|
||||||
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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 360° trigger buttons - click to open 360° view */}
|
return (
|
||||||
{triggerItems.map((img) => (
|
<button
|
||||||
<button
|
key={img.id}
|
||||||
key={img.id}
|
type='button'
|
||||||
type='button'
|
style={thumbnailStyle}
|
||||||
style={thumbnailStyle}
|
className={
|
||||||
className='trigger-360 focus:outline-none'
|
is360
|
||||||
onClick={() => {
|
? 'trigger-360 focus:outline-none'
|
||||||
// Toggle behavior: if already open, close; otherwise open
|
: isVideo
|
||||||
if (active360ItemId === img.id) {
|
? 'trigger-video focus:outline-none'
|
||||||
onImageClick(null);
|
: 'transition-opacity hover:opacity-100 focus:outline-none'
|
||||||
} else {
|
|
||||||
onImageClick(img);
|
|
||||||
}
|
}
|
||||||
}}
|
onClick={() => {
|
||||||
aria-label={
|
if (!isVideo && !is360) {
|
||||||
active360ItemId === img.id
|
onSelectImage?.(img.id);
|
||||||
? 'Close 360° view'
|
}
|
||||||
: 'Open 360° view'
|
handleImageDestination(
|
||||||
}
|
img,
|
||||||
aria-pressed={active360ItemId === img.id}
|
section,
|
||||||
>
|
sectionImages,
|
||||||
{img.iconUrl ? (
|
);
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
}}
|
||||||
<img
|
aria-label={
|
||||||
src={resolve(img.iconUrl)}
|
is360
|
||||||
alt='360°'
|
? active360ItemId === img.id
|
||||||
style={{
|
? 'Close 360° view'
|
||||||
width: '100%',
|
: 'Open 360° view'
|
||||||
height: '100%',
|
: img.caption ||
|
||||||
objectFit: 'contain',
|
(isVideo ? 'Open video' : 'Select image')
|
||||||
}}
|
}
|
||||||
draggable={false}
|
aria-pressed={
|
||||||
/>
|
is360 ? active360ItemId === img.id : undefined
|
||||||
) : (
|
}
|
||||||
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
>
|
||||||
<svg
|
{is360 ? (
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
img.iconUrl ? (
|
||||||
fill='none'
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
viewBox='0 0 24 24'
|
<img
|
||||||
strokeWidth={1.5}
|
src={resolve(img.iconUrl)}
|
||||||
stroke='currentColor'
|
alt='360°'
|
||||||
className='w-8 h-8 mb-1'
|
style={{
|
||||||
>
|
width: '100%',
|
||||||
<path
|
height: '100%',
|
||||||
strokeLinecap='round'
|
objectFit: 'contain',
|
||||||
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'
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</svg>
|
) : (
|
||||||
<span className='text-xs'>360°</span>
|
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
||||||
</div>
|
<svg
|
||||||
)}
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { CSSProperties } from 'react';
|
|||||||
import type { CanvasElement } from '../../../types/constructor';
|
import type { CanvasElement } from '../../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||||
|
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
|
||||||
|
|
||||||
interface AudioPlayerElementProps {
|
interface AudioPlayerElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -26,6 +27,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
|
|
||||||
// Ducking: pause background audio when this player plays
|
// Ducking: pause background audio when this player plays
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -67,7 +69,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
|||||||
controls
|
controls
|
||||||
autoPlay={Boolean(element.mediaAutoplay)}
|
autoPlay={Boolean(element.mediaAutoplay)}
|
||||||
loop={Boolean(element.mediaLoop)}
|
loop={Boolean(element.mediaLoop)}
|
||||||
muted={Boolean(element.mediaMuted)}
|
muted={Boolean(element.mediaMuted) || isMuted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { CanvasElement } from '../../../types/constructor';
|
|||||||
import type { PreloadCacheProvider } from '../../../hooks/video';
|
import type { PreloadCacheProvider } from '../../../hooks/video';
|
||||||
import { useVideoPlayer } from '../../../hooks/video';
|
import { useVideoPlayer } from '../../../hooks/video';
|
||||||
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||||
|
import { useGlobalAudioMute } from '../../../hooks/useGlobalAudioMute';
|
||||||
|
|
||||||
interface VideoPlayerElementProps {
|
interface VideoPlayerElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -25,12 +26,15 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
|
const effectiveMuted = Boolean(element.mediaMuted) || isMuted;
|
||||||
|
|
||||||
const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({
|
const { videoRef, resolvedUrl, isBuffering } = useVideoPlayer({
|
||||||
sourceUrl: element.mediaUrl,
|
sourceUrl: element.mediaUrl,
|
||||||
preloadCache,
|
preloadCache,
|
||||||
autoplay: Boolean(element.mediaAutoplay),
|
autoplay: Boolean(element.mediaAutoplay),
|
||||||
loop: Boolean(element.mediaLoop),
|
loop: Boolean(element.mediaLoop),
|
||||||
muted: Boolean(element.mediaMuted),
|
muted: effectiveMuted,
|
||||||
trackBuffering: true,
|
trackBuffering: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -80,7 +84,7 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
|||||||
controls
|
controls
|
||||||
autoPlay={Boolean(element.mediaAutoplay)}
|
autoPlay={Boolean(element.mediaAutoplay)}
|
||||||
loop={Boolean(element.mediaLoop)}
|
loop={Boolean(element.mediaLoop)}
|
||||||
muted={Boolean(element.mediaMuted)}
|
muted={effectiveMuted}
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export interface ConstructorContextValue {
|
|||||||
// Background convenience setters
|
// Background convenience setters
|
||||||
setBackgroundImageUrl: (url: string) => void;
|
setBackgroundImageUrl: (url: string) => void;
|
||||||
setBackgroundVideoUrl: (url: string) => void;
|
setBackgroundVideoUrl: (url: string) => void;
|
||||||
|
setBackgroundEmbedUrl: (url: string) => void;
|
||||||
setBackgroundAudioUrl: (url: string) => void;
|
setBackgroundAudioUrl: (url: string) => void;
|
||||||
setBackgroundVideoSettings: (
|
setBackgroundVideoSettings: (
|
||||||
settings: Partial<PageBackgroundVideoSettings>,
|
settings: Partial<PageBackgroundVideoSettings>,
|
||||||
@ -316,6 +317,7 @@ export function useConstructorBackground() {
|
|||||||
updateBackgroundFromPage: ctx.updateBackgroundFromPage,
|
updateBackgroundFromPage: ctx.updateBackgroundFromPage,
|
||||||
setBackgroundImageUrl: ctx.setBackgroundImageUrl,
|
setBackgroundImageUrl: ctx.setBackgroundImageUrl,
|
||||||
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
|
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
|
||||||
|
setBackgroundEmbedUrl: ctx.setBackgroundEmbedUrl,
|
||||||
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
|
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
|
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
|
||||||
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
|
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
|
||||||
@ -326,6 +328,7 @@ export function useConstructorBackground() {
|
|||||||
ctx.updateBackgroundFromPage,
|
ctx.updateBackgroundFromPage,
|
||||||
ctx.setBackgroundImageUrl,
|
ctx.setBackgroundImageUrl,
|
||||||
ctx.setBackgroundVideoUrl,
|
ctx.setBackgroundVideoUrl,
|
||||||
|
ctx.setBackgroundEmbedUrl,
|
||||||
ctx.setBackgroundAudioUrl,
|
ctx.setBackgroundAudioUrl,
|
||||||
ctx.setBackgroundVideoSettings,
|
ctx.setBackgroundVideoSettings,
|
||||||
ctx.setBackgroundAudioSettings,
|
ctx.setBackgroundAudioSettings,
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
|
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch audio file with credentials and return a blob URL.
|
* Fetch audio file with credentials and return a blob URL.
|
||||||
@ -79,10 +80,13 @@ export function useAudioEffects({
|
|||||||
}: UseAudioEffectsOptions): UseAudioEffectsResult {
|
}: UseAudioEffectsOptions): UseAudioEffectsResult {
|
||||||
const hoverAudioRef = useRef<HTMLAudioElement | null>(null);
|
const hoverAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const clickAudioRef = useRef<HTMLAudioElement | null>(null);
|
const clickAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
const hoverBlobUrlRef = useRef<string | null>(null);
|
const hoverBlobUrlRef = useRef<string | null>(null);
|
||||||
const clickBlobUrlRef = useRef<string | null>(null);
|
const clickBlobUrlRef = useRef<string | null>(null);
|
||||||
const wasHoveredRef = useRef(false);
|
const wasHoveredRef = useRef(false);
|
||||||
const wasActiveRef = 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
|
// Track which URLs we've already fetched to prevent duplicate fetches
|
||||||
const lastFetchedHoverUrlRef = useRef<string | null>(null);
|
const lastFetchedHoverUrlRef = useRef<string | null>(null);
|
||||||
@ -240,45 +244,61 @@ export function useAudioEffects({
|
|||||||
// Play hover audio when hover starts (plays to completion, not interrupted)
|
// Play hover audio when hover starts (plays to completion, not interrupted)
|
||||||
// Sound effects layer over background audio (no ducking)
|
// Sound effects layer over background audio (no ducking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hoverStateInitializedRef.current || isMuted) {
|
||||||
|
hoverStateInitializedRef.current = true;
|
||||||
|
wasHoveredRef.current = isHovered;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const justEntered = isHovered && !wasHoveredRef.current;
|
||||||
|
wasHoveredRef.current = isHovered;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isHovered &&
|
justEntered &&
|
||||||
!wasHoveredRef.current &&
|
|
||||||
hoverAudioRef.current &&
|
hoverAudioRef.current &&
|
||||||
hoverAudioReady &&
|
hoverAudioReady &&
|
||||||
backgroundAudioController.hasInteracted()
|
backgroundAudioController.hasInteracted() &&
|
||||||
|
!isMuted
|
||||||
) {
|
) {
|
||||||
const audio = hoverAudioRef.current;
|
const audio = hoverAudioRef.current;
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => undefined);
|
audio.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
wasHoveredRef.current = isHovered;
|
}, [isHovered, hoverAudioReady, isMuted]);
|
||||||
}, [isHovered, hoverAudioReady]);
|
|
||||||
|
|
||||||
// Play click audio when active state begins
|
// Play click audio when active state begins
|
||||||
// Sound effects layer over background audio (no ducking)
|
// Sound effects layer over background audio (no ducking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeStateInitializedRef.current || isMuted) {
|
||||||
|
activeStateInitializedRef.current = true;
|
||||||
|
wasActiveRef.current = isActive;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const justActivated = isActive && !wasActiveRef.current;
|
||||||
|
wasActiveRef.current = isActive;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isActive &&
|
justActivated &&
|
||||||
!wasActiveRef.current &&
|
|
||||||
clickAudioRef.current &&
|
clickAudioRef.current &&
|
||||||
clickAudioReady &&
|
clickAudioReady &&
|
||||||
backgroundAudioController.hasInteracted()
|
backgroundAudioController.hasInteracted() &&
|
||||||
|
!isMuted
|
||||||
) {
|
) {
|
||||||
const audio = clickAudioRef.current;
|
const audio = clickAudioRef.current;
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
audio.play().catch(() => undefined);
|
audio.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
wasActiveRef.current = isActive;
|
}, [isActive, clickAudioReady, isMuted]);
|
||||||
}, [isActive, clickAudioReady]);
|
|
||||||
|
|
||||||
// Manual click audio trigger
|
// Manual click audio trigger
|
||||||
const playClickAudio = useCallback(() => {
|
const playClickAudio = useCallback(() => {
|
||||||
if (clickAudioRef.current && clickAudioReady) {
|
if (clickAudioRef.current && clickAudioReady && !isMuted) {
|
||||||
clickAudioRef.current.currentTime = 0;
|
clickAudioRef.current.currentTime = 0;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
clickAudioRef.current.play().catch(() => {});
|
clickAudioRef.current.play().catch(() => {});
|
||||||
}
|
}
|
||||||
}, [clickAudioReady]);
|
}, [clickAudioReady, isMuted]);
|
||||||
|
|
||||||
const stopAll = useCallback(() => {
|
const stopAll = useCallback(() => {
|
||||||
if (hoverAudioRef.current) {
|
if (hoverAudioRef.current) {
|
||||||
@ -296,7 +316,15 @@ export function useAudioEffects({
|
|||||||
stopAll();
|
stopAll();
|
||||||
wasHoveredRef.current = false;
|
wasHoveredRef.current = false;
|
||||||
wasActiveRef.current = false;
|
wasActiveRef.current = false;
|
||||||
|
hoverStateInitializedRef.current = false;
|
||||||
|
activeStateInitializedRef.current = false;
|
||||||
}, [resetKey, stopAll]);
|
}, [resetKey, stopAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMuted) {
|
||||||
|
stopAll();
|
||||||
|
}
|
||||||
|
}, [isMuted, stopAll]);
|
||||||
|
|
||||||
return { playClickAudio, stopAll };
|
return { playClickAudio, stopAll };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useEffect, useRef, useCallback, type RefObject } from 'react';
|
|||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { useAudioEventManager } from './audio/useAudioEventManager';
|
import { useAudioEventManager } from './audio/useAudioEventManager';
|
||||||
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
|
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||||
|
|
||||||
// Session-scoped tracking of audio that has finished playing (when loop=false)
|
// Session-scoped tracking of audio that has finished playing (when loop=false)
|
||||||
// Key: audioUrl, cleared on browser refresh
|
// Key: audioUrl, cleared on browser refresh
|
||||||
@ -66,6 +67,8 @@ export function useBackgroundAudioPlayback({
|
|||||||
paused = false,
|
paused = false,
|
||||||
}: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult {
|
}: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const { isMuted } = useGlobalAudioMute();
|
||||||
|
const effectivePaused = paused || isMuted;
|
||||||
|
|
||||||
// Use storage path for tracking (stable across blob URL changes)
|
// Use storage path for tracking (stable across blob URL changes)
|
||||||
// Falls back to audioUrl if no storage path provided
|
// Falls back to audioUrl if no storage path provided
|
||||||
@ -87,9 +90,9 @@ export function useBackgroundAudioPlayback({
|
|||||||
startTimeRef.current = startTime;
|
startTimeRef.current = startTime;
|
||||||
endTimeRef.current = endTime;
|
endTimeRef.current = endTime;
|
||||||
loopRef.current = loop;
|
loopRef.current = loop;
|
||||||
pausedRef.current = paused;
|
pausedRef.current = effectivePaused;
|
||||||
trackingKeyRef.current = trackingKey;
|
trackingKeyRef.current = trackingKey;
|
||||||
}, [startTime, endTime, loop, paused, trackingKey]);
|
}, [startTime, endTime, loop, effectivePaused, trackingKey]);
|
||||||
|
|
||||||
// Register audio element with background audio controller for ducking
|
// Register audio element with background audio controller for ducking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -180,7 +183,7 @@ export function useBackgroundAudioPlayback({
|
|||||||
if (!audio || !audioUrl) return;
|
if (!audio || !audioUrl) return;
|
||||||
|
|
||||||
// External pause takes precedence over autoplay
|
// External pause takes precedence over autoplay
|
||||||
if (paused) {
|
if (effectivePaused) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
backgroundAudioController.setWaitingForInteraction(false);
|
backgroundAudioController.setWaitingForInteraction(false);
|
||||||
return;
|
return;
|
||||||
@ -194,7 +197,7 @@ export function useBackgroundAudioPlayback({
|
|||||||
audio.pause();
|
audio.pause();
|
||||||
backgroundAudioController.setWaitingForInteraction(false);
|
backgroundAudioController.setWaitingForInteraction(false);
|
||||||
}
|
}
|
||||||
}, [audioUrl, paused, shouldBlockAutoplay]);
|
}, [audioUrl, effectivePaused, shouldBlockAutoplay]);
|
||||||
|
|
||||||
// Session-scoped "play once" behavior when loop is disabled
|
// Session-scoped "play once" behavior when loop is disabled
|
||||||
// Audio that has already played stops on revisit
|
// Audio that has already played stops on revisit
|
||||||
|
|||||||
@ -526,9 +526,13 @@ export function useConstructorElements({
|
|||||||
...(sectionType === 'cards' && {
|
...(sectionType === 'cards' && {
|
||||||
columns: 2,
|
columns: 2,
|
||||||
gap: '8',
|
gap: '8',
|
||||||
|
mediaOpenMode: 'panel',
|
||||||
|
images: [],
|
||||||
|
}),
|
||||||
|
...(sectionType === 'images' && {
|
||||||
|
mediaOpenMode: 'panel',
|
||||||
images: [],
|
images: [],
|
||||||
}),
|
}),
|
||||||
...(sectionType === 'images' && { images: [] }),
|
|
||||||
};
|
};
|
||||||
return { infoPanelSections: [...currentSections, newSection] };
|
return { infoPanelSections: [...currentSections, newSection] };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,6 +24,7 @@ interface TourPage {
|
|||||||
ui_schema_json?: string;
|
ui_schema_json?: string;
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_loop?: boolean;
|
background_loop?: boolean;
|
||||||
// Background video playback settings
|
// Background video playback settings
|
||||||
@ -128,6 +129,7 @@ export function useConstructorPageActions({
|
|||||||
const {
|
const {
|
||||||
imageUrl: backgroundImageUrl,
|
imageUrl: backgroundImageUrl,
|
||||||
videoUrl: backgroundVideoUrl,
|
videoUrl: backgroundVideoUrl,
|
||||||
|
embedUrl: backgroundEmbedUrl,
|
||||||
audioUrl: backgroundAudioUrl,
|
audioUrl: backgroundAudioUrl,
|
||||||
videoSettings: {
|
videoSettings: {
|
||||||
autoplay: backgroundVideoAutoplay,
|
autoplay: backgroundVideoAutoplay,
|
||||||
@ -209,6 +211,7 @@ export function useConstructorPageActions({
|
|||||||
ui_schema_json: schemaToSave,
|
ui_schema_json: schemaToSave,
|
||||||
background_image_url: backgroundImageUrl,
|
background_image_url: backgroundImageUrl,
|
||||||
background_video_url: backgroundVideoUrl,
|
background_video_url: backgroundVideoUrl,
|
||||||
|
background_embed_url: backgroundEmbedUrl,
|
||||||
background_audio_url: backgroundAudioUrl,
|
background_audio_url: backgroundAudioUrl,
|
||||||
background_loop: Boolean(backgroundAudioUrl),
|
background_loop: Boolean(backgroundAudioUrl),
|
||||||
background_video_autoplay: backgroundVideoAutoplay,
|
background_video_autoplay: backgroundVideoAutoplay,
|
||||||
@ -335,6 +338,7 @@ export function useConstructorPageActions({
|
|||||||
sort_order: maxSortOrder + 1,
|
sort_order: maxSortOrder + 1,
|
||||||
background_image_url: '',
|
background_image_url: '',
|
||||||
background_video_url: '',
|
background_video_url: '',
|
||||||
|
background_embed_url: '',
|
||||||
background_audio_url: '',
|
background_audio_url: '',
|
||||||
background_loop: false,
|
background_loop: false,
|
||||||
requires_auth: 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:
|
* Replaces:
|
||||||
* - backgroundImageUrl, setBackgroundImageUrl
|
* - backgroundImageUrl, setBackgroundImageUrl
|
||||||
* - backgroundVideoUrl, setBackgroundVideoUrl
|
* - backgroundVideoUrl, setBackgroundVideoUrl
|
||||||
|
* - backgroundEmbedUrl, setBackgroundEmbedUrl
|
||||||
* - backgroundAudioUrl, setBackgroundAudioUrl
|
* - backgroundAudioUrl, setBackgroundAudioUrl
|
||||||
* - backgroundVideoAutoplay, setBackgroundVideoAutoplay
|
* - backgroundVideoAutoplay, setBackgroundVideoAutoplay
|
||||||
* - backgroundVideoLoop, setBackgroundVideoLoop
|
* - backgroundVideoLoop, setBackgroundVideoLoop
|
||||||
@ -29,6 +30,7 @@ import {
|
|||||||
interface TourPageData {
|
interface TourPageData {
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_video_autoplay?: boolean;
|
background_video_autoplay?: boolean;
|
||||||
background_video_loop?: boolean;
|
background_video_loop?: boolean;
|
||||||
@ -59,6 +61,7 @@ export interface UsePageBackgroundResult {
|
|||||||
/** Update individual URL */
|
/** Update individual URL */
|
||||||
setImageUrl: (url: string) => void;
|
setImageUrl: (url: string) => void;
|
||||||
setVideoUrl: (url: string) => void;
|
setVideoUrl: (url: string) => void;
|
||||||
|
setEmbedUrl: (url: string) => void;
|
||||||
setAudioUrl: (url: string) => void;
|
setAudioUrl: (url: string) => void;
|
||||||
|
|
||||||
/** Update video settings */
|
/** Update video settings */
|
||||||
@ -74,6 +77,7 @@ export interface UsePageBackgroundResult {
|
|||||||
// These allow gradual migration of components
|
// These allow gradual migration of components
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
backgroundVideoUrl: string;
|
backgroundVideoUrl: string;
|
||||||
|
backgroundEmbedUrl: string;
|
||||||
backgroundAudioUrl: string;
|
backgroundAudioUrl: string;
|
||||||
backgroundVideoAutoplay: boolean;
|
backgroundVideoAutoplay: boolean;
|
||||||
backgroundVideoLoop: 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) => {
|
const setAudioUrl = useCallback((url: string) => {
|
||||||
setBackground((prev) => ({
|
setBackground((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -181,6 +192,7 @@ export function usePageBackground(
|
|||||||
updateFromPage,
|
updateFromPage,
|
||||||
setImageUrl,
|
setImageUrl,
|
||||||
setVideoUrl,
|
setVideoUrl,
|
||||||
|
setEmbedUrl,
|
||||||
setAudioUrl,
|
setAudioUrl,
|
||||||
setVideoSettings,
|
setVideoSettings,
|
||||||
setAudioSettings,
|
setAudioSettings,
|
||||||
@ -189,6 +201,7 @@ export function usePageBackground(
|
|||||||
// Legacy compatibility: flat values for gradual migration
|
// Legacy compatibility: flat values for gradual migration
|
||||||
backgroundImageUrl: background.imageUrl,
|
backgroundImageUrl: background.imageUrl,
|
||||||
backgroundVideoUrl: background.videoUrl,
|
backgroundVideoUrl: background.videoUrl,
|
||||||
|
backgroundEmbedUrl: background.embedUrl,
|
||||||
backgroundAudioUrl: background.audioUrl,
|
backgroundAudioUrl: background.audioUrl,
|
||||||
backgroundVideoAutoplay: background.videoSettings.autoplay,
|
backgroundVideoAutoplay: background.videoSettings.autoplay,
|
||||||
backgroundVideoLoop: background.videoSettings.loop,
|
backgroundVideoLoop: background.videoSettings.loop,
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export interface NavigablePage {
|
|||||||
id: string;
|
id: string;
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ interface NavigationState {
|
|||||||
// Current page URLs (resolved for display)
|
// Current page URLs (resolved for display)
|
||||||
currentImageUrl: string;
|
currentImageUrl: string;
|
||||||
currentVideoUrl: string;
|
currentVideoUrl: string;
|
||||||
|
currentEmbedUrl: string;
|
||||||
currentAudioUrl: string;
|
currentAudioUrl: string;
|
||||||
|
|
||||||
// Previous page URLs (for overlay during transition)
|
// Previous page URLs (for overlay during transition)
|
||||||
@ -124,6 +126,7 @@ type NavigationAction =
|
|||||||
payload: {
|
payload: {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
embedUrl: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -137,6 +140,7 @@ type NavigationAction =
|
|||||||
payload: {
|
payload: {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
embedUrl: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -153,6 +157,7 @@ const initialState: NavigationState = {
|
|||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
currentImageUrl: '',
|
currentImageUrl: '',
|
||||||
currentVideoUrl: '',
|
currentVideoUrl: '',
|
||||||
|
currentEmbedUrl: '',
|
||||||
currentAudioUrl: '',
|
currentAudioUrl: '',
|
||||||
previousImageUrl: '',
|
previousImageUrl: '',
|
||||||
previousVideoUrl: '',
|
previousVideoUrl: '',
|
||||||
@ -205,6 +210,7 @@ function navigationReducer(
|
|||||||
...state,
|
...state,
|
||||||
currentImageUrl: action.payload.imageUrl,
|
currentImageUrl: action.payload.imageUrl,
|
||||||
currentVideoUrl: action.payload.videoUrl,
|
currentVideoUrl: action.payload.videoUrl,
|
||||||
|
currentEmbedUrl: action.payload.embedUrl,
|
||||||
currentAudioUrl: action.payload.audioUrl,
|
currentAudioUrl: action.payload.audioUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -258,6 +264,7 @@ function navigationReducer(
|
|||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
currentImageUrl: action.payload.imageUrl,
|
currentImageUrl: action.payload.imageUrl,
|
||||||
currentVideoUrl: action.payload.videoUrl,
|
currentVideoUrl: action.payload.videoUrl,
|
||||||
|
currentEmbedUrl: action.payload.embedUrl,
|
||||||
currentAudioUrl: action.payload.audioUrl,
|
currentAudioUrl: action.payload.audioUrl,
|
||||||
previousImageUrl: '',
|
previousImageUrl: '',
|
||||||
previousVideoUrl: '',
|
previousVideoUrl: '',
|
||||||
@ -320,6 +327,7 @@ export interface UsePageNavigationStateResult {
|
|||||||
// Current page URLs (for display)
|
// Current page URLs (for display)
|
||||||
currentImageUrl: string;
|
currentImageUrl: string;
|
||||||
currentVideoUrl: string;
|
currentVideoUrl: string;
|
||||||
|
currentEmbedUrl: string;
|
||||||
currentAudioUrl: string;
|
currentAudioUrl: string;
|
||||||
|
|
||||||
// Previous page URLs (for overlay)
|
// Previous page URLs (for overlay)
|
||||||
@ -374,6 +382,7 @@ export interface UsePageNavigationStateResult {
|
|||||||
setBackgroundDirectly: (
|
setBackgroundDirectly: (
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
videoUrl: string,
|
videoUrl: string,
|
||||||
|
embedUrl: string,
|
||||||
audioUrl: string,
|
audioUrl: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
@ -662,23 +671,27 @@ export function usePageNavigationState(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Resolve URLs (may be async)
|
// 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),
|
resolveToDisplayUrl(targetPage.background_image_url),
|
||||||
resolveMediaUrl(targetPage.background_video_url),
|
resolveMediaUrl(targetPage.background_video_url),
|
||||||
|
resolveMediaUrl(targetPage.background_embed_url),
|
||||||
resolveMediaUrl(targetPage.background_audio_url),
|
resolveMediaUrl(targetPage.background_audio_url),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update current URLs
|
// Update current URLs
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'URLS_RESOLVED',
|
type: 'URLS_RESOLVED',
|
||||||
payload: { imageUrl, videoUrl, audioUrl },
|
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify caller
|
// Notify caller
|
||||||
onSwitched?.();
|
onSwitched?.();
|
||||||
|
|
||||||
// For blob URLs, decode image before marking ready
|
// For blob URLs, decode image before marking ready
|
||||||
if (!hasTransition && (imageUrl.startsWith('blob:') || !imageUrl)) {
|
if (
|
||||||
|
!hasTransition &&
|
||||||
|
(embedUrl || imageUrl.startsWith('blob:') || !imageUrl)
|
||||||
|
) {
|
||||||
decodeImage(imageUrl).then(() => {
|
decodeImage(imageUrl).then(() => {
|
||||||
dispatch({ type: 'BACKGROUND_READY' });
|
dispatch({ type: 'BACKGROUND_READY' });
|
||||||
});
|
});
|
||||||
@ -705,10 +718,15 @@ export function usePageNavigationState(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setBackgroundDirectly = useCallback(
|
const setBackgroundDirectly = useCallback(
|
||||||
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
(
|
||||||
|
imageUrl: string,
|
||||||
|
videoUrl: string,
|
||||||
|
embedUrl: string,
|
||||||
|
audioUrl: string,
|
||||||
|
) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SET_BACKGROUND_DIRECTLY',
|
type: 'SET_BACKGROUND_DIRECTLY',
|
||||||
payload: { imageUrl, videoUrl, audioUrl },
|
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@ -834,6 +852,7 @@ export function usePageNavigationState(
|
|||||||
// Current page URLs
|
// Current page URLs
|
||||||
currentImageUrl: state.currentImageUrl,
|
currentImageUrl: state.currentImageUrl,
|
||||||
currentVideoUrl: state.currentVideoUrl,
|
currentVideoUrl: state.currentVideoUrl,
|
||||||
|
currentEmbedUrl: state.currentEmbedUrl,
|
||||||
currentAudioUrl: state.currentAudioUrl,
|
currentAudioUrl: state.currentAudioUrl,
|
||||||
|
|
||||||
// Previous page URLs
|
// Previous page URLs
|
||||||
|
|||||||
@ -1,26 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* useVideoSoundControl Hook
|
* useVideoSoundControl Hook
|
||||||
*
|
*
|
||||||
* Manages video sound state with iOS autoplay compatibility.
|
* Manages global presentation sound state with autoplay compatibility.
|
||||||
* Videos start muted to ensure autoplay works on iOS WebKit browsers,
|
* Presentation audio starts muted so browsers can autoplay visual media,
|
||||||
* then can be unmuted by user interaction.
|
* then can be unmuted by user interaction from RuntimeControls.
|
||||||
*
|
*
|
||||||
* iOS WebKit autoplay policy:
|
* Browser autoplay policy:
|
||||||
* - Videos CAN autoplay if they have `playsinline` AND `muted` attributes
|
* - Muted media can autoplay in most browsers
|
||||||
* - Unmuted videos require user interaction → native play button appears
|
* - Unmuted audio/video usually requires user interaction
|
||||||
* - By forcing muted start + custom sound button, we avoid native controls
|
* - A custom sound button provides that interaction and keeps media controls stable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { logger } from '../lib/logger';
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
|
import { useGlobalAudioMute } from './useGlobalAudioMute';
|
||||||
|
|
||||||
export interface UseVideoSoundControlOptions {
|
export interface UseVideoSoundControlOptions {
|
||||||
/** Whether page settings allow sound (background_video_muted === false) */
|
/** Whether page settings allow sound (background_video_muted === false) */
|
||||||
pageHasSound: boolean;
|
pageHasSound: boolean;
|
||||||
/** Whether page has a background video */
|
/** Whether page has a background video */
|
||||||
hasBackgroundVideo: boolean;
|
hasBackgroundVideo: boolean;
|
||||||
/** Current video URL - used to detect page changes (optional) */
|
/** Whether page has background/ambient audio */
|
||||||
videoUrl?: string;
|
hasBackgroundAudio?: boolean;
|
||||||
|
/** Whether page elements have hover/click audio effects or media player sound */
|
||||||
|
hasElementAudio?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseVideoSoundControlResult {
|
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
|
* @example
|
||||||
* const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({
|
* const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({
|
||||||
@ -50,50 +53,29 @@ export interface UseVideoSoundControlResult {
|
|||||||
* onSoundToggle={toggleSound}
|
* onSoundToggle={toggleSound}
|
||||||
* />
|
* />
|
||||||
*
|
*
|
||||||
* // Pass to CanvasBackground (always muted for autoplay)
|
* // Pass to CanvasBackground and native media elements
|
||||||
* <CanvasBackground videoMuted={isMuted} />
|
* <CanvasBackground videoMuted={isMuted} />
|
||||||
*/
|
*/
|
||||||
export function useVideoSoundControl({
|
export function useVideoSoundControl({
|
||||||
pageHasSound,
|
pageHasSound,
|
||||||
hasBackgroundVideo,
|
hasBackgroundVideo,
|
||||||
videoUrl,
|
hasBackgroundAudio = false,
|
||||||
|
hasElementAudio = false,
|
||||||
}: UseVideoSoundControlOptions): UseVideoSoundControlResult {
|
}: UseVideoSoundControlOptions): UseVideoSoundControlResult {
|
||||||
// Always start muted for iOS autoplay compatibility
|
const { isMuted } = useGlobalAudioMute();
|
||||||
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 toggleSound = useCallback(() => {
|
const toggleSound = useCallback(() => {
|
||||||
setIsMuted((prev) => {
|
backgroundAudioController.toggleMuted();
|
||||||
logger.debug('[useVideoSoundControl] Toggle sound:', {
|
|
||||||
from: prev,
|
|
||||||
to: !prev,
|
|
||||||
});
|
|
||||||
return !prev;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMuted,
|
isMuted,
|
||||||
// Show button only if page allows sound AND has a background video
|
showSoundButton:
|
||||||
showSoundButton: pageHasSound && hasBackgroundVideo,
|
(pageHasSound && hasBackgroundVideo) ||
|
||||||
|
hasBackgroundAudio ||
|
||||||
|
hasElementAudio,
|
||||||
toggleSound,
|
toggleSound,
|
||||||
setMuted: setIsMuted,
|
setMuted: (muted) => backgroundAudioController.setMuted(muted),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,15 +19,29 @@ class BackgroundAudioController {
|
|||||||
|
|
||||||
private waitingForInteraction = false;
|
private waitingForInteraction = false;
|
||||||
private hasUserInteracted = 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 {
|
register(audio: HTMLAudioElement | null): void {
|
||||||
this.audioElement = audio;
|
this.audioElement = audio;
|
||||||
this.waitingForInteraction = false;
|
this.waitingForInteraction = false;
|
||||||
|
if (audio && this.muted) {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setWaitingForInteraction(waiting: boolean): void {
|
setWaitingForInteraction(waiting: boolean): void {
|
||||||
this.waitingForInteraction = waiting;
|
this.waitingForInteraction = waiting;
|
||||||
if (waiting && this.hasUserInteracted && this.audioElement) {
|
if (
|
||||||
|
waiting &&
|
||||||
|
this.hasUserInteracted &&
|
||||||
|
!this.muted &&
|
||||||
|
this.audioElement
|
||||||
|
) {
|
||||||
this.audioElement.play().catch(() => undefined);
|
this.audioElement.play().catch(() => undefined);
|
||||||
this.waitingForInteraction = false;
|
this.waitingForInteraction = false;
|
||||||
}
|
}
|
||||||
@ -37,7 +51,7 @@ class BackgroundAudioController {
|
|||||||
if (this.hasUserInteracted) return;
|
if (this.hasUserInteracted) return;
|
||||||
this.hasUserInteracted = true;
|
this.hasUserInteracted = true;
|
||||||
|
|
||||||
if (this.waitingForInteraction && this.audioElement) {
|
if (this.waitingForInteraction && !this.muted && this.audioElement) {
|
||||||
this.audioElement.play().catch(() => undefined);
|
this.audioElement.play().catch(() => undefined);
|
||||||
this.waitingForInteraction = false;
|
this.waitingForInteraction = false;
|
||||||
}
|
}
|
||||||
@ -61,11 +75,48 @@ class BackgroundAudioController {
|
|||||||
if (this.foregroundCount > 0) {
|
if (this.foregroundCount > 0) {
|
||||||
this.foregroundCount--;
|
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);
|
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 {
|
reset(): void {
|
||||||
this.foregroundCount = 0;
|
this.foregroundCount = 0;
|
||||||
this.wasPaused = false;
|
this.wasPaused = false;
|
||||||
|
|||||||
@ -11,9 +11,16 @@ import type {
|
|||||||
GalleryCard,
|
GalleryCard,
|
||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
|
InfoPanelImageClickAction,
|
||||||
InfoPanelImage,
|
InfoPanelImage,
|
||||||
|
InfoPanelInfoSpan,
|
||||||
|
InfoPanelLinkClickAction,
|
||||||
|
InfoPanelMediaOpenMode,
|
||||||
|
InfoPanelSectionInstance,
|
||||||
|
InfoPanelSectionType,
|
||||||
NavigationButtonKind,
|
NavigationButtonKind,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
|
import { DEFAULT_INFO_PANEL_SECTIONS } from '../types/constructor';
|
||||||
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
import { ELEMENT_STYLE_PROPS } from './elementStyles';
|
||||||
import { GALLERY_SECTION_STYLE_PROPS } from './gallerySectionStyles';
|
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 {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...typeDefaults,
|
...typeDefaults,
|
||||||
@ -294,13 +313,172 @@ export const normalizeCarouselSlide = (
|
|||||||
*/
|
*/
|
||||||
export const normalizeInfoPanelImage = (
|
export const normalizeInfoPanelImage = (
|
||||||
image: Record<string, unknown>,
|
image: Record<string, unknown>,
|
||||||
): InfoPanelImage => ({
|
): InfoPanelImage => {
|
||||||
id: String(image?.id || createLocalId()),
|
const clickAction =
|
||||||
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
|
image.clickAction === 'target_page' ||
|
||||||
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
|
image.clickAction === 'external_url' ||
|
||||||
caption: image?.caption ? String(image.caption) : undefined,
|
image.clickAction === 'panel' ||
|
||||||
itemType: (image?.itemType as 'image' | '360') || 'image',
|
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.
|
* 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;
|
return merged;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -464,6 +663,12 @@ export const parseElementSettings = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(settings.infoPanelSections)) {
|
||||||
|
settings.infoPanelSections = normalizeInfoPanelSections(
|
||||||
|
settings.infoPanelSections,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return settings as Partial<CanvasElement>;
|
return settings as Partial<CanvasElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -711,6 +916,11 @@ export const buildElementSettings = (
|
|||||||
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
|
addIfNotEmpty(settings, 'detailBorderRadius', element.detailBorderRadius);
|
||||||
addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
|
addIfNotEmpty(settings, 'detailPadding', element.detailPadding);
|
||||||
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
|
addIfNotEmpty(settings, 'detailOverlayColor', element.detailOverlayColor);
|
||||||
|
if (Array.isArray(element.infoPanelSections)) {
|
||||||
|
settings.infoPanelSections = normalizeInfoPanelSections(
|
||||||
|
element.infoPanelSections,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings;
|
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;
|
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.
|
* 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
|
// Build synthetic page links for navigation elements and Info Panel targets
|
||||||
const targetSlug =
|
const targetSlugs = collectTargetPageSlugs(el);
|
||||||
el.targetPageSlug && typeof el.targetPageSlug === 'string'
|
|
||||||
? el.targetPageSlug
|
|
||||||
: '';
|
|
||||||
const legacyTargetId =
|
const legacyTargetId =
|
||||||
el.targetPageId && typeof el.targetPageId === 'string'
|
el.targetPageId && typeof el.targetPageId === 'string'
|
||||||
? el.targetPageId
|
? el.targetPageId
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
|
targetSlugs.forEach((targetSlug, index) => {
|
||||||
let resolvedTargetPageId = '';
|
addPageLink(
|
||||||
if (targetSlug) {
|
pageLinks,
|
||||||
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
page,
|
||||||
} else if (legacyTargetId) {
|
el,
|
||||||
|
targetSlug,
|
||||||
|
slugToIdMap,
|
||||||
|
`${preloadElements.length}-${index}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetSlugs.length === 0 && legacyTargetId) {
|
||||||
// Legacy: targetPageId might be a slug or an ID
|
// Legacy: targetPageId might be a slug or an ID
|
||||||
resolvedTargetPageId =
|
const legacySlug = slugToIdMap.has(legacyTargetId)
|
||||||
slugToIdMap.get(legacyTargetId) || legacyTargetId;
|
? legacyTargetId
|
||||||
}
|
: '';
|
||||||
|
if (legacySlug) {
|
||||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
addPageLink(
|
||||||
const hasTransitionVideo =
|
pageLinks,
|
||||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
page,
|
||||||
const hasReverseVideo =
|
el,
|
||||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
legacySlug,
|
||||||
|
slugToIdMap,
|
||||||
pageLinks.push({
|
`${preloadElements.length}-legacy`,
|
||||||
id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
|
);
|
||||||
from_pageId: page.id,
|
} else if (legacyTargetId && legacyTargetId !== page.id) {
|
||||||
to_pageId: resolvedTargetPageId,
|
pageLinks.push({
|
||||||
is_active: true,
|
id: `synthetic-${page.id}-${el.id || preloadElements.length}-legacy`,
|
||||||
transition:
|
from_pageId: page.id,
|
||||||
hasTransitionVideo || hasReverseVideo
|
to_pageId: legacyTargetId,
|
||||||
? {
|
is_active: true,
|
||||||
id: `transition-${el.id || preloadElements.length}`,
|
});
|
||||||
video_url: hasTransitionVideo
|
}
|
||||||
? (el.transitionVideoUrl as string)
|
|
||||||
: undefined,
|
|
||||||
reverse_video_url: hasReverseVideo
|
|
||||||
? (el.reverseVideoUrl as string)
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -226,50 +355,32 @@ export function extractPageLinksOnly(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
pageElements.forEach((el) => {
|
pageElements.forEach((el) => {
|
||||||
// Build synthetic page link for navigation elements
|
// Build synthetic page links for navigation elements and Info Panel targets
|
||||||
const targetSlug =
|
const targetSlugs = collectTargetPageSlugs(el);
|
||||||
el.targetPageSlug && typeof el.targetPageSlug === 'string'
|
|
||||||
? el.targetPageSlug
|
|
||||||
: '';
|
|
||||||
const legacyTargetId =
|
const legacyTargetId =
|
||||||
el.targetPageId && typeof el.targetPageId === 'string'
|
el.targetPageId && typeof el.targetPageId === 'string'
|
||||||
? el.targetPageId
|
? el.targetPageId
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
|
targetSlugs.forEach((targetSlug, index) => {
|
||||||
let resolvedTargetPageId = '';
|
addPageLink(pageLinks, page, el, targetSlug, slugToIdMap, `${index}`);
|
||||||
if (targetSlug) {
|
});
|
||||||
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
|
||||||
} else if (legacyTargetId) {
|
if (targetSlugs.length === 0 && legacyTargetId) {
|
||||||
// Legacy: targetPageId might be a slug or an ID
|
// Legacy: targetPageId might be a slug or an ID
|
||||||
resolvedTargetPageId =
|
const legacySlug = slugToIdMap.has(legacyTargetId)
|
||||||
slugToIdMap.get(legacyTargetId) || legacyTargetId;
|
? legacyTargetId
|
||||||
}
|
: '';
|
||||||
|
if (legacySlug) {
|
||||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
addPageLink(pageLinks, page, el, legacySlug, slugToIdMap, 'legacy');
|
||||||
const hasTransitionVideo =
|
} else if (legacyTargetId && legacyTargetId !== page.id) {
|
||||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
pageLinks.push({
|
||||||
const hasReverseVideo =
|
id: `synthetic-${page.id}-${el.id || 'legacy'}-legacy`,
|
||||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
from_pageId: page.id,
|
||||||
|
to_pageId: legacyTargetId,
|
||||||
pageLinks.push({
|
is_active: true,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -163,6 +163,28 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
|
|||||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
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(
|
export function buildImagesPreviewStyle(
|
||||||
element: Partial<CanvasElement>,
|
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(
|
export function buildImagesThumbnailStripStyle(
|
||||||
element: Partial<CanvasElement>,
|
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.
|
* Uses section instance settings (columns, gap) if available, falls back to element settings.
|
||||||
* When columns is set, uses grid layout; otherwise uses flex layout.
|
* 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(
|
export function buildImagesThumbnailStyle(
|
||||||
element: Partial<CanvasElement>,
|
element: Partial<CanvasElement>,
|
||||||
@ -872,7 +872,7 @@ export const INFO_PANEL_SECTION_STYLE_PROPS = [
|
|||||||
'panelPadding',
|
'panelPadding',
|
||||||
'panelBorderRadius',
|
'panelBorderRadius',
|
||||||
'panelBackdropBlur',
|
'panelBackdropBlur',
|
||||||
// Images section
|
// Media section
|
||||||
'infoPanelSelectedImageId',
|
'infoPanelSelectedImageId',
|
||||||
'infoPanelImagesPreviewHeight',
|
'infoPanelImagesPreviewHeight',
|
||||||
'infoPanelImagesThumbnailSize',
|
'infoPanelImagesThumbnailSize',
|
||||||
|
|||||||
@ -76,6 +76,7 @@ import type {
|
|||||||
GalleryInfoSpan,
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
InfoPanelImage,
|
InfoPanelImage,
|
||||||
|
GalleryCarouselMediaItem,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import type { TourPage } from '../types/entities';
|
import type { TourPage } from '../types/entities';
|
||||||
|
|
||||||
@ -208,12 +209,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateFromPage: updateBackgroundFromPage,
|
updateFromPage: updateBackgroundFromPage,
|
||||||
setImageUrl: setBackgroundImageUrl,
|
setImageUrl: setBackgroundImageUrl,
|
||||||
setVideoUrl: setBackgroundVideoUrl,
|
setVideoUrl: setBackgroundVideoUrl,
|
||||||
|
setEmbedUrl: setBackgroundEmbedUrl,
|
||||||
setAudioUrl: setBackgroundAudioUrl,
|
setAudioUrl: setBackgroundAudioUrl,
|
||||||
setVideoSettings: setBackgroundVideoSettings,
|
setVideoSettings: setBackgroundVideoSettings,
|
||||||
setAudioSettings: setBackgroundAudioSettings,
|
setAudioSettings: setBackgroundAudioSettings,
|
||||||
// Legacy compatibility values for components that expect flat props
|
// Legacy compatibility values for components that expect flat props
|
||||||
backgroundImageUrl,
|
backgroundImageUrl,
|
||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
|
backgroundEmbedUrl,
|
||||||
backgroundAudioUrl,
|
backgroundAudioUrl,
|
||||||
backgroundVideoAutoplay,
|
backgroundVideoAutoplay,
|
||||||
backgroundVideoLoop,
|
backgroundVideoLoop,
|
||||||
@ -225,12 +228,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
backgroundAudioEndTime,
|
backgroundAudioEndTime,
|
||||||
} = usePageBackground();
|
} = usePageBackground();
|
||||||
|
|
||||||
// Sound control hook for iOS autoplay compatibility
|
// Global sound control starts muted for browser autoplay compatibility.
|
||||||
// Videos start muted (for iOS autoplay), can be controlled via page settings
|
|
||||||
const soundControl = useVideoSoundControl({
|
const soundControl = useVideoSoundControl({
|
||||||
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound
|
pageHasSound: backgroundVideoMuted === false,
|
||||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
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
|
// Network-aware transitions: skip video on slow networks, use CSS fade instead
|
||||||
@ -292,6 +294,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [activeDetailImage, setActiveDetailImage] =
|
const [activeDetailImage, setActiveDetailImage] =
|
||||||
useState<InfoPanelImage | null>(null);
|
useState<InfoPanelImage | null>(null);
|
||||||
|
const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{
|
||||||
|
items: GalleryCarouselMediaItem[];
|
||||||
|
initialIndex: number;
|
||||||
|
} | null>(null);
|
||||||
// Current element transition settings (for CSS transitions when no video)
|
// Current element transition settings (for CSS transitions when no video)
|
||||||
const [
|
const [
|
||||||
currentElementTransitionSettings,
|
currentElementTransitionSettings,
|
||||||
@ -369,15 +375,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return selectedElement;
|
return selectedElement;
|
||||||
}, [isConstructorEditMode, selectedElement]);
|
}, [isConstructorEditMode, selectedElement]);
|
||||||
|
|
||||||
// Determine which info panel element to use for overlay rendering
|
// In edit mode the overlay is only a selected-element preview. Do not keep
|
||||||
const infoPanelElementToRender =
|
// runtime-open Info Panel state above the canvas after selection is cleared.
|
||||||
activeInfoPanelElement || editModeInfoPanelElement;
|
const infoPanelElementToRender = isConstructorEditMode
|
||||||
|
? editModeInfoPanelElement
|
||||||
|
: activeInfoPanelElement;
|
||||||
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
|
const shouldShowInfoPanelOverlays = !!infoPanelElementToRender;
|
||||||
|
|
||||||
// Reset info panel state when switching between edit and interact modes
|
// Reset info panel state when switching between edit and interact modes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveInfoPanel(null);
|
setActiveInfoPanel(null);
|
||||||
setActiveDetailImage(null);
|
setActiveDetailImage(null);
|
||||||
|
setActiveInfoPanelGallery(null);
|
||||||
}, [isConstructorEditMode]);
|
}, [isConstructorEditMode]);
|
||||||
|
|
||||||
// Draggable panels using useDraggable hook
|
// Draggable panels using useDraggable hook
|
||||||
@ -470,6 +479,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
background_image_url: p.background_image_url,
|
background_image_url: p.background_image_url,
|
||||||
background_video_url: p.background_video_url,
|
background_video_url: p.background_video_url,
|
||||||
|
background_embed_url: p.background_embed_url,
|
||||||
background_audio_url: p.background_audio_url,
|
background_audio_url: p.background_audio_url,
|
||||||
})),
|
})),
|
||||||
pageLinks,
|
pageLinks,
|
||||||
@ -506,6 +516,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const {
|
const {
|
||||||
currentImageUrl: navCurrentBgImageUrl,
|
currentImageUrl: navCurrentBgImageUrl,
|
||||||
currentVideoUrl: navCurrentBgVideoUrl,
|
currentVideoUrl: navCurrentBgVideoUrl,
|
||||||
|
currentEmbedUrl: navCurrentBgEmbedUrl,
|
||||||
currentAudioUrl: navCurrentBgAudioUrl,
|
currentAudioUrl: navCurrentBgAudioUrl,
|
||||||
previousImageUrl: navPreviousBgImageUrl,
|
previousImageUrl: navPreviousBgImageUrl,
|
||||||
previousVideoUrl: navPreviousBgVideoUrl,
|
previousVideoUrl: navPreviousBgVideoUrl,
|
||||||
@ -522,6 +533,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onVideoBufferStateChange,
|
onVideoBufferStateChange,
|
||||||
onTransitionEnded,
|
onTransitionEnded,
|
||||||
navigateToPage: navNavigateToPage,
|
navigateToPage: navNavigateToPage,
|
||||||
|
setBackgroundDirectly: navSetBackgroundDirectly,
|
||||||
resetToIdle: navResetToIdle,
|
resetToIdle: navResetToIdle,
|
||||||
startTransition,
|
startTransition,
|
||||||
} = navState;
|
} = navState;
|
||||||
@ -560,6 +572,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
id: page.id,
|
id: page.id,
|
||||||
background_image_url: page.background_image_url,
|
background_image_url: page.background_image_url,
|
||||||
background_video_url: page.background_video_url,
|
background_video_url: page.background_video_url,
|
||||||
|
background_embed_url: page.background_embed_url,
|
||||||
background_audio_url: page.background_audio_url,
|
background_audio_url: page.background_audio_url,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@ -578,6 +591,105 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
[navNavigateToPage, applyPageSelection],
|
[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 {
|
const {
|
||||||
isBuffering: isTransitionBuffering,
|
isBuffering: isTransitionBuffering,
|
||||||
isVideoReady: isTransitionVideoReady,
|
isVideoReady: isTransitionVideoReady,
|
||||||
@ -1154,6 +1266,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
id: activePage.id,
|
id: activePage.id,
|
||||||
background_image_url: activePage.background_image_url,
|
background_image_url: activePage.background_image_url,
|
||||||
background_video_url: activePage.background_video_url,
|
background_video_url: activePage.background_video_url,
|
||||||
|
background_embed_url: activePage.background_embed_url,
|
||||||
background_audio_url: activePage.background_audio_url,
|
background_audio_url: activePage.background_audio_url,
|
||||||
});
|
});
|
||||||
}, [activePage, navNavigateToPage]);
|
}, [activePage, navNavigateToPage]);
|
||||||
@ -1427,12 +1540,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Handler for info panel clicks
|
// Handler for info panel clicks
|
||||||
const handleInfoPanelClick = useCallback((element: CanvasElement) => {
|
const handleInfoPanelClick = useCallback(
|
||||||
if (isInfoPanelElementType(element.type)) {
|
(element: CanvasElement) => {
|
||||||
setActiveInfoPanel({ elementId: element.id });
|
if (isConstructorEditMode) return;
|
||||||
setActiveDetailImage(null);
|
if (isInfoPanelElementType(element.type)) {
|
||||||
}
|
setActiveInfoPanel({ elementId: element.id });
|
||||||
}, []);
|
setActiveDetailImage(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isConstructorEditMode],
|
||||||
|
);
|
||||||
|
|
||||||
// Handler for gallery carousel button position changes (constructor only)
|
// Handler for gallery carousel button position changes (constructor only)
|
||||||
const handleGalleryCarouselButtonPositionChange = useCallback(
|
const handleGalleryCarouselButtonPositionChange = useCallback(
|
||||||
@ -1557,6 +1674,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
resolveUrlWithBlob,
|
resolveUrlWithBlob,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const backgroundEmbedSrc = useMemo(() => {
|
||||||
|
if (isConstructorEditMode && backgroundEmbedUrl) {
|
||||||
|
return resolveUrlWithBlob(backgroundEmbedUrl);
|
||||||
|
}
|
||||||
|
return navCurrentBgEmbedUrl;
|
||||||
|
}, [
|
||||||
|
isConstructorEditMode,
|
||||||
|
backgroundEmbedUrl,
|
||||||
|
navCurrentBgEmbedUrl,
|
||||||
|
resolveUrlWithBlob,
|
||||||
|
]);
|
||||||
|
|
||||||
const backgroundAudioSrc = useMemo(() => {
|
const backgroundAudioSrc = useMemo(() => {
|
||||||
if (isConstructorEditMode && backgroundAudioUrl) {
|
if (isConstructorEditMode && backgroundAudioUrl) {
|
||||||
return resolveUrlWithBlob(backgroundAudioUrl);
|
return resolveUrlWithBlob(backgroundAudioUrl);
|
||||||
@ -1577,9 +1706,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
? 'Background image'
|
? 'Background image'
|
||||||
: selectedMenuItem === 'background_video'
|
: selectedMenuItem === 'background_video'
|
||||||
? 'Background video'
|
? 'Background video'
|
||||||
: selectedMenuItem === 'background_audio'
|
: selectedMenuItem === 'background_embed'
|
||||||
? 'Background audio'
|
? 'Background 360'
|
||||||
: selectedElement?.label || 'Element editor';
|
: selectedMenuItem === 'background_audio'
|
||||||
|
? 'Background audio'
|
||||||
|
: selectedElement?.label || 'Element editor';
|
||||||
|
|
||||||
// Background image is rendered by CanvasBackground component (same as runtime)
|
// Background image is rendered by CanvasBackground component (same as runtime)
|
||||||
// No CSS background-image needed on canvas div
|
// No CSS background-image needed on canvas div
|
||||||
@ -1621,6 +1752,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Background convenience setters
|
// Background convenience setters
|
||||||
setBackgroundImageUrl,
|
setBackgroundImageUrl,
|
||||||
setBackgroundVideoUrl,
|
setBackgroundVideoUrl,
|
||||||
|
setBackgroundEmbedUrl,
|
||||||
setBackgroundAudioUrl,
|
setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
setBackgroundAudioSettings,
|
setBackgroundAudioSettings,
|
||||||
@ -1696,6 +1828,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateBackgroundFromPage,
|
updateBackgroundFromPage,
|
||||||
setBackgroundImageUrl,
|
setBackgroundImageUrl,
|
||||||
setBackgroundVideoUrl,
|
setBackgroundVideoUrl,
|
||||||
|
setBackgroundEmbedUrl,
|
||||||
setBackgroundAudioUrl,
|
setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
elements,
|
elements,
|
||||||
@ -1842,6 +1975,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundImageUrl={backgroundImageSrc}
|
backgroundImageUrl={backgroundImageSrc}
|
||||||
backgroundVideoUrl={backgroundVideoSrc}
|
backgroundVideoUrl={backgroundVideoSrc}
|
||||||
|
backgroundEmbedUrl={backgroundEmbedSrc}
|
||||||
backgroundAudioUrl={backgroundAudioSrc}
|
backgroundAudioUrl={backgroundAudioSrc}
|
||||||
previousBgImageUrl={navPreviousBgImageUrl}
|
previousBgImageUrl={navPreviousBgImageUrl}
|
||||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
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 */}
|
{/* Info Panel Overlay */}
|
||||||
{shouldShowInfoPanelOverlays && infoPanelElementToRender && (
|
{shouldShowInfoPanelOverlays && infoPanelElementToRender && (
|
||||||
<>
|
<>
|
||||||
@ -2034,11 +2199,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setActiveInfoPanel(null);
|
setActiveInfoPanel(null);
|
||||||
setActiveDetailImage(null);
|
setActiveDetailImage(null);
|
||||||
|
setActiveInfoPanelGallery(null);
|
||||||
}}
|
}}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
cssVars={canvasCssVars}
|
cssVars={canvasCssVars}
|
||||||
onImageClick={(image) => setActiveDetailImage(image)}
|
onImageClick={(image) => setActiveDetailImage(image)}
|
||||||
|
onOpenGallery={handleInfoPanelOpenGallery}
|
||||||
|
onUseAsBackground={handleInfoPanelUseAsBackground}
|
||||||
|
onNavigateToPage={handleInfoPanelNavigateToPage}
|
||||||
|
onOpenExternalUrl={handleInfoPanelOpenExternalUrl}
|
||||||
onSelectImage={
|
onSelectImage={
|
||||||
isConstructorEditMode
|
isConstructorEditMode
|
||||||
? (imageId) => {
|
? (imageId) => {
|
||||||
@ -2073,6 +2243,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onClose={() => setActiveDetailImage(null)}
|
onClose={() => setActiveDetailImage(null)}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
letterboxStyles={letterboxStyles}
|
letterboxStyles={letterboxStyles}
|
||||||
|
cssVars={canvasCssVars}
|
||||||
isEditMode={isConstructorEditMode}
|
isEditMode={isConstructorEditMode}
|
||||||
onDetailPositionChange={
|
onDetailPositionChange={
|
||||||
isConstructorEditMode
|
isConstructorEditMode
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export type EditorMenuItem =
|
|||||||
| 'none'
|
| 'none'
|
||||||
| 'background_image'
|
| 'background_image'
|
||||||
| 'background_video'
|
| 'background_video'
|
||||||
|
| 'background_embed'
|
||||||
| 'background_audio';
|
| 'background_audio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,6 +69,19 @@ export interface GalleryCard {
|
|||||||
description: string;
|
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)
|
* 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
|
* - '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
|
* Info panel image/embed item
|
||||||
@ -100,12 +122,21 @@ export type InfoPanelItemType = 'image' | '360';
|
|||||||
export interface InfoPanelImage {
|
export interface InfoPanelImage {
|
||||||
id: string;
|
id: string;
|
||||||
imageUrl?: string; // Regular image URL (storage key)
|
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=...)
|
embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...)
|
||||||
caption?: string;
|
caption?: string;
|
||||||
/** Item type: 'image' for inline preview, '360' for embed trigger */
|
/** Item type: 'image' for inline preview, '360' for embed trigger */
|
||||||
itemType?: InfoPanelItemType;
|
itemType?: InfoPanelItemType;
|
||||||
/** Custom icon URL for 360° trigger button */
|
/** Custom icon URL for 360° trigger button */
|
||||||
iconUrl?: string;
|
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;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
iconUrl?: string; // Renders icon instead of text when set
|
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
|
* Available Info Panel section types for dynamic ordering
|
||||||
* - 'cards': Legacy card grid (click opens ImageDetailPanel)
|
* - 'cards': Card grid for media items
|
||||||
* - 'images': New inline image viewer with preview + thumbnail strip
|
* - 'images': Media viewer with preview + thumbnail strip
|
||||||
*/
|
*/
|
||||||
export type InfoPanelSectionType =
|
export type InfoPanelSectionType =
|
||||||
| 'header'
|
| 'header'
|
||||||
@ -145,6 +182,8 @@ export interface InfoPanelSectionInstance {
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
/** Gap between items */
|
/** Gap between items */
|
||||||
gap?: string;
|
gap?: string;
|
||||||
|
/** Media click rendering mode for cards/media sections */
|
||||||
|
mediaOpenMode?: InfoPanelMediaOpenMode;
|
||||||
|
|
||||||
// Per-instance data (each section has its OWN items)
|
// Per-instance data (each section has its OWN items)
|
||||||
/** For 'spans' type sections */
|
/** For 'spans' type sections */
|
||||||
@ -159,6 +198,12 @@ export interface InfoPanelSectionInstance {
|
|||||||
headerImageUrl?: string;
|
headerImageUrl?: string;
|
||||||
/** For 'header' type sections - text content */
|
/** For 'header' type sections - text content */
|
||||||
headerText?: string;
|
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-title', type: 'title' },
|
||||||
{ id: 'default-text', type: 'text' },
|
{ id: 'default-text', type: 'text' },
|
||||||
{ id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] },
|
{ 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',
|
text: 'Text',
|
||||||
spans: 'Info Spans',
|
spans: 'Info Spans',
|
||||||
cards: 'Cards',
|
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 */
|
/** Section instances with per-section columns, gap, spans, and images */
|
||||||
infoPanelSections?: InfoPanelSectionInstance[];
|
infoPanelSections?: InfoPanelSectionInstance[];
|
||||||
|
|
||||||
// Images section (inline image viewer)
|
// Media section (inline media viewer)
|
||||||
/** Currently selected image ID in the images section preview */
|
/** Currently selected image ID in the media section preview */
|
||||||
infoPanelSelectedImageId?: string;
|
infoPanelSelectedImageId?: string;
|
||||||
/** Preview area height for images section (e.g., '300', 'auto') */
|
/** Preview area height for media section (e.g., '300', 'auto') */
|
||||||
infoPanelImagesPreviewHeight?: string;
|
infoPanelImagesPreviewHeight?: string;
|
||||||
/** Thumbnail size for images section (e.g., '80') */
|
/** Thumbnail size for media section (e.g., '80') */
|
||||||
infoPanelImagesThumbnailSize?: string;
|
infoPanelImagesThumbnailSize?: string;
|
||||||
|
|
||||||
// Component 3: Image Detail Panel
|
// Component 3: Image Detail Panel
|
||||||
@ -696,6 +741,7 @@ export interface EditorElementProps {
|
|||||||
| 'none'
|
| 'none'
|
||||||
| 'background_image'
|
| 'background_image'
|
||||||
| 'background_video'
|
| 'background_video'
|
||||||
|
| 'background_embed'
|
||||||
| 'background_audio';
|
| 'background_audio';
|
||||||
onRemoveElement: () => void;
|
onRemoveElement: () => void;
|
||||||
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
||||||
@ -815,6 +861,8 @@ export interface PageBackgroundState {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
/** Storage key or URL for background video */
|
/** Storage key or URL for background video */
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
/** Storage key or URL for background 360/embed */
|
||||||
|
embedUrl: string;
|
||||||
/** Storage key or URL for background audio */
|
/** Storage key or URL for background audio */
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
/** Video playback settings */
|
/** Video playback settings */
|
||||||
@ -849,6 +897,7 @@ export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
|
|||||||
export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
|
export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
|
embedUrl: '',
|
||||||
audioUrl: '',
|
audioUrl: '',
|
||||||
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
|
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
|
||||||
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
|
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
|
||||||
@ -861,6 +910,7 @@ export function createPageBackgroundFromPage(
|
|||||||
page: {
|
page: {
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_video_autoplay?: boolean;
|
background_video_autoplay?: boolean;
|
||||||
background_video_loop?: boolean;
|
background_video_loop?: boolean;
|
||||||
@ -880,6 +930,7 @@ export function createPageBackgroundFromPage(
|
|||||||
return {
|
return {
|
||||||
imageUrl: page.background_image_url || '',
|
imageUrl: page.background_image_url || '',
|
||||||
videoUrl: page.background_video_url || '',
|
videoUrl: page.background_video_url || '',
|
||||||
|
embedUrl: page.background_embed_url || '',
|
||||||
audioUrl: page.background_audio_url || '',
|
audioUrl: page.background_audio_url || '',
|
||||||
videoSettings: {
|
videoSettings: {
|
||||||
autoplay: page.background_video_autoplay ?? true,
|
autoplay: page.background_video_autoplay ?? true,
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export interface TourPage extends BaseEntity {
|
|||||||
// Background URL fields (direct storage paths)
|
// Background URL fields (direct storage paths)
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_embed_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_loop?: boolean;
|
background_loop?: boolean;
|
||||||
// Background video playback settings
|
// Background video playback settings
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user