1048 lines
40 KiB
TypeScript
1048 lines
40 KiB
TypeScript
/**
|
|
* InfoPanelOverlay Component
|
|
*
|
|
* Overlay panel that displays header, title, info spans, text, and image cards.
|
|
* Section-based structure similar to Gallery for consistent styling.
|
|
* Positioned absolutely within the canvas (not fullscreen).
|
|
* Supports keyboard navigation, click outside to close, and mobile touch.
|
|
*/
|
|
|
|
import React, {
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useRef,
|
|
useMemo,
|
|
} from 'react';
|
|
import type {
|
|
CanvasElement,
|
|
InfoPanelImage,
|
|
InfoPanelImageClickAction,
|
|
InfoPanelLinkClickAction,
|
|
InfoPanelSectionInstance,
|
|
} from '../../types/constructor';
|
|
import { getInfoPanelSections } from '../../types/constructor';
|
|
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
|
import {
|
|
buildInfoPanelHeaderStyle,
|
|
buildInfoPanelTitleStyle,
|
|
buildInfoPanelTextStyle,
|
|
buildInfoPanelSpanStyle,
|
|
buildInfoPanelSpanGridStyleWithSection,
|
|
buildInfoPanelCardStyle,
|
|
buildInfoPanelCardTitleStyle,
|
|
buildInfoPanelCardGridStyleWithSection,
|
|
} from '../../lib/infoPanelSectionStyles';
|
|
|
|
interface InfoPanelOverlayProps {
|
|
element: CanvasElement;
|
|
onClose: () => void;
|
|
resolveUrl?: (url: string | undefined) => string;
|
|
letterboxStyles?: React.CSSProperties;
|
|
/** CSS custom properties including --cu for canvas units */
|
|
cssVars?: React.CSSProperties;
|
|
/** Callback when an image/360 is clicked. Pass null to close the detail panel. */
|
|
onImageClick: (image: InfoPanelImage | null) => void;
|
|
/** Callback when an image/video/360 group should open in fullscreen gallery mode */
|
|
onOpenGallery?: (items: InfoPanelImage[], initialIndex: number) => void;
|
|
/** Callback when an image thumbnail is selected */
|
|
onSelectImage?: (imageId: string) => void;
|
|
/** Callback when an Info Panel item targets an internal page slug */
|
|
onNavigateToPage?: (targetPageSlug: string) => void;
|
|
/** Callback when an Info Panel item targets an external URL */
|
|
onOpenExternalUrl?: (url: string) => void;
|
|
/** Callback when a media item should replace the current screen background */
|
|
onUseAsBackground?: (image: InfoPanelImage) => void;
|
|
/** Whether this overlay instance should render the fullscreen backdrop layer */
|
|
renderBackdrop?: boolean;
|
|
/** Optional close handler for backdrop clicks; defaults to onClose */
|
|
onBackdropClose?: () => void;
|
|
isEditMode?: boolean;
|
|
/** Callback when panel position changes (edit mode only) */
|
|
onPanelPositionChange?: (xPercent: number, yPercent: number) => void;
|
|
/** Currently active 360° item ID (for toggle state sync with parent) */
|
|
active360ItemId?: string | null;
|
|
}
|
|
|
|
const VideoThumbnail: React.FC<{
|
|
src: string;
|
|
caption?: string;
|
|
}> = ({ src, caption }) => {
|
|
const [isReady, setIsReady] = useState(false);
|
|
|
|
return (
|
|
<div className='relative h-full w-full overflow-hidden bg-black'>
|
|
<video
|
|
src={src}
|
|
className='h-full w-full object-cover'
|
|
muted
|
|
playsInline
|
|
preload='metadata'
|
|
aria-label={caption || 'Video thumbnail'}
|
|
style={{
|
|
pointerEvents: 'none',
|
|
opacity: isReady ? 1 : 0,
|
|
transition: 'opacity 160ms ease-out',
|
|
}}
|
|
onLoadedData={(event) => {
|
|
setIsReady(true);
|
|
const video = event.currentTarget;
|
|
if (video.duration > 0.25 && video.currentTime < 0.1) {
|
|
try {
|
|
video.currentTime = 0.1;
|
|
} catch {
|
|
// Some browsers disallow seeking before metadata is fully ready.
|
|
}
|
|
}
|
|
}}
|
|
onError={() => setIsReady(false)}
|
|
/>
|
|
{!isReady && (
|
|
<div className='absolute inset-0 flex items-center justify-center text-white/40'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='h-7 w-7'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
<div className='absolute inset-0 flex items-center justify-center bg-black/10'>
|
|
<span className='flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white shadow'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='currentColor'
|
|
viewBox='0 0 24 24'
|
|
className='ml-0.5 h-4 w-4'
|
|
aria-hidden='true'
|
|
>
|
|
<path d='M8 5v14l11-7z' />
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|
element,
|
|
onClose,
|
|
resolveUrl,
|
|
letterboxStyles,
|
|
cssVars,
|
|
onImageClick,
|
|
onOpenGallery,
|
|
onSelectImage,
|
|
onNavigateToPage,
|
|
onOpenExternalUrl,
|
|
onUseAsBackground,
|
|
renderBackdrop = true,
|
|
onBackdropClose,
|
|
isEditMode = false,
|
|
onPanelPositionChange,
|
|
active360ItemId,
|
|
}) => {
|
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragStartRef = useRef<{
|
|
x: number;
|
|
y: number;
|
|
panelX: number;
|
|
panelY: number;
|
|
} | null>(null);
|
|
|
|
// Section styles computed from element settings
|
|
const headerStyle = useMemo(
|
|
() => buildInfoPanelHeaderStyle(element),
|
|
[element],
|
|
);
|
|
const titleStyle = useMemo(
|
|
() => buildInfoPanelTitleStyle(element),
|
|
[element],
|
|
);
|
|
const textStyle = useMemo(() => buildInfoPanelTextStyle(element), [element]);
|
|
const spanStyle = useMemo(() => buildInfoPanelSpanStyle(element), [element]);
|
|
const cardStyle = useMemo(() => buildInfoPanelCardStyle(element), [element]);
|
|
const cardTitleStyle = useMemo(
|
|
() => buildInfoPanelCardTitleStyle(element),
|
|
[element],
|
|
);
|
|
// Get section instances
|
|
const sections = useMemo(() => getInfoPanelSections(element), [element]);
|
|
|
|
const openExternalUrl = useCallback(
|
|
(url: string) => {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return;
|
|
if (onOpenExternalUrl) {
|
|
onOpenExternalUrl(trimmed);
|
|
return;
|
|
}
|
|
const href = /^https?:\/\//i.test(trimmed)
|
|
? trimmed
|
|
: `https://${trimmed}`;
|
|
window.open(href, '_blank', 'noopener,noreferrer');
|
|
},
|
|
[onOpenExternalUrl],
|
|
);
|
|
|
|
const handleLinkDestination = useCallback(
|
|
(target: {
|
|
clickAction?: InfoPanelLinkClickAction;
|
|
targetPageSlug?: string;
|
|
externalUrl?: string;
|
|
}): boolean => {
|
|
if (target.clickAction === 'target_page' && target.targetPageSlug) {
|
|
onNavigateToPage?.(target.targetPageSlug);
|
|
return true;
|
|
}
|
|
if (target.clickAction === 'external_url' && target.externalUrl) {
|
|
openExternalUrl(target.externalUrl);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
[onNavigateToPage, openExternalUrl],
|
|
);
|
|
|
|
const handleImageDestination = useCallback(
|
|
(
|
|
image: InfoPanelImage,
|
|
section: InfoPanelSectionInstance,
|
|
items?: InfoPanelImage[],
|
|
) => {
|
|
if (image.useAsBackground) {
|
|
onUseAsBackground?.(image);
|
|
return;
|
|
}
|
|
|
|
const clickAction: InfoPanelImageClickAction =
|
|
image.clickAction || 'panel';
|
|
|
|
if (clickAction === 'target_page' && image.targetPageSlug) {
|
|
onNavigateToPage?.(image.targetPageSlug);
|
|
return;
|
|
}
|
|
|
|
if (clickAction === 'external_url' && image.externalUrl) {
|
|
openExternalUrl(image.externalUrl);
|
|
return;
|
|
}
|
|
|
|
const sectionMode =
|
|
section.mediaOpenMode ||
|
|
(clickAction === 'fullscreen' ? 'fullscreen' : 'panel');
|
|
|
|
if (sectionMode === 'fullscreen' && onOpenGallery && items?.length) {
|
|
onOpenGallery(
|
|
items,
|
|
Math.max(
|
|
0,
|
|
items.findIndex((item) => item.id === image.id),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
onImageClick(image);
|
|
},
|
|
[
|
|
onNavigateToPage,
|
|
onImageClick,
|
|
onOpenGallery,
|
|
onUseAsBackground,
|
|
openExternalUrl,
|
|
],
|
|
);
|
|
|
|
const getClickableStyle = (
|
|
target: {
|
|
clickAction?: InfoPanelLinkClickAction;
|
|
targetPageSlug?: string;
|
|
externalUrl?: string;
|
|
},
|
|
baseStyle: React.CSSProperties,
|
|
): React.CSSProperties => ({
|
|
...baseStyle,
|
|
cursor: target.clickAction ? 'pointer' : baseStyle.cursor,
|
|
});
|
|
|
|
// Fade in animation
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => setIsVisible(true));
|
|
}, []);
|
|
|
|
// Keyboard navigation (ESC to close)
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onClose]);
|
|
|
|
// Focus trap
|
|
useEffect(() => {
|
|
const panel = panelRef.current;
|
|
if (!panel) return;
|
|
|
|
// Focus the panel on mount
|
|
panel.focus();
|
|
}, []);
|
|
|
|
// Drag handling for edit mode
|
|
const handleDragStart = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!isEditMode || !onPanelPositionChange) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const panelX = element.panelXPercent ?? 50;
|
|
const panelY = element.panelYPercent ?? 50;
|
|
|
|
dragStartRef.current = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
panelX,
|
|
panelY,
|
|
};
|
|
setIsDragging(true);
|
|
},
|
|
[
|
|
isEditMode,
|
|
onPanelPositionChange,
|
|
element.panelXPercent,
|
|
element.panelYPercent,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isEditMode || !isDragging || !onPanelPositionChange) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!dragStartRef.current || !containerRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
const deltaX = e.clientX - dragStartRef.current.x;
|
|
const deltaY = e.clientY - dragStartRef.current.y;
|
|
|
|
// Convert pixel delta to percentage
|
|
const deltaXPercent = (deltaX / rect.width) * 100;
|
|
const deltaYPercent = (deltaY / rect.height) * 100;
|
|
|
|
const newX = Math.max(
|
|
0,
|
|
Math.min(100, dragStartRef.current.panelX + deltaXPercent),
|
|
);
|
|
const newY = Math.max(
|
|
0,
|
|
Math.min(100, dragStartRef.current.panelY + deltaYPercent),
|
|
);
|
|
|
|
onPanelPositionChange(newX, newY);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
dragStartRef.current = null;
|
|
setIsDragging(false);
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [isEditMode, isDragging, onPanelPositionChange]);
|
|
|
|
// Handle backdrop click (close when clicking outside panel)
|
|
const handleBackdropClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (e.target === overlayRef.current && !isEditMode) {
|
|
(onBackdropClose || onClose)();
|
|
}
|
|
},
|
|
[onBackdropClose, onClose, isEditMode],
|
|
);
|
|
|
|
// Handle touch on backdrop
|
|
const handleBackdropTouch = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (e.target === overlayRef.current && !isEditMode) {
|
|
(onBackdropClose || onClose)();
|
|
}
|
|
},
|
|
[onBackdropClose, onClose, isEditMode],
|
|
);
|
|
|
|
// Extract panel styling from element
|
|
const panelXPercent = element.panelXPercent ?? 50;
|
|
const panelYPercent = element.panelYPercent ?? 50;
|
|
const panelWidth = element.panelWidth ?? '400';
|
|
const panelHeight = element.panelHeight ?? 'auto';
|
|
const panelBackgroundColor =
|
|
element.panelBackgroundColor ?? 'rgba(0, 0, 0, 0.85)';
|
|
const panelBorderRadius = element.panelBorderRadius ?? '12';
|
|
const panelBorderWidth = element.panelBorderWidth || '0';
|
|
const panelBorderColor = element.panelBorderColor || '#ffffff';
|
|
const panelBorderStyle = element.panelBorderStyle || 'solid';
|
|
const panelPadding = element.panelPadding ?? '20';
|
|
const panelBackdropBlur = element.panelBackdropBlur ?? '10px';
|
|
const panelTitle = element.panelTitle ?? 'Information';
|
|
const panelText = element.panelText ?? '';
|
|
const panelOverlayColor = element.panelOverlayColor ?? 'rgba(0, 0, 0, 0.3)';
|
|
|
|
// Check if overlay should be visible
|
|
const isOverlayVisible =
|
|
panelOverlayColor !== 'transparent' && panelOverlayColor !== 'none';
|
|
|
|
// Convert numeric values to canvas units for responsive scaling
|
|
const toCU = (value: string): string => {
|
|
const trimmed = value.trim();
|
|
// Zero doesn't need a unit
|
|
if (trimmed === '0') return '0';
|
|
// Already uses canvas units - return as-is
|
|
if (trimmed.includes('var(--cu') || trimmed.includes('--cu'))
|
|
return trimmed;
|
|
// CSS functions (calc, var, min, max, etc.) - return as-is
|
|
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
|
// Already has a unit suffix - return as-is
|
|
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
|
|
// Parse numeric value and convert to canvas units
|
|
const num = parseFloat(trimmed);
|
|
if (!Number.isFinite(num)) return trimmed;
|
|
return `calc(${num} * var(--cu, 1px))`;
|
|
};
|
|
|
|
// Panel style with responsive constraints
|
|
const panelStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
left: `${panelXPercent}%`,
|
|
top: `${panelYPercent}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
width:
|
|
panelWidth === 'auto'
|
|
? 'auto'
|
|
: `min(${toCU(panelWidth)}, calc(100vw - 32px))`,
|
|
maxHeight:
|
|
panelHeight === 'auto'
|
|
? 'calc(100dvh - 64px)'
|
|
: `min(${toCU(panelHeight)}, calc(100dvh - 64px))`,
|
|
backgroundColor: panelBackgroundColor,
|
|
borderRadius: toCU(panelBorderRadius),
|
|
border:
|
|
panelBorderWidth !== '0' && panelBorderWidth
|
|
? `${toCU(panelBorderWidth)} ${panelBorderStyle} ${panelBorderColor}`
|
|
: 'none',
|
|
padding: toCU(panelPadding),
|
|
WebkitBackdropFilter: `blur(${panelBackdropBlur})`,
|
|
backdropFilter: `blur(${panelBackdropBlur})`,
|
|
overflowY: 'auto',
|
|
opacity: isVisible ? 1 : 0,
|
|
transition: 'opacity 200ms ease-out',
|
|
// Safe area for notched devices
|
|
paddingTop: `max(${toCU(panelPadding)}, env(safe-area-inset-top))`,
|
|
paddingRight: `max(${toCU(panelPadding)}, env(safe-area-inset-right))`,
|
|
paddingBottom: `max(${toCU(panelPadding)}, env(safe-area-inset-bottom))`,
|
|
paddingLeft: `max(${toCU(panelPadding)}, env(safe-area-inset-left))`,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop overlay - separate from panel for correct z-index stacking */}
|
|
{renderBackdrop && (
|
|
<div
|
|
ref={overlayRef}
|
|
className='fixed inset-0 z-[51] overflow-hidden'
|
|
style={{
|
|
backgroundColor: isOverlayVisible
|
|
? panelOverlayColor
|
|
: 'transparent',
|
|
pointerEvents: isEditMode ? 'none' : 'auto',
|
|
}}
|
|
onClick={handleBackdropClick}
|
|
onTouchEnd={handleBackdropTouch}
|
|
/>
|
|
)}
|
|
|
|
{/* Inner container constrained to canvas bounds */}
|
|
<div
|
|
ref={containerRef}
|
|
className='fixed inset-0 z-[53] overflow-hidden pointer-events-none'
|
|
style={{
|
|
...cssVars,
|
|
...(letterboxStyles || { position: 'absolute', inset: 0 }),
|
|
}}
|
|
>
|
|
{/* Info Panel */}
|
|
<div
|
|
ref={panelRef}
|
|
role='dialog'
|
|
aria-modal='true'
|
|
aria-labelledby='info-panel-title'
|
|
tabIndex={-1}
|
|
style={{
|
|
...panelStyle,
|
|
cursor:
|
|
isEditMode && onPanelPositionChange
|
|
? isDragging
|
|
? 'grabbing'
|
|
: 'grab'
|
|
: undefined,
|
|
pointerEvents: isEditMode ? 'none' : 'auto',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onTouchEnd={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Drag handle header (edit mode only) */}
|
|
{isEditMode && onPanelPositionChange && (
|
|
<div
|
|
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
|
|
style={{
|
|
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
|
|
pointerEvents: 'auto',
|
|
}}
|
|
onMouseDown={handleDragStart}
|
|
>
|
|
{/* Drag indicator dots */}
|
|
<div className='flex items-center justify-center h-full gap-1'>
|
|
<div className='w-8 h-1 rounded-full bg-white/30' />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
type='button'
|
|
className='absolute top-2 right-2 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors z-10'
|
|
onClick={onClose}
|
|
aria-label='Close panel'
|
|
>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={2}
|
|
stroke='currentColor'
|
|
className='w-5 h-5'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='M6 18 18 6M6 6l12 12'
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Content wrapper - disable pointer events in edit mode for dragging */}
|
|
<div
|
|
style={{
|
|
pointerEvents: isEditMode ? 'none' : 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: toCU(element.infoPanelSectionGap ?? '12'),
|
|
}}
|
|
>
|
|
{/* Render sections in dynamic order using section instances */}
|
|
{sections.map((section) => {
|
|
switch (section.type) {
|
|
case 'header': {
|
|
// Header section: use section-level data or fall back to element-level
|
|
const headerImageUrl =
|
|
section.headerImageUrl ?? element.infoPanelHeaderImageUrl;
|
|
const headerText =
|
|
section.headerText ?? element.infoPanelHeaderText;
|
|
const headerClickProps = section.clickAction
|
|
? {
|
|
role: 'button' as const,
|
|
tabIndex: 0,
|
|
onClick: () => handleLinkDestination(section),
|
|
onKeyDown: (
|
|
event: React.KeyboardEvent<HTMLDivElement>,
|
|
) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
handleLinkDestination(section);
|
|
}
|
|
},
|
|
}
|
|
: {};
|
|
|
|
// Image takes priority, otherwise render text
|
|
if (headerImageUrl) {
|
|
return (
|
|
<div
|
|
key={section.id}
|
|
{...headerClickProps}
|
|
style={{
|
|
...getClickableStyle(section, headerStyle),
|
|
padding: 0,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={resolve(headerImageUrl)}
|
|
alt=''
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (headerText) {
|
|
return (
|
|
<div
|
|
key={section.id}
|
|
{...headerClickProps}
|
|
style={getClickableStyle(section, headerStyle)}
|
|
>
|
|
{headerText}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
case 'title': {
|
|
// Use section-level title or fall back to element-level
|
|
const titleContent = section.title ?? panelTitle;
|
|
if (!titleContent) return null;
|
|
return (
|
|
<h2
|
|
key={section.id}
|
|
id='info-panel-title'
|
|
style={{
|
|
...getClickableStyle(section, titleStyle),
|
|
marginTop:
|
|
isEditMode && onPanelPositionChange
|
|
? '16px'
|
|
: undefined,
|
|
}}
|
|
role={section.clickAction ? 'button' : undefined}
|
|
tabIndex={section.clickAction ? 0 : undefined}
|
|
onClick={() => handleLinkDestination(section)}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
section.clickAction &&
|
|
(event.key === 'Enter' || event.key === ' ')
|
|
) {
|
|
event.preventDefault();
|
|
handleLinkDestination(section);
|
|
}
|
|
}}
|
|
>
|
|
{titleContent}
|
|
</h2>
|
|
);
|
|
}
|
|
|
|
case 'text': {
|
|
// Use section-level text or fall back to element-level
|
|
const textContent = section.text ?? panelText;
|
|
if (!textContent) return null;
|
|
return (
|
|
<p
|
|
key={section.id}
|
|
style={getClickableStyle(section, textStyle)}
|
|
role={section.clickAction ? 'button' : undefined}
|
|
tabIndex={section.clickAction ? 0 : undefined}
|
|
onClick={() => handleLinkDestination(section)}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
section.clickAction &&
|
|
(event.key === 'Enter' || event.key === ' ')
|
|
) {
|
|
event.preventDefault();
|
|
handleLinkDestination(section);
|
|
}
|
|
}}
|
|
>
|
|
{textContent}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
case 'spans': {
|
|
// Use section-level spans
|
|
const sectionSpans = section.spans || [];
|
|
if (sectionSpans.length === 0) return null;
|
|
|
|
// Build grid style with per-section settings
|
|
const spanGridStyle = buildInfoPanelSpanGridStyleWithSection(
|
|
element,
|
|
section,
|
|
);
|
|
|
|
return (
|
|
<div key={section.id} style={spanGridStyle}>
|
|
{sectionSpans.map((span) => (
|
|
<button
|
|
key={span.id}
|
|
type='button'
|
|
style={{
|
|
...spanStyle,
|
|
border: 'none',
|
|
cursor: span.clickAction ? 'pointer' : 'default',
|
|
}}
|
|
onClick={() => handleLinkDestination(span)}
|
|
disabled={!span.clickAction}
|
|
className='focus:outline-none disabled:cursor-default'
|
|
>
|
|
{span.iconUrl ? (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={resolve(span.iconUrl)}
|
|
alt={span.text || ''}
|
|
style={{
|
|
width: '1em',
|
|
height: '1em',
|
|
objectFit: 'contain',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
span.text
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'cards': {
|
|
// Use section-level images
|
|
const sectionImages = section.images || [];
|
|
if (sectionImages.length === 0) return null;
|
|
|
|
// Build grid style with per-section settings
|
|
const cardGridStyle = buildInfoPanelCardGridStyleWithSection(
|
|
element,
|
|
section,
|
|
);
|
|
|
|
return (
|
|
<div key={section.id} style={cardGridStyle}>
|
|
{sectionImages.map((image) => (
|
|
<button
|
|
key={image.id}
|
|
type='button'
|
|
style={{
|
|
...cardStyle,
|
|
display: 'block', // Needed for aspectRatio to work on buttons
|
|
position: 'relative',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
padding: 0,
|
|
overflow: 'hidden', // Respect borderRadius on children
|
|
}}
|
|
className='focus:outline-none'
|
|
onClick={() =>
|
|
handleImageDestination(
|
|
image,
|
|
section,
|
|
sectionImages,
|
|
)
|
|
}
|
|
aria-label={image.caption || 'View image'}
|
|
>
|
|
{image.itemType === '360' ? (
|
|
// Embed placeholder - shows globe icon
|
|
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='w-8 h-8 mb-1'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
|
|
/>
|
|
</svg>
|
|
<span className='text-xs'>360/Embed</span>
|
|
</div>
|
|
) : image.itemType === 'video' && image.videoUrl ? (
|
|
<VideoThumbnail
|
|
src={resolve(image.videoUrl)}
|
|
caption={image.caption}
|
|
/>
|
|
) : image.itemType === 'video' ? (
|
|
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='w-8 h-8 mb-1'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
|
/>
|
|
</svg>
|
|
<span className='text-xs'>Video</span>
|
|
</div>
|
|
) : image.imageUrl ? (
|
|
// Regular image thumbnail
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolve(image.imageUrl)}
|
|
alt={image.caption || ''}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
// Empty placeholder
|
|
<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-8 h-8'
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{/* Card title overlay */}
|
|
{image.caption && (
|
|
<span
|
|
style={{
|
|
...cardTitleStyle,
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
}}
|
|
>
|
|
{image.caption}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'images': {
|
|
// Use section-level images
|
|
const sectionImages = section.images || [];
|
|
|
|
// Nothing to show if no items
|
|
if (sectionImages.length === 0) return null;
|
|
|
|
// Build grid style with per-section settings
|
|
const gridStyle = buildInfoPanelCardGridStyleWithSection(
|
|
element,
|
|
section,
|
|
);
|
|
|
|
// Thumbnail style for grid items
|
|
const thumbnailStyle: React.CSSProperties = {
|
|
...cardStyle,
|
|
display: 'block',
|
|
width: '100%',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
padding: 0,
|
|
overflow: 'hidden',
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={section.id}
|
|
className='images-section'
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: gridStyle.gap || 'calc(8 * var(--cu, 1px))',
|
|
}}
|
|
>
|
|
{/* Thumbnail Grid */}
|
|
<div style={gridStyle}>
|
|
{sectionImages.map((img) => {
|
|
const itemType = img.itemType || 'image';
|
|
const is360 = itemType === '360';
|
|
const isVideo = itemType === 'video';
|
|
|
|
return (
|
|
<button
|
|
key={img.id}
|
|
type='button'
|
|
style={thumbnailStyle}
|
|
className={
|
|
is360
|
|
? 'trigger-360 focus:outline-none'
|
|
: isVideo
|
|
? 'trigger-video focus:outline-none'
|
|
: 'transition-opacity hover:opacity-100 focus:outline-none'
|
|
}
|
|
onClick={() => {
|
|
if (!isVideo && !is360) {
|
|
onSelectImage?.(img.id);
|
|
}
|
|
handleImageDestination(
|
|
img,
|
|
section,
|
|
sectionImages,
|
|
);
|
|
}}
|
|
aria-label={
|
|
is360
|
|
? active360ItemId === img.id
|
|
? 'Close 360° view'
|
|
: 'Open 360° view'
|
|
: img.caption ||
|
|
(isVideo ? 'Open video' : 'Select image')
|
|
}
|
|
aria-pressed={
|
|
is360 ? active360ItemId === img.id : undefined
|
|
}
|
|
>
|
|
{is360 ? (
|
|
img.iconUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolve(img.iconUrl)}
|
|
alt='360°'
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='w-8 h-8 mb-1'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
|
|
/>
|
|
</svg>
|
|
<span className='text-xs'>360°</span>
|
|
</div>
|
|
)
|
|
) : isVideo ? (
|
|
img.videoUrl ? (
|
|
<VideoThumbnail
|
|
src={resolve(img.videoUrl)}
|
|
caption={img.caption}
|
|
/>
|
|
) : (
|
|
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='w-8 h-8 mb-1'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='m5.25 5.653 8.954 5.722a.75.75 0 0 1 0 1.25L5.25 18.347A.75.75 0 0 1 4.125 17.722V6.278a.75.75 0 0 1 1.125-.625Z'
|
|
/>
|
|
</svg>
|
|
<span className='text-xs'>Video</span>
|
|
</div>
|
|
)
|
|
) : img.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={resolve(img.imageUrl)}
|
|
alt=''
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className='w-full h-full flex items-center justify-center text-white/40'>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
fill='none'
|
|
viewBox='0 0 24 24'
|
|
strokeWidth={1.5}
|
|
stroke='currentColor'
|
|
className='w-6 h-6'
|
|
>
|
|
<path
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default InfoPanelOverlay;
|