added 360 panoramas supporting

This commit is contained in:
Dmitri 2026-05-30 11:15:50 +02:00
parent 990fc87b95
commit cd4ffb2c90
21 changed files with 774 additions and 163 deletions

View File

@ -11,7 +11,14 @@ class AssetsDBApi extends GenericDBApi {
}
static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
return [
'name',
'cdn_url',
'storage_key',
'mime_type',
'checksum',
'embed_provider',
];
}
static get RANGE_FIELDS() {
@ -83,8 +90,11 @@ class AssetsDBApi extends GenericDBApi {
width_px: data.width_px || null,
height_px: data.height_px || null,
duration_sec: data.duration_sec || null,
embed_code: data.embed_code || null,
embed_provider: data.embed_provider || null,
checksum: data.checksum || null,
is_public: data.is_public || false,
projectId: data.projectId || data.project || null,
};
}
}

View File

@ -220,7 +220,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
sort_order: 12,
default_settings_json: {
label: 'Info Panel',
// Trigger position
// Trigger position (width/height intentionally not set - sizes based on content)
xPercent: 5,
yPercent: 90,
infoPanelTriggerFontFamily: '',
@ -309,7 +309,13 @@ class Element_type_defaultsDBApi extends GenericDBApi {
{ id: 'default-header', type: 'header' },
{ id: 'default-title', type: 'title' },
{ 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: [] },
],
// Images section settings

View File

@ -0,0 +1,49 @@
'use strict';
/**
* Migration: Add embed asset support
*
* Adds support for storing embed assets (360 panoramas, 3D models, iframes):
* - Add 'embed' to asset_type ENUM
* - Add embed types to type ENUM (embed_360, embed_3d, embed_iframe)
* - Add embed_code column (stores full iframe HTML or direct URL)
* - Add embed_provider column (extracted provider name: matterport, kuula, etc.)
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Add 'embed' to asset_type ENUM
await queryInterface.sequelize.query(`
ALTER TYPE "enum_assets_asset_type" ADD VALUE IF NOT EXISTS 'embed';
`);
// Add embed types to type ENUM (categories)
await queryInterface.sequelize.query(`
ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_360';
`);
await queryInterface.sequelize.query(`
ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_3d';
`);
await queryInterface.sequelize.query(`
ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_iframe';
`);
// Add embed_code column (stores full iframe HTML or direct URL)
await queryInterface.addColumn('assets', 'embed_code', {
type: Sequelize.TEXT,
allowNull: true,
});
// Add embed_provider column (extracted: 'matterport', 'kuula', etc.)
await queryInterface.addColumn('assets', 'embed_provider', {
type: Sequelize.TEXT,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('assets', 'embed_code');
await queryInterface.removeColumn('assets', 'embed_provider');
// Note: PostgreSQL doesn't support removing ENUM values
},
};

View File

@ -22,7 +22,7 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.ENUM,
allowNull: false,
values: ['image', 'video', 'audio', 'file'],
values: ['image', 'video', 'audio', 'file', 'embed'],
},
type: {
@ -48,6 +48,12 @@ module.exports = function (sequelize, DataTypes) {
'document',
'general',
'embed_360',
'embed_3d',
'embed_iframe',
],
},
@ -85,6 +91,16 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.DECIMAL,
},
embed_code: {
type: DataTypes.TEXT,
allowNull: true,
},
embed_provider: {
type: DataTypes.TEXT,
allowNull: true,
},
checksum: {
type: DataTypes.TEXT,
},

View File

@ -21,13 +21,80 @@ const VALID_MIME_PATTERNS = {
prefixes: ['audio/'],
description: 'audio (mp3, wav, ogg, etc.)',
},
embed: {
// Embeds don't have MIME types - skip validation
prefixes: [],
description: 'embed (360/3D iframe)',
skipValidation: true,
},
};
/**
* Allowed domains for embed URLs (security: only trusted providers)
*/
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',
'360stories.com',
];
/**
* Extract and validate embed URL from iframe code or direct URL
* @param {string} embedCode - Full iframe HTML or direct URL
* @returns {{ url: string, provider: string }}
* @throws {ValidationError} If URL is invalid or domain not allowed
*/
function extractEmbedUrl(embedCode) {
if (!embedCode?.trim()) {
throw new ValidationError('Embed code is required');
}
// Extract src from iframe HTML
const srcMatch = embedCode.match(/src=["']([^"']+)["']/i);
const urlString = srcMatch ? srcMatch[1] : embedCode.trim();
let url;
try {
url = new URL(urlString);
} catch {
throw new ValidationError('Invalid embed URL format');
}
if (url.protocol !== 'https:') {
throw new ValidationError('Embed URL must use HTTPS');
}
const hostname = url.hostname.replace(/^www\./, '');
const isAllowed = ALLOWED_EMBED_DOMAINS.some(
(domain) => hostname === domain || hostname.endsWith('.' + domain),
);
if (!isAllowed) {
throw new ValidationError(
`Untrusted domain: ${hostname}. Allowed: ${ALLOWED_EMBED_DOMAINS.join(', ')}`,
);
}
// Extract provider name (e.g., 'matterport', 'kuula')
const provider = hostname.split('.').slice(-2, -1)[0];
return { url: urlString, provider };
}
/**
* Validate that mime_type matches asset_type
* @param {string} assetType - Expected asset type (image, video, audio)
* @param {string} assetType - Expected asset type (image, video, audio, embed)
* @param {string} mimeType - Actual MIME type of the file
* @returns {{ valid: boolean, error?: string }}
* @returns {{ valid: boolean, error?: string, skipValidation?: boolean }}
*/
function validateAssetMimeType(assetType, mimeType) {
// If no asset_type specified, skip validation
@ -42,6 +109,11 @@ function validateAssetMimeType(assetType, mimeType) {
return { valid: true };
}
// If patterns has skipValidation flag (e.g., embeds), skip MIME validation
if (patterns.skipValidation) {
return { valid: true, skipValidation: true };
}
// If no mime_type provided, we can't validate but allow it
// (browser may not always send mime type)
if (!mimeType) {
@ -75,7 +147,7 @@ const BaseService = createEntityService(AssetsDBApi, {
*/
class AssetsService extends BaseService {
/**
* Create asset with MIME type validation and video pre-processing
* Create asset with MIME type validation, embed URL validation, and video pre-processing
*/
static async create(data, currentUser) {
// Validate asset_type and mime_type match
@ -87,6 +159,13 @@ class AssetsService extends BaseService {
throw new ValidationError(validation.error);
}
// Handle embed assets: extract and validate URL, set provider
if (assetType === 'embed') {
const { url, provider } = extractEmbedUrl(data.embed_code);
data.cdn_url = url; // Store extracted URL for easy access
data.embed_provider = provider;
}
// Call parent create
const asset = await super.create(data, currentUser);
@ -98,7 +177,7 @@ class AssetsService extends BaseService {
}
/**
* Update asset with MIME type validation
* Update asset with MIME type validation and embed URL validation
*/
static async update(data, id, currentUser) {
// If updating asset_type or mime_type, validate they match
@ -115,6 +194,13 @@ class AssetsService extends BaseService {
}
}
// Handle embed assets: if embed_code is being updated, re-extract URL and provider
if (data.embed_code) {
const { url, provider } = extractEmbedUrl(data.embed_code);
data.cdn_url = url;
data.embed_provider = provider;
}
// Call parent update
return super.update(data, id, currentUser);
}

View File

@ -1,14 +1,13 @@
const { getNotification, isNotification } = require('../helpers');
const { getNotification } = require('../helpers');
module.exports = class ValidationError extends Error {
constructor(messageCode) {
let message;
if (messageCode && isNotification(messageCode)) {
message = getNotification(messageCode);
}
message = message || getNotification('errors.validation.message');
// getNotification returns the translated message if key exists,
// or the key itself as the message if not found
// This allows both notification keys and plain string messages
const message = messageCode
? getNotification(messageCode)
: getNotification('errors.validation.message');
super(message);
this.code = 400;

View File

@ -6,7 +6,7 @@ import UploadProgressList, { UploadQueueItem } from './UploadProgressList';
export type Asset = {
id: string;
name: string;
asset_type: 'image' | 'video' | 'audio' | 'file';
asset_type: 'image' | 'video' | 'audio' | 'file' | 'embed';
type?:
| 'icon'
| 'background_image'
@ -16,9 +16,14 @@ export type Asset = {
| 'logo'
| 'favicon'
| 'document'
| 'general';
| 'general'
| 'embed_360'
| 'embed_3d'
| 'embed_iframe';
cdn_url?: string | null;
mime_type?: string | null;
embed_code?: string | null;
embed_provider?: string | null;
};
export type AssetSection = {
@ -28,12 +33,14 @@ export type AssetSection = {
| 'audio'
| 'video'
| 'transitions'
| 'logo';
| 'logo'
| 'embeds';
label: string;
accept: string;
assetFormat: 'image' | 'video' | 'audio';
accept: string | null; // null for sections without file upload (embeds)
assetFormat: 'image' | 'video' | 'audio' | 'embed';
assetCategory: NonNullable<Asset['type']>;
legacyTag: string;
isEmbed?: boolean; // Flag to render EmbedAssetSection instead of file input
};
type AssetSectionCardProps = {

View File

@ -0,0 +1,273 @@
/**
* EmbedAssetSection Component
*
* Form for adding 360/3D embed assets (Matterport, Kuula, Sketchfab, etc.)
* Validates embed URLs against a trusted domain allowlist on the backend.
*/
import React, { useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { create, deleteItem } from '../../stores/assets/assetsSlice';
import { queryKeys } from '../../lib/queryClient';
import CardBox from '../CardBox';
import BaseButton from '../BaseButton';
import type { Asset } from './AssetSectionCard';
interface EmbedAssetSectionProps {
projectId: string;
onAssetCreated?: () => void;
hasCreatePermission: boolean;
hasDeletePermission?: boolean;
disabled: boolean;
}
type EmbedType = 'embed_360' | 'embed_3d' | 'embed_iframe';
const EMBED_TYPE_OPTIONS: { value: EmbedType; label: string }[] = [
{ value: 'embed_360', label: '360 Panorama' },
{ value: 'embed_3d', label: '3D Model' },
{ value: 'embed_iframe', label: 'Other Embed' },
];
const EmbedAssetSection: React.FC<EmbedAssetSectionProps> = ({
projectId,
onAssetCreated,
hasCreatePermission,
hasDeletePermission = false,
disabled,
}) => {
const dispatch = useAppDispatch();
const queryClient = useQueryClient();
const assets = useAppSelector((state) => state.assets.data) as Asset[];
const isLoadingAssets = useAppSelector((state) => state.assets.loading);
const [name, setName] = useState('');
const [embedCode, setEmbedCode] = useState('');
const [embedType, setEmbedType] = useState<EmbedType>('embed_360');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [isExpanded, setIsExpanded] = useState(false);
const [deletingAssetId, setDeletingAssetId] = useState('');
// Filter embed assets
const embedAssets = assets.filter(
(asset) =>
asset.type === 'embed_360' ||
asset.type === 'embed_3d' ||
asset.type === 'embed_iframe',
);
const handleSubmit = useCallback(async () => {
if (!embedCode.trim()) {
setError('Please enter embed code or URL');
return;
}
setIsSubmitting(true);
setError('');
try {
await dispatch(
create({
name: name.trim() || 'Embed',
asset_type: 'embed',
type: embedType,
embed_code: embedCode.trim(),
projectId,
} as Parameters<typeof create>[0]),
).unwrap();
// Reset form
setName('');
setEmbedCode('');
// Invalidate React Query assets cache so constructor sees the new embed
await queryClient.invalidateQueries({ queryKey: queryKeys.assets.all });
onAssetCreated?.();
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: (err as { message?: string })?.message || 'Failed to save embed';
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
}, [
dispatch,
queryClient,
name,
embedCode,
embedType,
projectId,
onAssetCreated,
]);
const handleDeleteAsset = useCallback(
async (assetId: string) => {
setDeletingAssetId(assetId);
try {
await dispatch(deleteItem(assetId)).unwrap();
// Invalidate React Query assets cache so constructor sees the deletion
await queryClient.invalidateQueries({ queryKey: queryKeys.assets.all });
onAssetCreated?.(); // Refresh assets list
} catch {
// Error handling is done globally
} finally {
setDeletingAssetId('');
}
},
[dispatch, queryClient, onAssetCreated],
);
return (
<CardBox className='h-full'>
<div className='flex items-center justify-between gap-3 mb-4'>
<h3 className='text-lg font-semibold'>360/3D Embeds</h3>
{!hasCreatePermission && (
<p className='text-xs text-gray-500'>
You do not have upload permission
</p>
)}
</div>
<div className='space-y-3'>
<div>
<label className='mb-1 block text-xs text-gray-600 dark:text-gray-400'>
Name
</label>
<input
type='text'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='My 360 Tour'
disabled={disabled || !hasCreatePermission || isSubmitting}
className='w-full border border-gray-300 dark:border-dark-700 rounded px-3 py-2 text-sm bg-white dark:bg-dark-800'
/>
</div>
<div>
<label className='mb-1 block text-xs text-gray-600 dark:text-gray-400'>
Type
</label>
<select
value={embedType}
onChange={(e) => setEmbedType(e.target.value as EmbedType)}
disabled={disabled || !hasCreatePermission || isSubmitting}
className='w-full border border-gray-300 dark:border-dark-700 rounded px-3 py-2 text-sm bg-white dark:bg-dark-800'
>
{EMBED_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-xs text-gray-600 dark:text-gray-400'>
Embed Code or URL
</label>
<textarea
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
placeholder={`Paste iframe code or direct URL:\n<iframe src="https://my.matterport.com/show/?m=..." ...></iframe>\nor\nhttps://kuula.co/share/...`}
rows={4}
disabled={disabled || !hasCreatePermission || isSubmitting}
className='w-full border border-gray-300 dark:border-dark-700 rounded px-3 py-2 font-mono text-xs bg-white dark:bg-dark-800'
/>
<p className='mt-1 text-[10px] text-gray-500'>
Allowed: Matterport, Kuula, Roundme, Sketchfab, YouTube, Vimeo,
Google Maps, 360stories
</p>
</div>
{error && <p className='text-xs text-red-500'>{error}</p>}
<BaseButton
color='info'
label={isSubmitting ? 'Saving...' : 'Add Embed'}
disabled={disabled || !hasCreatePermission || isSubmitting}
onClick={handleSubmit}
/>
</div>
{/* Existing embeds list */}
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-dark-700'>
{isLoadingAssets && (
<p className='text-sm text-gray-500'>Loading assets...</p>
)}
{!isLoadingAssets && embedAssets.length > 0 && (
<button
type='button'
className='mb-2 text-xs underline'
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded
? 'Hide uploaded embeds'
: `Show uploaded embeds (${embedAssets.length})`}
</button>
)}
{!isLoadingAssets && embedAssets.length === 0 && (
<p className='text-sm text-gray-500'>No uploaded embeds.</p>
)}
{!isLoadingAssets && embedAssets.length > 0 && isExpanded && (
<ul className='space-y-2'>
{embedAssets.map((asset) => (
<li
key={asset.id}
className='flex items-center justify-between gap-2 p-2 border border-gray-200 dark:border-dark-700 rounded'
>
<div className='flex items-center gap-2 flex-1 min-w-0'>
<span className='text-lg shrink-0'>
{asset.type === 'embed_360'
? '360'
: asset.type === 'embed_3d'
? '3D'
: 'if'}
</span>
<div className='min-w-0 flex-1'>
<span className='text-sm truncate block'>{asset.name}</span>
{asset.embed_provider && (
<span className='text-[10px] text-gray-500'>
{asset.embed_provider}
</span>
)}
</div>
{asset.cdn_url && (
<a
href={asset.cdn_url}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-500 hover:underline shrink-0'
>
Preview
</a>
)}
</div>
<BaseButton
color='danger'
label='X'
small
disabled={
deletingAssetId === asset.id || !hasDeletePermission
}
onClick={() => handleDeleteAsset(asset.id)}
/>
</li>
))}
</ul>
)}
</div>
</CardBox>
);
};
export default EmbedAssetSection;

View File

@ -50,25 +50,6 @@ interface InfoPanelSettingsSectionCompactProps {
patch: Partial<InfoPanelImage>,
) => void;
onRemoveImage?: (sectionId: string, imageId: string) => void;
// Legacy props (deprecated)
/** @deprecated Use onAddImage with sectionId instead */
onAddImageLegacy?: () => void;
/** @deprecated Use onUpdateImage with sectionId instead */
onUpdateImageLegacy?: (
imageId: string,
patch: Partial<InfoPanelImage>,
) => void;
/** @deprecated Use onRemoveImage with sectionId instead */
onRemoveImageLegacy?: (imageId: string) => void;
/** @deprecated Use onAddSpan with sectionId instead */
onAddInfoSpanLegacy?: () => void;
/** @deprecated Use onUpdateSpan with sectionId instead */
onUpdateInfoSpanLegacy?: (
spanId: string,
patch: Partial<InfoPanelInfoSpan>,
) => void;
/** @deprecated Use onRemoveSpan with sectionId instead */
onRemoveInfoSpanLegacy?: (spanId: string) => void;
}
const CollapsibleSection: React.FC<{
@ -654,19 +635,25 @@ const InfoPanelSettingsSectionCompact: React.FC<
</button>
)}
</div>
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
<input
type='checkbox'
checked={image.isEmbed || false}
{/* Type selector */}
<div>
<label className='mb-0.5 block text-[10px] font-medium text-white/70'>
Type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={image.itemType || 'image'}
onChange={(e) =>
onUpdateImage?.(section.id, image.id, {
isEmbed: e.target.checked,
itemType: e.target.value as 'image' | '360',
})
}
/>
Is 360/Embed
</label>
{image.isEmbed ? (
>
<option value='image'>Image</option>
<option value='360'>360° Embed</option>
</select>
</div>
{image.itemType === '360' ? (
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={image.embedUrl || ''}
@ -894,9 +881,8 @@ const InfoPanelSettingsSectionCompact: React.FC<
{/* Items list */}
{sectionImages.map((image, imgIndex) => {
// Determine item type with backward compatibility
const currentItemType: InfoPanelItemType =
image.itemType || (image.isEmbed ? '360' : 'image');
image.itemType || 'image';
return (
<div
@ -931,10 +917,8 @@ const InfoPanelSettingsSectionCompact: React.FC<
onChange={(e) => {
const newType = e.target
.value as InfoPanelItemType;
// Update itemType and clear isEmbed for clean migration
onUpdateImage?.(section.id, image.id, {
itemType: newType,
isEmbed: undefined,
});
}}
>

View File

@ -994,9 +994,7 @@ export default function RuntimePresentation({
setRuntimeSelectedImageId(imageId)
}
active360ItemId={
activeDetailImage &&
(activeDetailImage.isEmbed ||
activeDetailImage.itemType === '360')
activeDetailImage?.itemType === '360'
? activeDetailImage.id
: null
}

View File

@ -35,6 +35,7 @@ const ALLOWED_EMBED_DOMAINS = [
'www.google.com',
'docs.google.com',
'drive.google.com',
'360stories.com',
];
/**
@ -77,6 +78,7 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
const [isVisible, setIsVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [embedError, setEmbedError] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Drag state for edit mode
const [isDragging, setIsDragging] = useState(false);
@ -92,10 +94,11 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
requestAnimationFrame(() => setIsVisible(true));
}, []);
// Keyboard navigation (ESC to close)
// Keyboard navigation (ESC to close) - only when not in fullscreen
// (fullscreen mode handles ESC itself to exit fullscreen first)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && !document.fullscreenElement) {
e.stopPropagation();
onClose();
}
@ -112,16 +115,77 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
panel.focus();
}, []);
// Handle fullscreen change events (user may exit via ESC or browser controls)
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, []);
// Toggle fullscreen mode
const toggleFullscreen = useCallback(async () => {
const panel = panelRef.current;
if (!panel) return;
try {
if (!document.fullscreenElement) {
// Enter fullscreen
if (panel.requestFullscreen) {
await panel.requestFullscreen();
} else if ((panel as HTMLDivElement & { webkitRequestFullscreen?: () => Promise<void> }).webkitRequestFullscreen) {
// Safari fallback
await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise<void> }).webkitRequestFullscreen();
}
} else {
// Exit fullscreen
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if ((document as Document & { webkitExitFullscreen?: () => Promise<void> }).webkitExitFullscreen) {
// Safari fallback
await (document as Document & { webkitExitFullscreen: () => Promise<void> }).webkitExitFullscreen();
}
}
} catch {
// Fullscreen may be blocked or not supported (iOS Safari) - silently ignore
}
}, []);
// Handle backdrop click (disabled in edit mode)
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isEditMode) {
// Exit fullscreen first if active, then close
if (document.fullscreenElement) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
document.exitFullscreen().catch(() => {});
}
onClose();
}
},
[onClose, isEditMode],
);
// Handle close with fullscreen exit
const handleClose = useCallback(async () => {
// Exit fullscreen before closing if in fullscreen mode
if (document.fullscreenElement) {
try {
await document.exitFullscreen();
} catch {
// Ignore errors
}
}
onClose();
}, [onClose]);
// Extract detail panel styling from element
const detailXPercent = element.detailXPercent ?? 75;
const detailYPercent = element.detailYPercent ?? 50;
@ -146,41 +210,63 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
// Note: ImageDetailPanel doesn't render its own overlay backdrop.
// The parent InfoPanelOverlay already provides the backdrop when the info panel is open.
// Convert numeric values to px if needed
const toPx = (value: string): string => {
const trimmed = value.trim();
if (/[a-z%]+$/i.test(trimmed)) return trimmed;
const num = parseFloat(trimmed);
if (!Number.isFinite(num)) return trimmed;
return `${num}px`;
};
// Panel style
const panelStyle: React.CSSProperties = {
position: 'absolute',
left: `${detailXPercent}%`,
top: `${detailYPercent}%`,
transform: 'translate(-50%, -50%)',
width: `min(${toPx(detailWidth)}, calc(100vw - 32px))`,
height: `min(${toPx(detailHeight)}, calc(100dvh - 64px))`,
backgroundColor: detailBackgroundColor,
borderRadius: toPx(detailBorderRadius),
padding: toPx(detailPadding),
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transition: 'opacity 200ms ease-out',
border:
detailBorderWidth !== '0' && detailBorderWidth
? `${toPx(detailBorderWidth)} ${detailBorderStyle} ${detailBorderColor}`
: 'none',
};
// Determine content type (handle null image for edit mode placeholder)
const isEmbed = image?.isEmbed && image?.embedUrl;
const isEmbed = image?.itemType === '360' && image?.embedUrl;
const embedUrl = image?.embedUrl ?? '';
const isValidEmbed = isEmbed && isValidEmbedUrl(embedUrl);
const hasImage = !!image;
// 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 - fullscreen mode overrides positioning and sizing
const panelStyle: React.CSSProperties = isFullscreen
? {
position: 'fixed',
inset: 0,
width: '100%',
height: '100%',
backgroundColor: '#000',
borderRadius: 0,
padding: 0,
overflow: 'hidden',
opacity: 1,
border: 'none',
zIndex: 9999,
}
: {
position: 'absolute',
left: `${detailXPercent}%`,
top: `${detailYPercent}%`,
transform: 'translate(-50%, -50%)',
width: `min(${toCU(detailWidth)}, calc(100vw - 32px))`,
height: `min(${toCU(detailHeight)}, calc(100dvh - 64px))`,
backgroundColor: detailBackgroundColor,
borderRadius: toCU(detailBorderRadius),
padding: isEmbed ? 0 : toCU(detailPadding),
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transition: 'opacity 200ms ease-out',
border:
detailBorderWidth !== '0' && detailBorderWidth
? `${toCU(detailBorderWidth)} ${detailBorderStyle} ${detailBorderColor}`
: 'none',
};
// Handle iframe load
const handleIframeLoad = () => {
setIsLoading(false);
@ -303,28 +389,73 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
isEditMode && onDetailPositionChange ? handleDragStart : undefined
}
>
{/* Close button */}
<button
type='button'
className='absolute top-2 right-2 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={onClose}
aria-label='Close detail view'
>
<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>
{/* Control buttons - only show for regular images (embeds have their own controls) */}
{!isEmbed && image?.imageUrl && (
<>
{/* Close button */}
<button
type='button'
className='absolute top-2 right-2 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={handleClose}
aria-label='Close detail view'
>
<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>
{/* Fullscreen toggle button */}
<button
type='button'
className='absolute top-2 right-10 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
onClick={toggleFullscreen}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<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='M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25'
/>
</svg>
) : (
<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='M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15'
/>
</svg>
)}
</button>
</>
)}
{/* Loading spinner */}
{isLoading && (

View File

@ -245,13 +245,21 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
const isOverlayVisible =
panelOverlayColor !== 'transparent' && panelOverlayColor !== 'none';
// Convert numeric values to px if needed
const toPx = (value: string): string => {
// 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 `${num}px`;
return `calc(${num} * var(--cu, 1px))`;
};
// Panel style with responsive constraints
@ -263,28 +271,28 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
width:
panelWidth === 'auto'
? 'auto'
: `min(${toPx(panelWidth)}, calc(100vw - 32px))`,
: `min(${toCU(panelWidth)}, calc(100vw - 32px))`,
maxHeight:
panelHeight === 'auto'
? 'calc(100dvh - 64px)'
: `min(${toPx(panelHeight)}, calc(100dvh - 64px))`,
: `min(${toCU(panelHeight)}, calc(100dvh - 64px))`,
backgroundColor: panelBackgroundColor,
borderRadius: toPx(panelBorderRadius),
borderRadius: toCU(panelBorderRadius),
border:
panelBorderWidth !== '0' && panelBorderWidth
? `${toPx(panelBorderWidth)} ${panelBorderStyle} ${panelBorderColor}`
? `${toCU(panelBorderWidth)} ${panelBorderStyle} ${panelBorderColor}`
: 'none',
padding: toPx(panelPadding),
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(${toPx(panelPadding)}, env(safe-area-inset-top))`,
paddingRight: `max(${toPx(panelPadding)}, env(safe-area-inset-right))`,
paddingBottom: `max(${toPx(panelPadding)}, env(safe-area-inset-bottom))`,
paddingLeft: `max(${toPx(panelPadding)}, env(safe-area-inset-left))`,
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 (
@ -335,7 +343,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
<div
className='absolute top-0 left-0 right-0 h-8 cursor-grab active:cursor-grabbing rounded-t-xl'
style={{
borderRadius: `${toPx(panelBorderRadius)} ${toPx(panelBorderRadius)} 0 0`,
borderRadius: `${toCU(panelBorderRadius)} ${toCU(panelBorderRadius)} 0 0`,
}}
onMouseDown={handleDragStart}
>
@ -375,7 +383,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
pointerEvents: isEditMode ? 'none' : 'auto',
display: 'flex',
flexDirection: 'column',
gap: toPx(element.infoPanelSectionGap ?? '12'),
gap: toCU(element.infoPanelSectionGap ?? '12'),
}}
>
{/* Render sections in dynamic order using section instances */}
@ -521,7 +529,7 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
onClick={() => onImageClick(image)}
aria-label={image.caption || 'View image'}
>
{image.isEmbed ? (
{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
@ -599,14 +607,11 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
// Filter to get only regular images (not 360°)
const imageItems = sectionImages.filter(
(img) =>
img.itemType === 'image' ||
(!img.itemType && !img.isEmbed),
(img) => img.itemType !== '360',
);
// Get 360° items for trigger buttons
const triggerItems = sectionImages.filter(
(img) =>
img.itemType === '360' || (!img.itemType && img.isEmbed),
(img) => img.itemType === '360',
);
// Nothing to show if no items

View File

@ -94,8 +94,10 @@ export function useElementWrapperStyle({
: 'border shadow border-blue-200 bg-white/95',
// Overflow for icon-driven elements
hasIconDrivenSize ? 'overflow-hidden leading-none' : '',
// Flex centering for navigation elements (both icons and text)
isNavigationElement ? 'flex items-center justify-center' : '',
// Flex centering for navigation elements and info panel trigger (both icons and text)
isNavigationElement || isInfoPanelElementType(element.type)
? 'flex items-center justify-center'
: '',
// Constructor-specific states (only applied when in constructor)
isEditMode ? 'cursor-move' : 'cursor-pointer',
]

View File

@ -98,11 +98,14 @@ export function useAssetOptions({
// Icon assets
const iconOptions = useMemo(() => buildIconAssetOptions(assets), [assets]);
// Embed assets (360° panoramas, iframes) - filter by type='embed'
// Embed assets (360° panoramas, iframes) - filter by asset_type='embed'
const embedOptions = useMemo(
() =>
assets
.filter((asset) => asset.type === 'embed' && getAssetSourceValue(asset))
.filter(
(asset) =>
asset.asset_type === 'embed' && getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: asset.name || getAssetSourceValue(asset),

View File

@ -185,6 +185,7 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
iconUrl: '',
infoPanelTriggerLabel: 'Info',
infoPanelDisabled: false,
// Width/height intentionally not set - trigger sizes based on content (icon or text)
},
};
@ -295,7 +296,7 @@ export const normalizeInfoPanelImage = (
imageUrl: image?.imageUrl ? String(image.imageUrl) : undefined,
embedUrl: image?.embedUrl ? String(image.embedUrl) : undefined,
caption: image?.caption ? String(image.caption) : undefined,
isEmbed: Boolean(image?.isEmbed),
itemType: (image?.itemType as 'image' | '360') || 'image',
});
/**

View File

@ -24,6 +24,7 @@ import AssetSectionCard, {
Asset,
AssetSection,
} from '../../components/Assets/AssetSectionCard';
import EmbedAssetSection from '../../components/Assets/EmbedAssetSection';
import { useProjectSelector } from '../../components/Assets/ProjectSelector';
import { useAssetUploader } from '../../components/Assets/useAssetUploader';
import { logger } from '../../lib/logger';
@ -77,6 +78,15 @@ const ASSET_SECTIONS: AssetSection[] = [
assetCategory: 'logo',
legacyTag: 'LOGO',
},
{
key: 'embeds',
label: '360/3D Embeds',
accept: null, // No file upload for embeds
assetFormat: 'embed',
assetCategory: 'embed_360',
legacyTag: 'EMBED',
isEmbed: true,
},
];
const AssetsTablesPage = () => {
@ -157,6 +167,14 @@ const AssetsTablesPage = () => {
const assetsBySection = useMemo(() => {
return ASSET_SECTIONS.reduce<Record<string, Asset[]>>((acc, section) => {
acc[section.key] = assets.filter((asset) => {
// For embed sections, match any embed type (embed_360, embed_3d, embed_iframe)
if (section.isEmbed) {
return (
asset.type === 'embed_360' ||
asset.type === 'embed_3d' ||
asset.type === 'embed_iframe'
);
}
if (asset.type) return asset.type === section.assetCategory;
return asset.name?.startsWith(`[${section.legacyTag}] `);
});
@ -185,22 +203,33 @@ const AssetsTablesPage = () => {
</p>
<div className='grid grid-cols-1 xl:grid-cols-2 gap-4'>
{ASSET_SECTIONS.map((section) => (
<AssetSectionCard
key={section.key}
section={section}
assets={assetsBySection[section.key] || []}
uploadQueue={uploadQueues[section.key] || []}
isUploading={uploadingSections.includes(section.key)}
isLoadingAssets={isLoadingAssets}
hasCreatePermission={hasCreatePermission}
hasDeletePermission={hasDeletePermission}
deletingAssetId={deletingAssetId}
onUpload={(files) => runBatchUpload(section, files)}
onDeleteAsset={handleDeleteAsset}
disabled={!selectedProjectId}
/>
))}
{ASSET_SECTIONS.map((section) =>
section.isEmbed ? (
<EmbedAssetSection
key={section.key}
projectId={selectedProjectId}
onAssetCreated={() => loadAssets(selectedProjectId)}
hasCreatePermission={hasCreatePermission}
hasDeletePermission={hasDeletePermission}
disabled={!selectedProjectId}
/>
) : (
<AssetSectionCard
key={section.key}
section={section}
assets={assetsBySection[section.key] || []}
uploadQueue={uploadQueues[section.key] || []}
isUploading={uploadingSections.includes(section.key)}
isLoadingAssets={isLoadingAssets}
hasCreatePermission={hasCreatePermission}
hasDeletePermission={hasDeletePermission}
deletingAssetId={deletingAssetId}
onUpload={(files) => runBatchUpload(section, files)}
onDeleteAsset={handleDeleteAsset}
disabled={!selectedProjectId}
/>
),
)}
</div>
</SectionMain>
<ToastContainer />

View File

@ -2032,9 +2032,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: undefined
}
active360ItemId={
activeDetailImage &&
(activeDetailImage.isEmbed ||
activeDetailImage.itemType === '360')
activeDetailImage?.itemType === '360'
? activeDetailImage.id
: null
}

View File

@ -7,7 +7,7 @@ import { z } from 'zod';
export const assetSchema = z.object({
project: z.unknown().optional().nullable(),
name: z.string().min(1, 'Name is required').max(255, 'Name too long'),
asset_type: z.enum(['image', 'video', 'audio', 'file']),
asset_type: z.enum(['image', 'video', 'audio', 'file', 'embed']),
type: z
.enum([
'general',
@ -19,6 +19,9 @@ export const assetSchema = z.object({
'logo',
'favicon',
'document',
'embed_360',
'embed_3d',
'embed_iframe',
])
.default('general'),
cdn_url: z.string().url('Invalid URL').optional().or(z.literal('')),
@ -59,6 +62,8 @@ export const assetSchema = z.object({
.max(255, 'Checksum too long')
.optional()
.or(z.literal('')),
embed_code: z.string().optional().or(z.literal('')),
embed_provider: z.string().optional().or(z.literal('')),
is_public: z.boolean().default(false),
is_deleted: z.boolean().default(false),
deleted_at_time: z.date().optional().nullable(),
@ -79,6 +84,8 @@ export const assetInitialValues: AssetFormData = {
height_px: '',
duration_sec: '',
checksum: '',
embed_code: '',
embed_provider: '',
is_public: false,
is_deleted: false,
deleted_at_time: null,

View File

@ -102,8 +102,6 @@ export interface InfoPanelImage {
imageUrl?: string; // Regular image URL (storage key)
embedUrl?: string; // 360/3D embed URL (direct URL, e.g., https://my.matterport.com/show/?m=...)
caption?: string;
/** @deprecated Use itemType instead - keep for backward compatibility */
isEmbed?: boolean; // Flag to distinguish image vs embed
/** Item type: 'image' for inline preview, '360' for embed trigger */
itemType?: InfoPanelItemType;
/** Custom icon URL for 360° trigger button */
@ -551,7 +549,7 @@ export interface UiElementDefault {
export interface ConstructorAsset {
id: string;
name?: string;
asset_type?: 'image' | 'video' | 'audio' | 'file';
asset_type?: 'image' | 'video' | 'audio' | 'file' | 'embed';
type?:
| 'icon'
| 'background_image'
@ -562,9 +560,13 @@ export interface ConstructorAsset {
| 'favicon'
| 'document'
| 'general'
| 'embed';
| 'embed_360'
| 'embed_3d'
| 'embed_iframe';
cdn_url?: string | null;
storage_key?: string | null;
embed_code?: string | null;
embed_provider?: string | null;
}
/**

View File

@ -57,8 +57,9 @@ export interface Project extends BaseEntity {
// Asset entity
export interface Asset extends BaseEntity {
project?: Project | string | null;
projectId?: string;
name: string;
asset_type: 'image' | 'video' | 'audio' | 'file';
asset_type: 'image' | 'video' | 'audio' | 'file' | 'embed';
type?:
| 'general'
| 'icon'
@ -69,7 +70,9 @@ export interface Asset extends BaseEntity {
| 'logo'
| 'favicon'
| 'document'
| 'embed';
| 'embed_360'
| 'embed_3d'
| 'embed_iframe';
cdn_url?: string;
storage_key?: string;
mime_type?: string;
@ -77,6 +80,8 @@ export interface Asset extends BaseEntity {
width_px?: number;
height_px?: number;
duration_sec?: number;
embed_code?: string;
embed_provider?: string;
checksum?: string;
is_public?: boolean;
is_deleted?: boolean;

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
@ -13,7 +13,7 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",