added 360 panoramas supporting
This commit is contained in:
parent
990fc87b95
commit
cd4ffb2c90
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
273
frontend/src/components/Assets/EmbedAssetSection.tsx
Normal file
273
frontend/src/components/Assets/EmbedAssetSection.tsx
Normal 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;
|
||||
@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@ -994,9 +994,7 @@ export default function RuntimePresentation({
|
||||
setRuntimeSelectedImageId(imageId)
|
||||
}
|
||||
active360ItemId={
|
||||
activeDetailImage &&
|
||||
(activeDetailImage.isEmbed ||
|
||||
activeDetailImage.itemType === '360')
|
||||
activeDetailImage?.itemType === '360'
|
||||
? activeDetailImage.id
|
||||
: null
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -2032,9 +2032,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
: undefined
|
||||
}
|
||||
active360ItemId={
|
||||
activeDetailImage &&
|
||||
(activeDetailImage.isEmbed ||
|
||||
activeDetailImage.itemType === '360')
|
||||
activeDetailImage?.itemType === '360'
|
||||
? activeDetailImage.id
|
||||
: null
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user