39948-vm/frontend/src/components/UiElements/InfoPanelOverlay.tsx
2026-06-01 17:14:34 +02:00

817 lines
32 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,
InfoPanelSectionInstance,
} from '../../types/constructor';
import { getInfoPanelSections } from '../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
buildInfoPanelHeaderStyle,
buildInfoPanelTitleStyle,
buildInfoPanelTextStyle,
buildInfoPanelSpanStyle,
buildInfoPanelSpanGridStyleWithSection,
buildInfoPanelCardStyle,
buildInfoPanelCardTitleStyle,
buildInfoPanelCardGridStyleWithSection,
buildInfoPanelWrapperStyle,
buildImagesPreviewStyle,
} 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 is selected in the images section preview */
onSelectImage?: (imageId: string) => 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 InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
element,
onClose,
resolveUrl,
letterboxStyles,
cssVars,
onImageClick,
onSelectImage,
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);
// Track selected image per section (local UI state)
const [selectedImagePerSection, setSelectedImagePerSection] = useState<
Record<string, string>
>({});
// Section styles computed from element settings
const headerStyle = useMemo(
() => buildInfoPanelHeaderStyle(element),
[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],
);
const wrapperStyle = useMemo(
() => buildInfoPanelWrapperStyle(element),
[element],
);
const imagesPreviewStyle = useMemo(
() => buildImagesPreviewStyle(element),
[element],
);
// Get section instances
const sections = useMemo(() => getInfoPanelSections(element), [element]);
// 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) {
onClose();
}
},
[onClose, isEditMode],
);
// Handle touch on backdrop
const handleBackdropTouch = useCallback(
(e: React.TouchEvent) => {
if (e.target === overlayRef.current && !isEditMode) {
onClose();
}
},
[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 */}
<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: 'auto', // Ensure panel receives events
}}
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`,
}}
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;
// Image takes priority, otherwise render text
if (headerImageUrl) {
return (
<div
key={section.id}
style={{
...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} style={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={{
...titleStyle,
marginTop:
isEditMode && onPanelPositionChange
? '16px'
: undefined,
}}
>
{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={textStyle}>
{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) => (
<div key={span.id} style={spanStyle}>
{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
)}
</div>
))}
</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={() => onImageClick(image)}
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.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 || [];
// Filter to get only regular images (not 360°)
const imageItems = sectionImages.filter(
(img) => img.itemType !== '360',
);
// Get 360° items for trigger buttons
const triggerItems = sectionImages.filter(
(img) => img.itemType === '360',
);
// Nothing to show if no items
if (imageItems.length === 0 && triggerItems.length === 0)
return null;
// Use per-section selected state, fallback to first image
const selectedId =
selectedImagePerSection[section.id] || imageItems[0]?.id;
const selectedImage = imageItems.find(
(img) => img.id === selectedId,
);
// Handler to update selected image for this section
const handleSelectImage = (imageId: string) => {
setSelectedImagePerSection((prev) => ({
...prev,
[section.id]: imageId,
}));
// Also call the optional external handler
onSelectImage?.(imageId);
};
// 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))',
}}
>
{/* Large Preview - always visible */}
<div
style={{
...imagesPreviewStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{selectedImage?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(selectedImage.imageUrl)}
alt={selectedImage.caption || ''}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='text-white/40 text-sm'>
{imageItems.length === 0
? 'No images added'
: 'No image selected'}
</div>
)}
</div>
{/* Thumbnail Grid */}
<div style={gridStyle}>
{/* Image thumbnails - click to select for preview */}
{imageItems.map((img) => (
<button
key={img.id}
type='button'
style={{
...thumbnailStyle,
opacity: img.id === selectedId ? 1 : 0.6,
}}
className='transition-opacity hover:opacity-100 focus:outline-none'
onClick={() => handleSelectImage(img.id)}
aria-label={img.caption || 'Select image'}
aria-pressed={img.id === selectedId}
>
{img.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.imageUrl)}
alt=''
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex items-center justify-center text-white/40'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-6 h-6'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z'
/>
</svg>
</div>
)}
</button>
))}
{/* 360° trigger buttons - click to open 360° view */}
{triggerItems.map((img) => (
<button
key={img.id}
type='button'
style={thumbnailStyle}
className='trigger-360 focus:outline-none'
onClick={() => {
// Toggle behavior: if already open, close; otherwise open
if (active360ItemId === img.id) {
onImageClick(null);
} else {
onImageClick(img);
}
}}
aria-label={
active360ItemId === img.id
? 'Close 360° view'
: 'Open 360° view'
}
aria-pressed={active360ItemId === img.id}
>
{img.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(img.iconUrl)}
alt='360°'
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
draggable={false}
/>
) : (
<div className='w-full h-full flex flex-col items-center justify-center text-white/60'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
strokeWidth={1.5}
stroke='currentColor'
className='w-8 h-8 mb-1'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-4.247m0 0A8.966 8.966 0 0 1 3 12c0-1.97.633-3.79 1.706-5.27'
/>
</svg>
<span className='text-xs'>360°</span>
</div>
)}
</button>
))}
</div>
</div>
);
}
default:
return null;
}
})}
</div>
</div>
</div>
</>
);
};
export default InfoPanelOverlay;