updates gallery and carousel settings
This commit is contained in:
parent
7b21006086
commit
024c04e05a
@ -17,6 +17,16 @@ const config = {
|
|||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||||
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
|
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
|
||||||
|
// Timeout configuration (in milliseconds)
|
||||||
|
connectionTimeout: parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000,
|
||||||
|
requestTimeout: parseInt(process.env.AWS_S3_REQUEST_TIMEOUT, 10) || 30000,
|
||||||
|
// Retry configuration
|
||||||
|
maxAttempts: parseInt(process.env.AWS_S3_MAX_ATTEMPTS, 10) || 3,
|
||||||
|
// Connection pool configuration
|
||||||
|
maxSockets: parseInt(process.env.AWS_S3_MAX_SOCKETS, 10) || 50,
|
||||||
|
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false',
|
||||||
|
// Presigned URL expiry (in seconds)
|
||||||
|
presignExpirySeconds: parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600,
|
||||||
},
|
},
|
||||||
bcrypt: {
|
bcrypt: {
|
||||||
saltRounds: 12,
|
saltRounds: 12,
|
||||||
|
|||||||
@ -2,6 +2,9 @@ const express = require('express');
|
|||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const services = require('../services/file/');
|
const services = require('../services/file/');
|
||||||
|
const { isValidPath, createErrorResponse } = require('../services/file');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// JSON body parser that ONLY parses application/json content-type
|
// JSON body parser that ONLY parses application/json content-type
|
||||||
@ -20,32 +23,40 @@ router.get('/download', (req, res) => {
|
|||||||
|
|
||||||
// POST /api/file/presign - Generate presigned URLs for multiple assets
|
// POST /api/file/presign - Generate presigned URLs for multiple assets
|
||||||
router.post('/presign', jsonParser, async (req, res) => {
|
router.post('/presign', jsonParser, async (req, res) => {
|
||||||
|
const log = req.log || logger;
|
||||||
const { urls } = req.body || {};
|
const { urls } = req.body || {};
|
||||||
|
|
||||||
if (!Array.isArray(urls) || urls.length === 0) {
|
if (!Array.isArray(urls) || urls.length === 0) {
|
||||||
return res.status(400).json({ error: 'urls array required' });
|
return res.status(400).json(createErrorResponse('urls array required', 'MISSING_URLS'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urls.length > 50) {
|
if (urls.length > 50) {
|
||||||
return res.status(400).json({ error: 'Maximum 50 URLs per request' });
|
return res.status(400).json(createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that all URLs are strings
|
// Validate that all URLs are non-empty strings
|
||||||
const invalidUrls = urls.filter(
|
const invalidUrls = urls.filter(
|
||||||
(url) => typeof url !== 'string' || !url.trim(),
|
(url) => typeof url !== 'string' || !url.trim(),
|
||||||
);
|
);
|
||||||
if (invalidUrls.length > 0) {
|
if (invalidUrls.length > 0) {
|
||||||
return res
|
return res.status(400).json(createErrorResponse('All URLs must be non-empty strings', 'INVALID_URL_FORMAT'));
|
||||||
.status(400)
|
}
|
||||||
.json({ error: 'All URLs must be non-empty strings' });
|
|
||||||
|
// Validate paths for security (no traversal, no protocols)
|
||||||
|
const unsafeUrls = urls.filter((url) => !isValidPath(url));
|
||||||
|
if (unsafeUrls.length > 0) {
|
||||||
|
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
|
||||||
|
return res.status(400).json(createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
|
||||||
|
invalidPaths: unsafeUrls,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const presignedUrls = await services.generatePresignedUrls(urls);
|
const presignedUrls = await services.generatePresignedUrls(urls);
|
||||||
res.json({ presignedUrls });
|
res.json({ presignedUrls });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate presigned URLs', error);
|
log.error({ err: error, urlCount: urls.length }, 'Failed to generate presigned URLs');
|
||||||
res.status(500).json({ error: 'Failed to generate presigned URLs' });
|
res.status(500).json(createErrorResponse('Failed to generate presigned URLs', 'PRESIGN_ERROR'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,114 @@
|
|||||||
const AssetsDBApi = require('../db/api/assets');
|
const AssetsDBApi = require('../db/api/assets');
|
||||||
const { createEntityService } = require('../factories/service.factory');
|
const { createEntityService } = require('../factories/service.factory');
|
||||||
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
|
||||||
module.exports = createEntityService(AssetsDBApi, {
|
/**
|
||||||
|
* Valid MIME type patterns for each asset type
|
||||||
|
*/
|
||||||
|
const VALID_MIME_PATTERNS = {
|
||||||
|
image: {
|
||||||
|
prefixes: ['image/'],
|
||||||
|
description: 'image (jpeg, png, gif, webp, svg, etc.)',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
prefixes: ['video/'],
|
||||||
|
description: 'video (mp4, webm, mov, etc.)',
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
prefixes: ['audio/'],
|
||||||
|
description: 'audio (mp3, wav, ogg, etc.)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that mime_type matches asset_type
|
||||||
|
* @param {string} assetType - Expected asset type (image, video, audio)
|
||||||
|
* @param {string} mimeType - Actual MIME type of the file
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateAssetMimeType(assetType, mimeType) {
|
||||||
|
// If no asset_type specified, skip validation
|
||||||
|
if (!assetType) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = VALID_MIME_PATTERNS[assetType];
|
||||||
|
|
||||||
|
// If asset_type is not one we validate (e.g., 'file'), skip validation
|
||||||
|
if (!patterns) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no mime_type provided, we can't validate but allow it
|
||||||
|
// (browser may not always send mime type)
|
||||||
|
if (!mimeType) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMime = String(mimeType).toLowerCase().trim();
|
||||||
|
|
||||||
|
// Check if mime_type matches any of the valid prefixes
|
||||||
|
const isValid = patterns.prefixes.some((prefix) =>
|
||||||
|
normalizedMime.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create base service from factory
|
||||||
|
const BaseService = createEntityService(AssetsDBApi, {
|
||||||
entityName: 'assets',
|
entityName: 'assets',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assets Service with validation
|
||||||
|
*/
|
||||||
|
class AssetsService extends BaseService {
|
||||||
|
/**
|
||||||
|
* Create asset with MIME type validation
|
||||||
|
*/
|
||||||
|
static async create(data, currentUser) {
|
||||||
|
// Validate asset_type and mime_type match
|
||||||
|
const assetType = data.asset_type;
|
||||||
|
const mimeType = data.mime_type;
|
||||||
|
|
||||||
|
const validation = validateAssetMimeType(assetType, mimeType);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new ValidationError(validation.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call parent create
|
||||||
|
return super.create(data, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update asset with MIME type validation
|
||||||
|
*/
|
||||||
|
static async update(data, id, currentUser) {
|
||||||
|
// If updating asset_type or mime_type, validate they match
|
||||||
|
if (data.asset_type || data.mime_type) {
|
||||||
|
const assetType = data.asset_type;
|
||||||
|
const mimeType = data.mime_type;
|
||||||
|
|
||||||
|
// Only validate if both are provided in the update
|
||||||
|
if (assetType && mimeType) {
|
||||||
|
const validation = validateAssetMimeType(assetType, mimeType);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new ValidationError(validation.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call parent update
|
||||||
|
return super.update(data, id, currentUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AssetsService;
|
||||||
|
|||||||
@ -3,6 +3,12 @@
|
|||||||
*
|
*
|
||||||
* Unified file storage service using Strategy Pattern providers.
|
* Unified file storage service using Strategy Pattern providers.
|
||||||
* Supports S3, GCloud, and Local storage backends.
|
* Supports S3, GCloud, and Local storage backends.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Comprehensive error handling with proper HTTP status codes
|
||||||
|
* - AbortController support for request cancellation
|
||||||
|
* - Structured logging with Pino
|
||||||
|
* - Path validation for security
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@ -11,6 +17,7 @@ const { pipeline } = require('stream/promises');
|
|||||||
const { format } = require('util');
|
const { format } = require('util');
|
||||||
|
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
const S3StorageProvider = require('./file/S3StorageProvider');
|
const S3StorageProvider = require('./file/S3StorageProvider');
|
||||||
const LocalStorageProvider = require('./file/LocalStorageProvider');
|
const LocalStorageProvider = require('./file/LocalStorageProvider');
|
||||||
const UploadSessionManager = require('./file/UploadSessionManager');
|
const UploadSessionManager = require('./file/UploadSessionManager');
|
||||||
@ -52,7 +59,22 @@ const getS3Provider = () => {
|
|||||||
accessKeyId: config.s3.accessKeyId,
|
accessKeyId: config.s3.accessKeyId,
|
||||||
secretAccessKey: config.s3.secretAccessKey,
|
secretAccessKey: config.s3.secretAccessKey,
|
||||||
prefix: config.s3.prefix,
|
prefix: config.s3.prefix,
|
||||||
|
// Timeout and connection pool configuration from config
|
||||||
|
connectionTimeout: config.s3.connectionTimeout,
|
||||||
|
requestTimeout: config.s3.requestTimeout,
|
||||||
|
maxAttempts: config.s3.maxAttempts,
|
||||||
|
maxSockets: config.s3.maxSockets,
|
||||||
|
keepAlive: config.s3.keepAlive,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
provider: 's3',
|
||||||
|
bucket: config.s3.bucket,
|
||||||
|
region: config.s3.region,
|
||||||
|
connectionTimeout: config.s3.connectionTimeout,
|
||||||
|
requestTimeout: config.s3.requestTimeout,
|
||||||
|
maxAttempts: config.s3.maxAttempts,
|
||||||
|
}, 'S3 storage provider initialized');
|
||||||
}
|
}
|
||||||
return s3Provider;
|
return s3Provider;
|
||||||
};
|
};
|
||||||
@ -91,21 +113,100 @@ const getUploadSessionManager = () => {
|
|||||||
return uploadSessionManager;
|
return uploadSessionManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Handling Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized error response format
|
||||||
|
* @param {string} message - Error message
|
||||||
|
* @param {string} [code] - Error code for programmatic handling
|
||||||
|
* @param {Object} [details] - Additional error details
|
||||||
|
*/
|
||||||
|
const createErrorResponse = (message, code = null, details = null) => {
|
||||||
|
const response = { message };
|
||||||
|
if (code) response.code = code;
|
||||||
|
if (details) response.details = details;
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTTP status code for S3 errors
|
||||||
|
*/
|
||||||
|
const getS3ErrorStatusCode = (error) => {
|
||||||
|
return S3StorageProvider.getErrorStatusCode(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build user-friendly error message based on error type
|
||||||
|
*/
|
||||||
|
const getErrorMessage = (error, operation = 'process') => {
|
||||||
|
const errorName = error?.name || '';
|
||||||
|
const errorCode = error?.code || '';
|
||||||
|
|
||||||
|
if (errorName === 'NoSuchKey' || errorName === 'NotFound' || errorName === 'NoSuchBucket') {
|
||||||
|
return 'File not found';
|
||||||
|
}
|
||||||
|
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
|
||||||
|
return 'Access denied to file';
|
||||||
|
}
|
||||||
|
if (errorName === 'TimeoutError' || errorCode === 'ETIMEDOUT') {
|
||||||
|
return 'Request timed out while accessing file';
|
||||||
|
}
|
||||||
|
if (errorCode === 'ECONNRESET' || errorCode === 'ECONNREFUSED') {
|
||||||
|
return 'Connection error while accessing storage';
|
||||||
|
}
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return 'Request was cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Could not ${operation} the file`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a path doesn't contain traversal attacks
|
||||||
|
* @param {string} urlPath - The path to validate
|
||||||
|
* @returns {boolean} Whether the path is valid
|
||||||
|
*/
|
||||||
|
const isValidPath = (urlPath) => {
|
||||||
|
if (!urlPath || typeof urlPath !== 'string') return false;
|
||||||
|
|
||||||
|
const trimmed = urlPath.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if (trimmed.includes('..')) return false;
|
||||||
|
if (trimmed.includes('\0')) return false;
|
||||||
|
|
||||||
|
// Check for double slashes (potential injection)
|
||||||
|
if (trimmed.includes('//')) return false;
|
||||||
|
|
||||||
|
// Check for protocol indicators
|
||||||
|
if (/^[a-zA-Z]+:/.test(trimmed)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unified Upload/Download/Delete Interface
|
// Unified Upload/Download/Delete Interface
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const uploadFile = async (folder, req, res) => {
|
const uploadFile = async (folder, req, res) => {
|
||||||
const provider = getFileStorageProvider();
|
const provider = getFileStorageProvider();
|
||||||
|
const log = req.log || logger;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const processFile = require('../middlewares/upload');
|
const processFile = require('../middlewares/upload');
|
||||||
await processFile(req, res);
|
await processFile(req, res);
|
||||||
|
|
||||||
if (!req.file) return res.status(400).send({ message: 'Please upload a file!' });
|
if (!req.file) return res.status(400).send(createErrorResponse('Please upload a file!', 'MISSING_FILE'));
|
||||||
|
|
||||||
const filename = req.body.filename;
|
const filename = req.body.filename;
|
||||||
if (!filename) return res.status(400).send({ message: 'Missing filename' });
|
if (!filename) return res.status(400).send(createErrorResponse('Missing filename', 'MISSING_FILENAME'));
|
||||||
|
|
||||||
const privateUrl = `${folder}/${filename}`;
|
const privateUrl = `${folder}/${filename}`;
|
||||||
let publicUrl = '';
|
let publicUrl = '';
|
||||||
@ -131,29 +232,54 @@ const uploadFile = async (folder, req, res) => {
|
|||||||
publicUrl = `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`;
|
publicUrl = `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info({ provider, privateUrl }, 'File uploaded successfully');
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
message: `Uploaded the file successfully: ${privateUrl}`,
|
message: `Uploaded the file successfully: ${privateUrl}`,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error', error);
|
log.error({ err: error, provider }, 'Failed to upload file');
|
||||||
return res.status(500).send({ message: `Could not upload the file. ${error.message || error}` });
|
return res.status(500).send(createErrorResponse(`Could not upload the file. ${error.message || error}`, 'UPLOAD_ERROR'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async (req, res) => {
|
const downloadFile = async (req, res) => {
|
||||||
const provider = getFileStorageProvider();
|
const provider = getFileStorageProvider();
|
||||||
const privateUrl = req.query.privateUrl;
|
const privateUrl = req.query.privateUrl;
|
||||||
|
const log = req.log || logger;
|
||||||
|
|
||||||
if (!privateUrl) return res.status(404).send({ message: 'Missing privateUrl' });
|
if (!privateUrl) return res.status(400).send(createErrorResponse('Missing privateUrl parameter', 'MISSING_PARAMETER'));
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
if (!isValidPath(privateUrl)) {
|
||||||
|
log.warn({ privateUrl }, 'Invalid file path requested');
|
||||||
|
return res.status(400).send(createErrorResponse('Invalid file path', 'INVALID_PATH'));
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
|
||||||
|
// Create AbortController for request cancellation
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const { signal } = abortController;
|
||||||
|
|
||||||
|
// Abort S3 request if client disconnects
|
||||||
|
req.on('close', () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
log.debug({ privateUrl }, 'Client disconnected, aborting download');
|
||||||
|
abortController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
if (provider === 's3') {
|
if (provider === 's3') {
|
||||||
const s3 = getS3Provider();
|
const s3 = getS3Provider();
|
||||||
const result = await s3.download(privateUrl);
|
const result = await s3.download(privateUrl, { signal });
|
||||||
|
|
||||||
if (result.contentType) res.setHeader('Content-Type', result.contentType);
|
if (result.contentType) res.setHeader('Content-Type', result.contentType);
|
||||||
|
if (result.contentLength) res.setHeader('Content-Length', result.contentLength);
|
||||||
|
|
||||||
if (typeof result.body.pipe === 'function') {
|
if (typeof result.body.pipe === 'function') {
|
||||||
result.body.pipe(res);
|
result.body.pipe(res);
|
||||||
@ -163,6 +289,8 @@ const downloadFile = async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
res.send(result.body);
|
res.send(result.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug({ provider, privateUrl, duration: Date.now() - startTime }, 'File downloaded');
|
||||||
} else if (provider === 'gcloud') {
|
} else if (provider === 'gcloud') {
|
||||||
const { bucket, hash } = getGCloudBucket();
|
const { bucket, hash } = getGCloudBucket();
|
||||||
const file = bucket.file(`${hash}/${privateUrl}`);
|
const file = bucket.file(`${hash}/${privateUrl}`);
|
||||||
@ -170,20 +298,50 @@ const downloadFile = async (req, res) => {
|
|||||||
if (exists) {
|
if (exists) {
|
||||||
file.createReadStream().pipe(res);
|
file.createReadStream().pipe(res);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).send({ message: 'File not found' });
|
res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res.download(path.join(config.uploadDir, privateUrl));
|
res.download(path.join(config.uploadDir, privateUrl));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const statusCode = error?.name === 'NoSuchKey' ? 404 : 500;
|
// Don't log abort errors as they're expected when client disconnects
|
||||||
return res.status(statusCode).send({ message: `Could not download the file. ${error.message || error}` });
|
if (error.name === 'AbortError') {
|
||||||
|
log.debug({ privateUrl }, 'Download aborted by client');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(499).end(); // Client Closed Request
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = provider === 's3' ? getS3ErrorStatusCode(error) : 500;
|
||||||
|
const errorMessage = getErrorMessage(error, 'download');
|
||||||
|
|
||||||
|
log.error({
|
||||||
|
err: error,
|
||||||
|
provider,
|
||||||
|
privateUrl,
|
||||||
|
statusCode,
|
||||||
|
errorName: error?.name,
|
||||||
|
errorCode: error?.code,
|
||||||
|
}, 'Failed to download file');
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(statusCode).send(createErrorResponse(errorMessage, error?.name || 'DOWNLOAD_ERROR'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFile = async (privateUrl) => {
|
/**
|
||||||
if (!privateUrl) return;
|
* Delete a file from storage
|
||||||
|
* @param {string} privateUrl - The file path to delete
|
||||||
|
* @param {Object} [options] - Delete options
|
||||||
|
* @param {boolean} [options.throwOnError=false] - Whether to throw errors instead of swallowing them
|
||||||
|
* @returns {Promise<{ success: boolean, error?: Error }>}
|
||||||
|
*/
|
||||||
|
const deleteFile = async (privateUrl, options = {}) => {
|
||||||
|
if (!privateUrl) return { success: false, error: new Error('Missing privateUrl') };
|
||||||
|
|
||||||
|
const { throwOnError = false } = options;
|
||||||
const provider = getFileStorageProvider();
|
const provider = getFileStorageProvider();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -199,8 +357,17 @@ const deleteFile = async (privateUrl) => {
|
|||||||
const local = getLocalProvider();
|
const local = getLocalProvider();
|
||||||
await local.delete(privateUrl);
|
await local.delete(privateUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug({ provider, privateUrl }, 'File deleted successfully');
|
||||||
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete file ${privateUrl}`, error);
|
logger.error({ err: error, provider, privateUrl }, 'Failed to delete file');
|
||||||
|
|
||||||
|
if (throwOnError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -219,6 +386,8 @@ const sanitizeFilename = (filename) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initUploadSession = async (req, res) => {
|
const initUploadSession = async (req, res) => {
|
||||||
|
const log = req.log || logger;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||||
|
|
||||||
@ -231,9 +400,9 @@ const initUploadSession = async (req, res) => {
|
|||||||
const size = Number(req.body?.size);
|
const size = Number(req.body?.size);
|
||||||
const contentType = String(req.body?.contentType || '').trim();
|
const contentType = String(req.body?.contentType || '').trim();
|
||||||
|
|
||||||
if (!folder || !filename) return res.status(400).send({ message: 'Invalid folder or filename' });
|
if (!folder || !filename) return res.status(400).send(createErrorResponse('Invalid folder or filename', 'INVALID_INPUT'));
|
||||||
if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send({ message: 'Invalid totalChunks' });
|
if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send(createErrorResponse('Invalid totalChunks', 'INVALID_INPUT'));
|
||||||
if (!Number.isFinite(size) || size < 0) return res.status(400).send({ message: 'Invalid file size' });
|
if (!Number.isFinite(size) || size < 0) return res.status(400).send(createErrorResponse('Invalid file size', 'INVALID_INPUT'));
|
||||||
|
|
||||||
const sessionId = sessionManager.createSession({
|
const sessionId = sessionManager.createSession({
|
||||||
userId: req.currentUser.id,
|
userId: req.currentUser.id,
|
||||||
@ -244,14 +413,16 @@ const initUploadSession = async (req, res) => {
|
|||||||
contentType,
|
contentType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.info({ sessionId, folder, filename, totalChunks, size }, 'Upload session initialized');
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
sessionId,
|
sessionId,
|
||||||
uploadedChunks: [],
|
uploadedChunks: [],
|
||||||
totalChunks,
|
totalChunks,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize upload session', error);
|
log.error({ err: error }, 'Failed to initialize upload session');
|
||||||
return res.status(500).send({ message: 'Failed to initialize upload session' });
|
return res.status(500).send(createErrorResponse('Failed to initialize upload session', 'SESSION_INIT_ERROR'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -263,7 +434,7 @@ const getUploadSession = async (req, res) => {
|
|||||||
const sessionManager = getUploadSessionManager();
|
const sessionManager = getUploadSessionManager();
|
||||||
const session = sessionManager.readMeta(sessionId);
|
const session = sessionManager.readMeta(sessionId);
|
||||||
|
|
||||||
if (!session) return res.status(404).send({ message: 'Upload session not found' });
|
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'));
|
||||||
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
@ -273,12 +444,15 @@ const getUploadSession = async (req, res) => {
|
|||||||
status: sessionManager.isComplete(sessionId) ? 'complete' : 'uploading',
|
status: sessionManager.isComplete(sessionId) ? 'complete' : 'uploading',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get upload session', error);
|
const log = req.log || logger;
|
||||||
return res.status(500).send({ message: 'Failed to get upload session' });
|
log.error({ err: error }, 'Failed to get upload session');
|
||||||
|
return res.status(500).send(createErrorResponse('Failed to get upload session', 'SESSION_GET_ERROR'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadChunk = async (req, res) => {
|
const uploadChunk = async (req, res) => {
|
||||||
|
const log = req.log || logger;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||||
|
|
||||||
@ -286,15 +460,15 @@ const uploadChunk = async (req, res) => {
|
|||||||
const chunkIndex = Number(req.params.chunkIndex);
|
const chunkIndex = Number(req.params.chunkIndex);
|
||||||
|
|
||||||
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
||||||
return res.status(400).send({ message: 'Invalid chunk index' });
|
return res.status(400).send(createErrorResponse('Invalid chunk index', 'INVALID_INPUT'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionManager = getUploadSessionManager();
|
const sessionManager = getUploadSessionManager();
|
||||||
const session = sessionManager.readMeta(sessionId);
|
const session = sessionManager.readMeta(sessionId);
|
||||||
|
|
||||||
if (!session) return res.status(404).send({ message: 'Upload session not found' });
|
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'));
|
||||||
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
||||||
if (chunkIndex >= session.totalChunks) return res.status(400).send({ message: 'Chunk index is out of range' });
|
if (chunkIndex >= session.totalChunks) return res.status(400).send(createErrorResponse('Chunk index is out of range', 'INVALID_INPUT'));
|
||||||
|
|
||||||
// Collect chunk data
|
// Collect chunk data
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@ -311,12 +485,14 @@ const uploadChunk = async (req, res) => {
|
|||||||
totalChunks: session.totalChunks,
|
totalChunks: session.totalChunks,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload chunk', error);
|
log.error({ err: error }, 'Failed to upload chunk');
|
||||||
return res.status(500).send({ message: 'Failed to upload chunk' });
|
return res.status(500).send(createErrorResponse('Failed to upload chunk', 'CHUNK_UPLOAD_ERROR'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizeUploadSession = async (req, res) => {
|
const finalizeUploadSession = async (req, res) => {
|
||||||
|
const log = req.log || logger;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||||
|
|
||||||
@ -324,13 +500,13 @@ const finalizeUploadSession = async (req, res) => {
|
|||||||
const sessionManager = getUploadSessionManager();
|
const sessionManager = getUploadSessionManager();
|
||||||
const session = sessionManager.readMeta(sessionId);
|
const session = sessionManager.readMeta(sessionId);
|
||||||
|
|
||||||
if (!session) return res.status(404).send({ message: 'Upload session not found' });
|
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'));
|
||||||
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
if (session.userId !== req.currentUser.id) return res.sendStatus(403);
|
||||||
|
|
||||||
// Verify all chunks exist
|
// Verify all chunks exist
|
||||||
for (let i = 0; i < session.totalChunks; i++) {
|
for (let i = 0; i < session.totalChunks; i++) {
|
||||||
if (!sessionManager.chunkExists(sessionId, i)) {
|
if (!sessionManager.chunkExists(sessionId, i)) {
|
||||||
return res.status(400).send({ message: `Missing chunk ${i}`, missingChunk: i });
|
return res.status(400).send(createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', { missingChunk: i }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,14 +543,16 @@ const finalizeUploadSession = async (req, res) => {
|
|||||||
// Cleanup session
|
// Cleanup session
|
||||||
sessionManager.removeSession(sessionId);
|
sessionManager.removeSession(sessionId);
|
||||||
|
|
||||||
|
log.info({ sessionId, provider, privateUrl }, 'Upload session finalized');
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
message: `Uploaded the file successfully: ${privateUrl}`,
|
message: `Uploaded the file successfully: ${privateUrl}`,
|
||||||
privateUrl,
|
privateUrl,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to finalize upload session', error);
|
log.error({ err: error }, 'Failed to finalize upload session');
|
||||||
return res.status(500).send({ message: 'Failed to finalize upload session' });
|
return res.status(500).send(createErrorResponse('Failed to finalize upload session', 'SESSION_FINALIZE_ERROR'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -382,7 +560,7 @@ const finalizeUploadSession = async (req, res) => {
|
|||||||
// Presigned URLs
|
// Presigned URLs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const PRESIGN_EXPIRY_SECONDS = 3600;
|
const getPresignExpirySeconds = () => config.s3.presignExpirySeconds || 3600;
|
||||||
|
|
||||||
const generatePresignedUrls = async (urls) => {
|
const generatePresignedUrls = async (urls) => {
|
||||||
const provider = getFileStorageProvider();
|
const provider = getFileStorageProvider();
|
||||||
@ -396,10 +574,11 @@ const generatePresignedUrls = async (urls) => {
|
|||||||
|
|
||||||
const s3 = getS3Provider();
|
const s3 = getS3Provider();
|
||||||
const presignedUrls = {};
|
const presignedUrls = {};
|
||||||
|
const expirySeconds = getPresignExpirySeconds();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
presignedUrls[url] = await s3.getSignedUrl(url, PRESIGN_EXPIRY_SECONDS);
|
presignedUrls[url] = await s3.getSignedUrl(url, expirySeconds);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -424,4 +603,8 @@ module.exports = {
|
|||||||
finalizeUploadSession,
|
finalizeUploadSession,
|
||||||
// Presigned URLs
|
// Presigned URLs
|
||||||
generatePresignedUrls,
|
generatePresignedUrls,
|
||||||
|
// Utilities (for testing/routes)
|
||||||
|
isValidPath,
|
||||||
|
createErrorResponse,
|
||||||
|
getS3ErrorStatusCode,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,8 +3,15 @@
|
|||||||
*
|
*
|
||||||
* AWS S3 storage implementation following the Strategy Pattern.
|
* AWS S3 storage implementation following the Strategy Pattern.
|
||||||
* Implements BaseStorageProvider interface for S3-specific operations.
|
* Implements BaseStorageProvider interface for S3-specific operations.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Request timeout and connection pool management
|
||||||
|
* - Retry strategy with exponential backoff
|
||||||
|
* - AbortController support for request cancellation
|
||||||
|
* - Comprehensive error handling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
const {
|
const {
|
||||||
S3Client,
|
S3Client,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
@ -15,8 +22,49 @@ const {
|
|||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
} = require('@aws-sdk/client-s3');
|
} = require('@aws-sdk/client-s3');
|
||||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||||
const BaseStorageProvider = require('./BaseStorageProvider');
|
const BaseStorageProvider = require('./BaseStorageProvider');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map S3 error names to HTTP status codes
|
||||||
|
*/
|
||||||
|
const S3_ERROR_STATUS_MAP = {
|
||||||
|
NoSuchKey: 404,
|
||||||
|
NotFound: 404,
|
||||||
|
NoSuchBucket: 404,
|
||||||
|
AccessDenied: 403,
|
||||||
|
InvalidAccessKeyId: 403,
|
||||||
|
SignatureDoesNotMatch: 403,
|
||||||
|
InvalidObjectState: 403,
|
||||||
|
ExpiredToken: 401,
|
||||||
|
TimeoutError: 504,
|
||||||
|
RequestTimeout: 504,
|
||||||
|
NetworkingError: 503,
|
||||||
|
ServiceUnavailable: 503,
|
||||||
|
SlowDown: 503,
|
||||||
|
InternalError: 500,
|
||||||
|
ThrottlingException: 429,
|
||||||
|
TooManyRequestsException: 429,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors that should be retried
|
||||||
|
*/
|
||||||
|
const RETRYABLE_ERRORS = new Set([
|
||||||
|
'TimeoutError',
|
||||||
|
'RequestTimeout',
|
||||||
|
'NetworkingError',
|
||||||
|
'ServiceUnavailable',
|
||||||
|
'SlowDown',
|
||||||
|
'InternalError',
|
||||||
|
'ThrottlingException',
|
||||||
|
'TooManyRequestsException',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'EPIPE',
|
||||||
|
]);
|
||||||
|
|
||||||
class S3StorageProvider extends BaseStorageProvider {
|
class S3StorageProvider extends BaseStorageProvider {
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
@ -25,12 +73,40 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
* @param {string} [options.accessKeyId] - AWS access key ID
|
* @param {string} [options.accessKeyId] - AWS access key ID
|
||||||
* @param {string} [options.secretAccessKey] - AWS secret access key
|
* @param {string} [options.secretAccessKey] - AWS secret access key
|
||||||
* @param {string} [options.prefix] - Key prefix for all operations
|
* @param {string} [options.prefix] - Key prefix for all operations
|
||||||
|
* @param {number} [options.connectionTimeout=5000] - Connection timeout in ms
|
||||||
|
* @param {number} [options.requestTimeout=30000] - Request timeout in ms
|
||||||
|
* @param {number} [options.maxAttempts=3] - Maximum retry attempts
|
||||||
|
* @param {number} [options.maxSockets=50] - Maximum concurrent connections
|
||||||
|
* @param {boolean} [options.keepAlive=true] - Enable connection keep-alive
|
||||||
*/
|
*/
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
super();
|
super();
|
||||||
this.bucket = options.bucket;
|
this.bucket = options.bucket;
|
||||||
this.prefix = options.prefix || '';
|
this.prefix = options.prefix || '';
|
||||||
|
|
||||||
|
// Timeout and connection pool settings
|
||||||
|
const connectionTimeout = options.connectionTimeout || 5000;
|
||||||
|
const requestTimeout = options.requestTimeout || 30000;
|
||||||
|
const maxSockets = options.maxSockets || 50;
|
||||||
|
const keepAlive = options.keepAlive !== false;
|
||||||
|
|
||||||
|
// Create HTTPS agent with connection pooling
|
||||||
|
this.httpsAgent = new https.Agent({
|
||||||
|
maxSockets,
|
||||||
|
keepAlive,
|
||||||
|
keepAliveMsecs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create NodeHttpHandler with timeout and connection pool
|
||||||
|
const requestHandler = new NodeHttpHandler({
|
||||||
|
connectionTimeout,
|
||||||
|
requestTimeout,
|
||||||
|
httpsAgent: this.httpsAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry configuration
|
||||||
|
const maxAttempts = options.maxAttempts || 3;
|
||||||
|
|
||||||
this.client = new S3Client({
|
this.client = new S3Client({
|
||||||
region: options.region || 'us-east-1',
|
region: options.region || 'us-east-1',
|
||||||
credentials:
|
credentials:
|
||||||
@ -40,13 +116,67 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
secretAccessKey: options.secretAccessKey,
|
secretAccessKey: options.secretAccessKey,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
requestHandler,
|
||||||
|
maxAttempts,
|
||||||
|
retryMode: 'adaptive', // Use adaptive retry with exponential backoff
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store config for health checks and logging
|
||||||
|
this.config = {
|
||||||
|
region: options.region || 'us-east-1',
|
||||||
|
connectionTimeout,
|
||||||
|
requestTimeout,
|
||||||
|
maxAttempts,
|
||||||
|
maxSockets,
|
||||||
|
keepAlive,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get providerName() {
|
static get providerName() {
|
||||||
return 's3';
|
return 's3';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get HTTP status code for an S3 error
|
||||||
|
* @param {Error} error - The error object
|
||||||
|
* @returns {number} HTTP status code
|
||||||
|
*/
|
||||||
|
static getErrorStatusCode(error) {
|
||||||
|
if (!error) return 500;
|
||||||
|
|
||||||
|
// Check by error name
|
||||||
|
if (error.name && S3_ERROR_STATUS_MAP[error.name]) {
|
||||||
|
return S3_ERROR_STATUS_MAP[error.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by error code (for network errors)
|
||||||
|
if (error.code && RETRYABLE_ERRORS.has(error.code)) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by $metadata (AWS SDK v3 format)
|
||||||
|
if (error.$metadata?.httpStatusCode) {
|
||||||
|
return error.$metadata.httpStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable
|
||||||
|
* @param {Error} error - The error object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isRetryableError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
return (
|
||||||
|
RETRYABLE_ERRORS.has(error.name) ||
|
||||||
|
RETRYABLE_ERRORS.has(error.code) ||
|
||||||
|
(error.$metadata?.httpStatusCode >= 500 &&
|
||||||
|
error.$metadata?.httpStatusCode < 600)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build full key with prefix
|
* Build full key with prefix
|
||||||
*/
|
*/
|
||||||
@ -61,10 +191,12 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
* @param {string} key - Storage key/path
|
* @param {string} key - Storage key/path
|
||||||
* @param {Buffer|ReadableStream} data - File data
|
* @param {Buffer|ReadableStream} data - File data
|
||||||
* @param {Object} options - Upload options
|
* @param {Object} options - Upload options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
* @returns {Promise<{ key: string, url?: string }>}
|
* @returns {Promise<{ key: string, url?: string }>}
|
||||||
*/
|
*/
|
||||||
async upload(key, data, options = {}) {
|
async upload(key, data, options = {}) {
|
||||||
const fullKey = this.buildKey(key);
|
const fullKey = this.buildKey(key);
|
||||||
|
const { signal, ...uploadOptions } = options;
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
@ -72,15 +204,16 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
Body: data,
|
Body: data,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.contentType) {
|
if (uploadOptions.contentType) {
|
||||||
params.ContentType = options.contentType;
|
params.ContentType = uploadOptions.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.metadata) {
|
if (uploadOptions.metadata) {
|
||||||
params.Metadata = options.metadata;
|
params.Metadata = uploadOptions.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.client.send(new PutObjectCommand(params));
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
|
await this.client.send(new PutObjectCommand(params), sendOptions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: fullKey,
|
key: fullKey,
|
||||||
@ -91,51 +224,68 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
/**
|
/**
|
||||||
* Download a file from S3
|
* Download a file from S3
|
||||||
* @param {string} key - Storage key/path
|
* @param {string} key - Storage key/path
|
||||||
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
|
* @param {Object} [options] - Download options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
|
* @returns {Promise<{ body: ReadableStream, contentType?: string, contentLength?: number }>}
|
||||||
*/
|
*/
|
||||||
async download(key) {
|
async download(key, options = {}) {
|
||||||
const fullKey = this.buildKey(key);
|
const fullKey = this.buildKey(key);
|
||||||
|
const { signal } = options;
|
||||||
|
|
||||||
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
const output = await this.client.send(
|
const output = await this.client.send(
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: fullKey,
|
Key: fullKey,
|
||||||
}),
|
}),
|
||||||
|
sendOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body: output.Body,
|
body: output.Body,
|
||||||
contentType: output.ContentType,
|
contentType: output.ContentType,
|
||||||
|
contentLength: output.ContentLength,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a file from S3
|
* Delete a file from S3
|
||||||
* @param {string} key - Storage key/path
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {Object} [options] - Delete options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async delete(key) {
|
async delete(key, options = {}) {
|
||||||
const fullKey = this.buildKey(key);
|
const fullKey = this.buildKey(key);
|
||||||
|
const { signal } = options;
|
||||||
|
|
||||||
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
await this.client.send(
|
await this.client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: fullKey,
|
Key: fullKey,
|
||||||
}),
|
}),
|
||||||
|
sendOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple files from S3
|
* Delete multiple files from S3
|
||||||
* @param {string[]} keys - Array of keys to delete
|
* @param {string[]} keys - Array of keys to delete
|
||||||
* @returns {Promise<void>}
|
* @param {Object} [options] - Delete options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
|
* @returns {Promise<{ deleted: string[], errors: Array<{ key: string, error: string }> }>}
|
||||||
*/
|
*/
|
||||||
async deleteMany(keys) {
|
async deleteMany(keys, options = {}) {
|
||||||
if (!keys || keys.length === 0) {
|
if (!keys || keys.length === 0) {
|
||||||
return;
|
return { deleted: [], errors: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { signal } = options;
|
||||||
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
const objects = keys.map((key) => ({ Key: this.buildKey(key) }));
|
const objects = keys.map((key) => ({ Key: this.buildKey(key) }));
|
||||||
|
const deleted = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
// S3 DeleteObjects supports max 1000 objects per request
|
// S3 DeleteObjects supports max 1000 objects per request
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@ -144,29 +294,49 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await this.client.send(
|
const result = await this.client.send(
|
||||||
new DeleteObjectsCommand({
|
new DeleteObjectsCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Delete: { Objects: chunk },
|
Delete: { Objects: chunk },
|
||||||
}),
|
}),
|
||||||
|
sendOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.Deleted) {
|
||||||
|
deleted.push(...result.Deleted.map((d) => d.Key));
|
||||||
|
}
|
||||||
|
if (result.Errors) {
|
||||||
|
errors.push(
|
||||||
|
...result.Errors.map((e) => ({
|
||||||
|
key: e.Key,
|
||||||
|
error: e.Message || e.Code,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { deleted, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file exists in S3
|
* Check if a file exists in S3
|
||||||
* @param {string} key - Storage key/path
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {Object} [options] - Options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async exists(key) {
|
async exists(key, options = {}) {
|
||||||
const fullKey = this.buildKey(key);
|
const fullKey = this.buildKey(key);
|
||||||
|
const { signal } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
await this.client.send(
|
await this.client.send(
|
||||||
new HeadObjectCommand({
|
new HeadObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: fullKey,
|
Key: fullKey,
|
||||||
}),
|
}),
|
||||||
|
sendOptions,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -180,10 +350,14 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
/**
|
/**
|
||||||
* List files with a given prefix
|
* List files with a given prefix
|
||||||
* @param {string} prefix - Key prefix
|
* @param {string} prefix - Key prefix
|
||||||
|
* @param {Object} [options] - Options
|
||||||
|
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||||
* @returns {Promise<string[]>} Array of keys
|
* @returns {Promise<string[]>} Array of keys
|
||||||
*/
|
*/
|
||||||
async list(prefix) {
|
async list(prefix, options = {}) {
|
||||||
const fullPrefix = this.buildKey(prefix);
|
const fullPrefix = this.buildKey(prefix);
|
||||||
|
const { signal } = options;
|
||||||
|
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||||
const keys = [];
|
const keys = [];
|
||||||
let continuationToken = null;
|
let continuationToken = null;
|
||||||
|
|
||||||
@ -197,7 +371,10 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
params.ContinuationToken = continuationToken;
|
params.ContinuationToken = continuationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.client.send(new ListObjectsV2Command(params));
|
const result = await this.client.send(
|
||||||
|
new ListObjectsV2Command(params),
|
||||||
|
sendOptions,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.Contents) {
|
if (result.Contents) {
|
||||||
keys.push(...result.Contents.map((obj) => obj.Key));
|
keys.push(...result.Contents.map((obj) => obj.Key));
|
||||||
@ -251,6 +428,23 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
getPrefix() {
|
getPrefix() {
|
||||||
return this.prefix;
|
return this.prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider configuration (for logging/debugging)
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getConfig() {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources (close connections)
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (this.httpsAgent) {
|
||||||
|
this.httpsAgent.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = S3StorageProvider;
|
module.exports = S3StorageProvider;
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.3",
|
"@emotion/react": "^11.11.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@fontsource-variable/instrument-sans": "^5.2.8",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mui/material": "^6.3.0",
|
"@mui/material": "^6.3.0",
|
||||||
@ -17,7 +18,6 @@
|
|||||||
"@reduxjs/toolkit": "^2.1.0",
|
"@reduxjs/toolkit": "^2.1.0",
|
||||||
"@serwist/next": "^9.5.7",
|
"@serwist/next": "^9.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@tinymce/tinymce-react": "^6.3.0",
|
|
||||||
"apexcharts": "^5.0.0",
|
"apexcharts": "^5.0.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -89,10 +89,13 @@ export function useAssetUploader({
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate file type based on section's expected asset format
|
||||||
|
const validationSchema = { assetType: section.assetFormat };
|
||||||
|
|
||||||
const remoteFile = await FileUploader.uploadChunked(
|
const remoteFile = await FileUploader.uploadChunked(
|
||||||
`assets/${projectId}`,
|
`assets/${projectId}`,
|
||||||
file,
|
file,
|
||||||
{},
|
validationSchema,
|
||||||
{
|
{
|
||||||
chunkSize: 5 * 1024 * 1024,
|
chunkSize: 5 * 1024 * 1024,
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
|
|||||||
@ -18,6 +18,8 @@ interface CanvasElementProps {
|
|||||||
onMouseDown?: (event: React.MouseEvent) => void;
|
onMouseDown?: (event: React.MouseEvent) => void;
|
||||||
/** Optional URL resolver for preloaded blob URLs */
|
/** Optional URL resolver for preloaded blob URLs */
|
||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
|
/** Gallery card click handler */
|
||||||
|
onGalleryCardClick?: (cardIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasElement: React.FC<CanvasElementProps> = ({
|
const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||||
@ -28,6 +30,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
resolveUrl,
|
resolveUrl,
|
||||||
|
onGalleryCardClick,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -53,6 +56,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
MediaSettingsSectionCompact,
|
MediaSettingsSectionCompact,
|
||||||
GallerySettingsSectionCompact,
|
GallerySettingsSectionCompact,
|
||||||
CarouselSettingsSectionCompact,
|
CarouselSettingsSectionCompact,
|
||||||
|
GalleryCarouselSettingsSectionCompact,
|
||||||
extractNumericValue,
|
extractNumericValue,
|
||||||
} from '../ElementSettings';
|
} from '../ElementSettings';
|
||||||
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
|
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
|
||||||
@ -37,6 +38,7 @@ import type {
|
|||||||
CanvasElement,
|
CanvasElement,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
GalleryCard,
|
GalleryCard,
|
||||||
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
|
|
||||||
@ -128,6 +130,11 @@ interface ElementEditorPanelProps {
|
|||||||
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
remove: (cardId: string) => void;
|
remove: (cardId: string) => void;
|
||||||
};
|
};
|
||||||
|
galleryInfoSpans: {
|
||||||
|
add: () => void;
|
||||||
|
update: (spanId: string, text: string) => void;
|
||||||
|
remove: (spanId: string) => void;
|
||||||
|
};
|
||||||
carouselSlides: {
|
carouselSlides: {
|
||||||
add: () => void;
|
add: () => void;
|
||||||
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
@ -236,6 +243,7 @@ export function ElementEditorPanel({
|
|||||||
activePageId,
|
activePageId,
|
||||||
onPreviewTransition,
|
onPreviewTransition,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
getDuration,
|
getDuration,
|
||||||
@ -412,6 +420,12 @@ export function ElementEditorPanel({
|
|||||||
iconUrl={selectedElement.iconUrl || ''}
|
iconUrl={selectedElement.iconUrl || ''}
|
||||||
tooltipTitle={selectedElement.tooltipTitle || ''}
|
tooltipTitle={selectedElement.tooltipTitle || ''}
|
||||||
tooltipText={selectedElement.tooltipText || ''}
|
tooltipText={selectedElement.tooltipText || ''}
|
||||||
|
tooltipTitleFontFamily={
|
||||||
|
selectedElement.tooltipTitleFontFamily || ''
|
||||||
|
}
|
||||||
|
tooltipTextFontFamily={
|
||||||
|
selectedElement.tooltipTextFontFamily || ''
|
||||||
|
}
|
||||||
iconAssetOptions={iconAssetOptions}
|
iconAssetOptions={iconAssetOptions}
|
||||||
onChange={(prop, value) =>
|
onChange={(prop, value) =>
|
||||||
onUpdateElement({ [prop]: value })
|
onUpdateElement({ [prop]: value })
|
||||||
@ -479,13 +493,67 @@ export function ElementEditorPanel({
|
|||||||
|
|
||||||
{selectedElement &&
|
{selectedElement &&
|
||||||
isGalleryElementType(selectedElement.type) && (
|
isGalleryElementType(selectedElement.type) && (
|
||||||
<GallerySettingsSectionCompact
|
<>
|
||||||
galleryCards={selectedElement.galleryCards || []}
|
<GallerySettingsSectionCompact
|
||||||
imageAssetOptions={imageAssetOptions}
|
galleryHeaderImageUrl={
|
||||||
onAddCard={galleryCards.add}
|
selectedElement.galleryHeaderImageUrl || ''
|
||||||
onUpdateCard={galleryCards.update}
|
}
|
||||||
onRemoveCard={galleryCards.remove}
|
galleryTitle={selectedElement.galleryTitle || ''}
|
||||||
/>
|
galleryInfoSpans={
|
||||||
|
selectedElement.galleryInfoSpans || []
|
||||||
|
}
|
||||||
|
galleryColumns={selectedElement.galleryColumns || 3}
|
||||||
|
galleryTitleFontFamily={
|
||||||
|
selectedElement.galleryTitleFontFamily || ''
|
||||||
|
}
|
||||||
|
galleryCardFontFamily={
|
||||||
|
selectedElement.galleryCardFontFamily || ''
|
||||||
|
}
|
||||||
|
galleryCards={selectedElement.galleryCards || []}
|
||||||
|
imageAssetOptions={imageAssetOptions}
|
||||||
|
onUpdateHeader={(patch) => onUpdateElement(patch)}
|
||||||
|
onAddInfoSpan={galleryInfoSpans.add}
|
||||||
|
onUpdateInfoSpan={galleryInfoSpans.update}
|
||||||
|
onRemoveInfoSpan={galleryInfoSpans.remove}
|
||||||
|
onAddCard={galleryCards.add}
|
||||||
|
onUpdateCard={galleryCards.update}
|
||||||
|
onRemoveCard={galleryCards.remove}
|
||||||
|
/>
|
||||||
|
<GalleryCarouselSettingsSectionCompact
|
||||||
|
prevIconUrl={
|
||||||
|
selectedElement.galleryCarouselPrevIconUrl || ''
|
||||||
|
}
|
||||||
|
nextIconUrl={
|
||||||
|
selectedElement.galleryCarouselNextIconUrl || ''
|
||||||
|
}
|
||||||
|
backIconUrl={
|
||||||
|
selectedElement.galleryCarouselBackIconUrl || ''
|
||||||
|
}
|
||||||
|
backLabel={
|
||||||
|
selectedElement.galleryCarouselBackLabel || ''
|
||||||
|
}
|
||||||
|
prevWidth={
|
||||||
|
selectedElement.galleryCarouselPrevWidth || ''
|
||||||
|
}
|
||||||
|
prevHeight={
|
||||||
|
selectedElement.galleryCarouselPrevHeight || ''
|
||||||
|
}
|
||||||
|
nextWidth={
|
||||||
|
selectedElement.galleryCarouselNextWidth || ''
|
||||||
|
}
|
||||||
|
nextHeight={
|
||||||
|
selectedElement.galleryCarouselNextHeight || ''
|
||||||
|
}
|
||||||
|
backWidth={
|
||||||
|
selectedElement.galleryCarouselBackWidth || ''
|
||||||
|
}
|
||||||
|
backHeight={
|
||||||
|
selectedElement.galleryCarouselBackHeight || ''
|
||||||
|
}
|
||||||
|
iconAssetOptions={iconAssetOptions}
|
||||||
|
onUpdateElement={onUpdateElement}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement &&
|
{selectedElement &&
|
||||||
@ -498,6 +566,9 @@ export function ElementEditorPanel({
|
|||||||
carouselNextIconUrl={
|
carouselNextIconUrl={
|
||||||
selectedElement.carouselNextIconUrl || ''
|
selectedElement.carouselNextIconUrl || ''
|
||||||
}
|
}
|
||||||
|
carouselCaptionFontFamily={
|
||||||
|
selectedElement.carouselCaptionFontFamily || ''
|
||||||
|
}
|
||||||
iconAssetOptions={iconAssetOptions}
|
iconAssetOptions={iconAssetOptions}
|
||||||
imageAssetOptions={imageAssetOptions}
|
imageAssetOptions={imageAssetOptions}
|
||||||
onUpdateElement={onUpdateElement}
|
onUpdateElement={onUpdateElement}
|
||||||
@ -540,6 +611,7 @@ export function ElementEditorPanel({
|
|||||||
backgroundColor: selectedElement.backgroundColor || '',
|
backgroundColor: selectedElement.backgroundColor || '',
|
||||||
color: selectedElement.color || '',
|
color: selectedElement.color || '',
|
||||||
fontFamily: selectedElement.fontFamily || '',
|
fontFamily: selectedElement.fontFamily || '',
|
||||||
|
fontStretch: selectedElement.fontStretch || '',
|
||||||
}}
|
}}
|
||||||
onChange={(prop, value) =>
|
onChange={(prop, value) =>
|
||||||
handleCssPropertyChange(prop, value, onUpdateElement)
|
handleCssPropertyChange(prop, value, onUpdateElement)
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import useDevCompilationStatus from '../hooks/useDevCompilationStatus';
|
|
||||||
const DevModeBadge: React.FC = () => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
|
||||||
const compilationStatus = useDevCompilationStatus();
|
|
||||||
|
|
||||||
const [badgeStyles, setBadgeStyles] = useState<React.CSSProperties>({
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '20px',
|
|
||||||
left: '70px',
|
|
||||||
background: 'rgba(0, 0, 0, 0.85)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontFamily: 'sans-serif',
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
textAlign: 'left',
|
|
||||||
zIndex: 2147483647,
|
|
||||||
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
transition:
|
|
||||||
'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width
|
|
||||||
opacity: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
width: '340px',
|
|
||||||
maxWidth: '340px',
|
|
||||||
height: 'auto',
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fullText = `🚧 Your app is running in development mode.
|
|
||||||
Current request is compiling and may take a few moments.
|
|
||||||
|
|
||||||
💡 Tip: Set up a stable environment to run your app in production mode—pages will load instantly without compilation delays.`;
|
|
||||||
|
|
||||||
const collapsedText = '🚧 DEV stage';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (compilationStatus === 'ready') {
|
|
||||||
setIsCollapsed(true);
|
|
||||||
} else {
|
|
||||||
setIsCollapsed(false);
|
|
||||||
}
|
|
||||||
}, [compilationStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
process.env.NODE_ENV === 'development' ||
|
|
||||||
(process.env.NODE_ENV as string) === 'dev_stage'
|
|
||||||
) {
|
|
||||||
setIsVisible(true);
|
|
||||||
|
|
||||||
setBadgeStyles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
opacity: 1,
|
|
||||||
width: '120px',
|
|
||||||
maxWidth: '120px',
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: '18px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
setBadgeStyles((prev) => ({ ...prev, opacity: 0 }));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible) return;
|
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
setBadgeStyles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
width: '140px',
|
|
||||||
maxWidth: '160px',
|
|
||||||
padding: '6px 20px',
|
|
||||||
borderRadius: '18px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
fontSize: '12px',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
setBadgeStyles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
width: '340px',
|
|
||||||
maxWidth: '340px',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
fontSize: '14px',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [isCollapsed, isVisible]);
|
|
||||||
|
|
||||||
const handleToggleCollapse = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsCollapsed((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={badgeStyles}
|
|
||||||
onClick={isCollapsed ? handleToggleCollapse : undefined}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleToggleCollapse}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: isCollapsed ? '3px' : '5px',
|
|
||||||
right: isCollapsed ? '2px' : '5px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: isCollapsed ? '10px' : '18px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '2px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
lineHeight: '1',
|
|
||||||
width: '24px',
|
|
||||||
height: isCollapsed ? '24px' : '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
transition:
|
|
||||||
'background-color 0.2s ease, font-size 0.2s ease, width 0.2s ease, height 0.2s ease',
|
|
||||||
}}
|
|
||||||
aria-label={isCollapsed ? 'Expand message' : 'Collapse message'}
|
|
||||||
>
|
|
||||||
{isCollapsed ? '+' : '×'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isCollapsed && <div style={{ marginRight: '20px' }}>{fullText}</div>}
|
|
||||||
{isCollapsed && (
|
|
||||||
<div style={{ marginRight: '10px' }}>{collapsedText}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DevModeBadge;
|
|
||||||
@ -11,10 +11,12 @@ import BaseButton from '../BaseButton';
|
|||||||
import CardBox from '../CardBox';
|
import CardBox from '../CardBox';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import type { CarouselSettingsSectionProps } from './types';
|
import type { CarouselSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
|
const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
|
||||||
carouselPrevIconUrl,
|
carouselPrevIconUrl,
|
||||||
carouselNextIconUrl,
|
carouselNextIconUrl,
|
||||||
|
carouselCaptionFontFamily,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
onAddSlide,
|
onAddSlide,
|
||||||
onRemoveSlide,
|
onRemoveSlide,
|
||||||
@ -86,6 +88,24 @@ const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4'>
|
||||||
|
<FormField label='Caption font family'>
|
||||||
|
<select
|
||||||
|
value={carouselCaptionFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('carouselCaptionFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mb-3 mt-4 flex items-center justify-between'>
|
<div className='mb-3 mt-4 flex items-center justify-between'>
|
||||||
<h3 className='text-sm font-semibold'>Carousel slides</h3>
|
<h3 className='text-sm font-semibold'>Carousel slides</h3>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|||||||
@ -8,16 +8,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { CarouselSlide, AssetOption } from '../../types/constructor';
|
import type { CarouselSlide, AssetOption } from '../../types/constructor';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
interface CarouselSettingsSectionCompactProps {
|
interface CarouselSettingsSectionCompactProps {
|
||||||
carouselSlides: CarouselSlide[];
|
carouselSlides: CarouselSlide[];
|
||||||
carouselPrevIconUrl: string;
|
carouselPrevIconUrl: string;
|
||||||
carouselNextIconUrl: string;
|
carouselNextIconUrl: string;
|
||||||
|
carouselCaptionFontFamily: string;
|
||||||
iconAssetOptions: AssetOption[];
|
iconAssetOptions: AssetOption[];
|
||||||
imageAssetOptions: AssetOption[];
|
imageAssetOptions: AssetOption[];
|
||||||
onUpdateElement: (patch: {
|
onUpdateElement: (patch: {
|
||||||
carouselPrevIconUrl?: string;
|
carouselPrevIconUrl?: string;
|
||||||
carouselNextIconUrl?: string;
|
carouselNextIconUrl?: string;
|
||||||
|
carouselCaptionFontFamily?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onAddSlide: () => void;
|
onAddSlide: () => void;
|
||||||
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
@ -30,6 +33,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
carouselSlides,
|
carouselSlides,
|
||||||
carouselPrevIconUrl,
|
carouselPrevIconUrl,
|
||||||
carouselNextIconUrl,
|
carouselNextIconUrl,
|
||||||
|
carouselCaptionFontFamily,
|
||||||
iconAssetOptions,
|
iconAssetOptions,
|
||||||
imageAssetOptions,
|
imageAssetOptions,
|
||||||
onUpdateElement,
|
onUpdateElement,
|
||||||
@ -81,6 +85,24 @@ const CarouselSettingsSectionCompact: React.FC<
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='text-[10px] text-gray-600'>Caption font:</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={carouselCaptionFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ carouselCaptionFontFamily: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import type { DescriptionSettingsSectionProps } from './types';
|
import type { DescriptionSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
||||||
iconUrl,
|
iconUrl,
|
||||||
@ -85,22 +86,34 @@ const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Title font family'>
|
<FormField label='Title font family'>
|
||||||
<input
|
<select
|
||||||
value={descriptionTitleFontFamily}
|
value={descriptionTitleFontFamily}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTitleFontFamily', event.target.value)
|
onChange('descriptionTitleFontFamily', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, sans-serif'
|
>
|
||||||
/>
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Text font family'>
|
<FormField label='Text font family'>
|
||||||
<input
|
<select
|
||||||
value={descriptionTextFontFamily}
|
value={descriptionTextFontFamily}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTextFontFamily', event.target.value)
|
onChange('descriptionTextFontFamily', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, sans-serif'
|
>
|
||||||
/>
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Title color'>
|
<FormField label='Title color'>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AssetOption } from '../../types/constructor';
|
import type { AssetOption } from '../../types/constructor';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
interface DescriptionSettingsSectionCompactProps {
|
interface DescriptionSettingsSectionCompactProps {
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
@ -119,28 +120,40 @@ const DescriptionSettingsSectionCompact: React.FC<
|
|||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Title font family
|
Title font family
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={descriptionTitleFontFamily}
|
value={descriptionTitleFontFamily}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTitleFontFamily', event.target.value)
|
onChange('descriptionTitleFontFamily', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, Helvetica, sans-serif'
|
>
|
||||||
/>
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Text font family
|
Text font family
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={descriptionTextFontFamily}
|
value={descriptionTextFontFamily}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onChange('descriptionTextFontFamily', event.target.value)
|
onChange('descriptionTextFontFamily', event.target.value)
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, Helvetica, sans-serif'
|
>
|
||||||
/>
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* GalleryCarouselSettingsSectionCompact
|
||||||
|
*
|
||||||
|
* Compact settings for gallery carousel navigation buttons in constructor sidebar.
|
||||||
|
* Allows selecting custom icons for prev, next, and back buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { AssetOption } from '../../types/constructor';
|
||||||
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
|
||||||
|
interface GalleryCarouselSettingsSectionCompactProps {
|
||||||
|
prevIconUrl: string;
|
||||||
|
nextIconUrl: string;
|
||||||
|
backIconUrl: string;
|
||||||
|
backLabel: string;
|
||||||
|
// Button dimensions
|
||||||
|
prevWidth: string;
|
||||||
|
prevHeight: string;
|
||||||
|
nextWidth: string;
|
||||||
|
nextHeight: string;
|
||||||
|
backWidth: string;
|
||||||
|
backHeight: string;
|
||||||
|
iconAssetOptions: AssetOption[];
|
||||||
|
onUpdateElement: (patch: {
|
||||||
|
galleryCarouselPrevIconUrl?: string;
|
||||||
|
galleryCarouselNextIconUrl?: string;
|
||||||
|
galleryCarouselBackIconUrl?: string;
|
||||||
|
galleryCarouselBackLabel?: string;
|
||||||
|
galleryCarouselPrevWidth?: string;
|
||||||
|
galleryCarouselPrevHeight?: string;
|
||||||
|
galleryCarouselNextWidth?: string;
|
||||||
|
galleryCarouselNextHeight?: string;
|
||||||
|
galleryCarouselBackWidth?: string;
|
||||||
|
galleryCarouselBackHeight?: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GalleryCarouselSettingsSectionCompact: React.FC<
|
||||||
|
GalleryCarouselSettingsSectionCompactProps
|
||||||
|
> = ({
|
||||||
|
prevIconUrl,
|
||||||
|
nextIconUrl,
|
||||||
|
backIconUrl,
|
||||||
|
backLabel,
|
||||||
|
prevWidth,
|
||||||
|
prevHeight,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
backWidth,
|
||||||
|
backHeight,
|
||||||
|
iconAssetOptions,
|
||||||
|
onUpdateElement,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='mt-3 space-y-2'>
|
||||||
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||||
|
<p className='text-[11px] font-semibold text-gray-700'>
|
||||||
|
Carousel navigation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Previous button */}
|
||||||
|
<p className='text-[10px] font-medium text-gray-600 mt-1'>Previous</p>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={prevIconUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselPrevIconUrl: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Icon (default)</option>
|
||||||
|
{addFallbackAssetOption(
|
||||||
|
iconAssetOptions,
|
||||||
|
prevIconUrl,
|
||||||
|
`Current prev icon`,
|
||||||
|
).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{prevIconUrl && (
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='W (rem)'
|
||||||
|
value={prevWidth}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselPrevWidth: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='H (rem)'
|
||||||
|
value={prevHeight}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselPrevHeight: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
<p className='text-[10px] font-medium text-gray-600 mt-1'>Next</p>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={nextIconUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselNextIconUrl: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Icon (default)</option>
|
||||||
|
{addFallbackAssetOption(
|
||||||
|
iconAssetOptions,
|
||||||
|
nextIconUrl,
|
||||||
|
`Current next icon`,
|
||||||
|
).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nextIconUrl && (
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='W (rem)'
|
||||||
|
value={nextWidth}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselNextWidth: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='H (rem)'
|
||||||
|
value={nextHeight}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselNextHeight: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
<p className='text-[10px] font-medium text-gray-600 mt-1'>Back</p>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={backIconUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselBackIconUrl: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Icon (default)</option>
|
||||||
|
{addFallbackAssetOption(
|
||||||
|
iconAssetOptions,
|
||||||
|
backIconUrl,
|
||||||
|
`Current back icon`,
|
||||||
|
).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{backIconUrl ? (
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='W (rem)'
|
||||||
|
value={backWidth}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselBackWidth: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
step='0.25'
|
||||||
|
min='0'
|
||||||
|
className='w-1/2 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='H (rem)'
|
||||||
|
value={backHeight}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselBackHeight: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='Back button label'
|
||||||
|
value={backLabel}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateElement({ galleryCarouselBackLabel: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className='text-[10px] text-gray-500 mt-1'>
|
||||||
|
Set icon + dimensions for navigation-style buttons. Drag to reposition.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GalleryCarouselSettingsSectionCompact;
|
||||||
@ -11,12 +11,16 @@ import BaseButton from '../BaseButton';
|
|||||||
import CardBox from '../CardBox';
|
import CardBox from '../CardBox';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import type { GallerySettingsSectionProps } from './types';
|
import type { GallerySettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
|
const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
|
||||||
galleryCards,
|
galleryCards,
|
||||||
|
galleryTitleFontFamily,
|
||||||
|
galleryCardFontFamily,
|
||||||
onAddCard,
|
onAddCard,
|
||||||
onRemoveCard,
|
onRemoveCard,
|
||||||
onUpdateCard,
|
onUpdateCard,
|
||||||
|
onChange,
|
||||||
context,
|
context,
|
||||||
imageAssetOptions = [],
|
imageAssetOptions = [],
|
||||||
}) => {
|
}) => {
|
||||||
@ -24,6 +28,41 @@ const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBox className='border border-gray-200 dark:border-dark-700'>
|
<CardBox className='border border-gray-200 dark:border-dark-700'>
|
||||||
|
<h3 className='mb-3 text-sm font-semibold'>Gallery settings</h3>
|
||||||
|
|
||||||
|
<div className='mb-4 grid gap-3 md:grid-cols-2'>
|
||||||
|
<FormField label='Title font family'>
|
||||||
|
<select
|
||||||
|
value={galleryTitleFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('galleryTitleFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Card font family'>
|
||||||
|
<select
|
||||||
|
value={galleryCardFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('galleryCardFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mb-3 flex items-center justify-between'>
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
<h3 className='text-sm font-semibold'>Gallery cards</h3>
|
<h3 className='text-sm font-semibold'>Gallery cards</h3>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
|||||||
@ -2,16 +2,42 @@
|
|||||||
* GallerySettingsSectionCompact
|
* GallerySettingsSectionCompact
|
||||||
*
|
*
|
||||||
* Compact gallery element settings for constructor sidebar.
|
* Compact gallery element settings for constructor sidebar.
|
||||||
* Card management with image, title, and description fields.
|
* Header image, title, info spans, and card management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { GalleryCard, AssetOption } from '../../types/constructor';
|
import type {
|
||||||
|
GalleryCard,
|
||||||
|
GalleryInfoSpan,
|
||||||
|
AssetOption,
|
||||||
|
} from '../../types/constructor';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
interface GallerySettingsSectionCompactProps {
|
interface GallerySettingsSectionCompactProps {
|
||||||
|
// Header settings
|
||||||
|
galleryHeaderImageUrl: string;
|
||||||
|
galleryTitle: string;
|
||||||
|
galleryInfoSpans: GalleryInfoSpan[];
|
||||||
|
galleryColumns: number;
|
||||||
|
// Font settings
|
||||||
|
galleryTitleFontFamily: string;
|
||||||
|
galleryCardFontFamily: string;
|
||||||
|
// Cards
|
||||||
galleryCards: GalleryCard[];
|
galleryCards: GalleryCard[];
|
||||||
imageAssetOptions: AssetOption[];
|
imageAssetOptions: AssetOption[];
|
||||||
|
// Header handlers
|
||||||
|
onUpdateHeader: (patch: {
|
||||||
|
galleryHeaderImageUrl?: string;
|
||||||
|
galleryTitle?: string;
|
||||||
|
galleryColumns?: number;
|
||||||
|
galleryTitleFontFamily?: string;
|
||||||
|
galleryCardFontFamily?: string;
|
||||||
|
}) => void;
|
||||||
|
onAddInfoSpan: () => void;
|
||||||
|
onUpdateInfoSpan: (spanId: string, text: string) => void;
|
||||||
|
onRemoveInfoSpan: (spanId: string) => void;
|
||||||
|
// Card handlers
|
||||||
onAddCard: () => void;
|
onAddCard: () => void;
|
||||||
onUpdateCard: (cardId: string, patch: Partial<GalleryCard>) => void;
|
onUpdateCard: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
onRemoveCard: (cardId: string) => void;
|
onRemoveCard: (cardId: string) => void;
|
||||||
@ -20,14 +46,148 @@ interface GallerySettingsSectionCompactProps {
|
|||||||
const GallerySettingsSectionCompact: React.FC<
|
const GallerySettingsSectionCompact: React.FC<
|
||||||
GallerySettingsSectionCompactProps
|
GallerySettingsSectionCompactProps
|
||||||
> = ({
|
> = ({
|
||||||
|
galleryHeaderImageUrl,
|
||||||
|
galleryTitle,
|
||||||
|
galleryInfoSpans,
|
||||||
|
galleryColumns,
|
||||||
|
galleryTitleFontFamily,
|
||||||
|
galleryCardFontFamily,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
imageAssetOptions,
|
imageAssetOptions,
|
||||||
|
onUpdateHeader,
|
||||||
|
onAddInfoSpan,
|
||||||
|
onUpdateInfoSpan,
|
||||||
|
onRemoveInfoSpan,
|
||||||
onAddCard,
|
onAddCard,
|
||||||
onUpdateCard,
|
onUpdateCard,
|
||||||
onRemoveCard,
|
onRemoveCard,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-3'>
|
||||||
|
{/* Header Settings */}
|
||||||
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||||
|
<p className='text-[11px] font-semibold text-gray-700'>Gallery header</p>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={galleryHeaderImageUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateHeader({ galleryHeaderImageUrl: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Header image</option>
|
||||||
|
{addFallbackAssetOption(
|
||||||
|
imageAssetOptions,
|
||||||
|
galleryHeaderImageUrl,
|
||||||
|
`Current header`,
|
||||||
|
).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder='Title (location note)'
|
||||||
|
value={galleryTitle}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateHeader({ galleryTitle: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<label className='text-[10px] text-gray-600'>Grid columns:</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='1'
|
||||||
|
max='6'
|
||||||
|
className='w-16 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={galleryColumns}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateHeader({
|
||||||
|
galleryColumns: Math.max(1, Math.min(6, parseInt(event.target.value) || 3)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='text-[10px] text-gray-600'>Title font:</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={galleryTitleFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateHeader({ galleryTitleFontFamily: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='text-[10px] text-gray-600'>Card font:</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={galleryCardFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onUpdateHeader({ galleryCardFontFamily: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Spans */}
|
||||||
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<p className='text-[11px] font-semibold text-gray-700'>Info spans</p>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-xs text-blue-700 hover:underline'
|
||||||
|
onClick={onAddInfoSpan}
|
||||||
|
>
|
||||||
|
+ Add span
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{galleryInfoSpans.map((span, index) => (
|
||||||
|
<div key={span.id} className='flex items-center gap-1'>
|
||||||
|
<input
|
||||||
|
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
placeholder={`Span ${index + 1}`}
|
||||||
|
value={span.text}
|
||||||
|
onChange={(event) => onUpdateInfoSpan(span.id, event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='text-xs text-red-600 hover:underline px-1'
|
||||||
|
onClick={() => onRemoveInfoSpan(span.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{galleryInfoSpans.length === 0 && (
|
||||||
|
<p className='text-[10px] text-gray-500'>
|
||||||
|
Add spans for brief notes (capacity, price, etc.)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery Cards */}
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<p className='text-[11px] font-semibold text-gray-600'>Gallery cards</p>
|
<p className='text-[11px] font-semibold text-gray-600'>Gallery cards</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import type { StyleSettingsSectionProps } from './types';
|
import type { StyleSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||||
|
|
||||||
const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
|
const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
@ -241,11 +242,29 @@ const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label='Font family'>
|
<FormField label='Font family'>
|
||||||
<input
|
<select
|
||||||
value={values.fontFamily || ''}
|
value={getFontKeyFromValues(values.fontFamily, values.fontStretch)}
|
||||||
onChange={(event) => onChange('fontFamily', event.target.value)}
|
onChange={(event) => {
|
||||||
placeholder='e.g. Montserrat, sans-serif'
|
const fontKey = event.target.value;
|
||||||
/>
|
if (!fontKey) {
|
||||||
|
onChange('fontFamily', '');
|
||||||
|
onChange('fontStretch', '');
|
||||||
|
} else {
|
||||||
|
const font = getFontByKey(fontKey);
|
||||||
|
if (font) {
|
||||||
|
onChange('fontFamily', font.fontFamily);
|
||||||
|
onChange('fontStretch', font.fontStretch || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.key}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { StyleSettingsSectionProps } from './types';
|
import type { StyleSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||||
|
|
||||||
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
@ -341,12 +342,30 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
|||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
Font family
|
Font family
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={values.fontFamily || ''}
|
value={getFontKeyFromValues(values.fontFamily, values.fontStretch)}
|
||||||
onChange={(e) => onChange('fontFamily', e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder='Montserrat, sans-serif'
|
const fontKey = e.target.value;
|
||||||
/>
|
if (!fontKey) {
|
||||||
|
onChange('fontFamily', '');
|
||||||
|
onChange('fontStretch', '');
|
||||||
|
} else {
|
||||||
|
const font = getFontByKey(fontKey);
|
||||||
|
if (font) {
|
||||||
|
onChange('fontFamily', font.fontFamily);
|
||||||
|
onChange('fontStretch', font.fontStretch || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.key}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,11 +7,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FormField from '../FormField';
|
import FormField from '../FormField';
|
||||||
import type { TooltipSettingsSectionProps } from './types';
|
import type { TooltipSettingsSectionProps } from './types';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
|
const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
|
||||||
iconUrl,
|
iconUrl,
|
||||||
tooltipTitle,
|
tooltipTitle,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
|
tooltipTitleFontFamily,
|
||||||
|
tooltipTextFontFamily,
|
||||||
onChange,
|
onChange,
|
||||||
context,
|
context,
|
||||||
iconAssetOptions = [],
|
iconAssetOptions = [],
|
||||||
@ -57,6 +60,39 @@ const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
|
|||||||
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
|
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<FormField label='Title font family'>
|
||||||
|
<select
|
||||||
|
value={tooltipTitleFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('tooltipTitleFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Text font family'>
|
||||||
|
<select
|
||||||
|
value={tooltipTextFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('tooltipTextFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,18 +8,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AssetOption } from '../../types/constructor';
|
import type { AssetOption } from '../../types/constructor';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
interface TooltipSettingsSectionCompactProps {
|
interface TooltipSettingsSectionCompactProps {
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
tooltipTitle: string;
|
tooltipTitle: string;
|
||||||
tooltipText: string;
|
tooltipText: string;
|
||||||
|
tooltipTitleFontFamily: string;
|
||||||
|
tooltipTextFontFamily: string;
|
||||||
iconAssetOptions: AssetOption[];
|
iconAssetOptions: AssetOption[];
|
||||||
onChange: (prop: string, value: string) => void;
|
onChange: (prop: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TooltipSettingsSectionCompact: React.FC<
|
const TooltipSettingsSectionCompact: React.FC<
|
||||||
TooltipSettingsSectionCompactProps
|
TooltipSettingsSectionCompactProps
|
||||||
> = ({ iconUrl, tooltipTitle, tooltipText, iconAssetOptions, onChange }) => {
|
> = ({
|
||||||
|
iconUrl,
|
||||||
|
tooltipTitle,
|
||||||
|
tooltipText,
|
||||||
|
tooltipTitleFontFamily,
|
||||||
|
tooltipTextFontFamily,
|
||||||
|
iconAssetOptions,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
@ -66,6 +77,46 @@ const TooltipSettingsSectionCompact: React.FC<
|
|||||||
onChange={(event) => onChange('tooltipText', event.target.value)}
|
onChange={(event) => onChange('tooltipText', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Title font family
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={tooltipTitleFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('tooltipTitleFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Text font family
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={tooltipTextFontFamily}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('tooltipTextFontFamily', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.fontFamily}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export { default as GallerySettingsSection } from './GallerySettingsSection';
|
|||||||
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
|
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
|
||||||
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
|
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
|
||||||
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
||||||
|
export { default as GalleryCarouselSettingsSectionCompact } from './GalleryCarouselSettingsSectionCompact';
|
||||||
|
|
||||||
// Hook
|
// Hook
|
||||||
export { useElementSettingsForm } from './useElementSettingsForm';
|
export { useElementSettingsForm } from './useElementSettingsForm';
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
CanvasElement,
|
CanvasElement,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
GalleryCard,
|
GalleryCard,
|
||||||
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
AssetOption,
|
AssetOption,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
@ -103,6 +104,8 @@ export interface TooltipSettingsSectionProps {
|
|||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
tooltipTitle: string;
|
tooltipTitle: string;
|
||||||
tooltipText: string;
|
tooltipText: string;
|
||||||
|
tooltipTitleFontFamily: string;
|
||||||
|
tooltipTextFontFamily: string;
|
||||||
onChange: (field: string, value: string) => void;
|
onChange: (field: string, value: string) => void;
|
||||||
context: ElementSettingsContext;
|
context: ElementSettingsContext;
|
||||||
iconAssetOptions?: AssetOption[];
|
iconAssetOptions?: AssetOption[];
|
||||||
@ -143,10 +146,13 @@ export interface MediaSettingsSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for gallery element settings
|
* Props for gallery element settings (non-compact version)
|
||||||
|
* Used in element-type-defaults and project-element-defaults pages
|
||||||
*/
|
*/
|
||||||
export interface GallerySettingsSectionProps {
|
export interface GallerySettingsSectionProps {
|
||||||
galleryCards: GalleryCard[];
|
galleryCards: GalleryCard[];
|
||||||
|
galleryTitleFontFamily: string;
|
||||||
|
galleryCardFontFamily: string;
|
||||||
onAddCard: () => void;
|
onAddCard: () => void;
|
||||||
onRemoveCard: (cardId: string) => void;
|
onRemoveCard: (cardId: string) => void;
|
||||||
onUpdateCard: (
|
onUpdateCard: (
|
||||||
@ -154,16 +160,52 @@ export interface GallerySettingsSectionProps {
|
|||||||
field: keyof GalleryCard,
|
field: keyof GalleryCard,
|
||||||
value: string,
|
value: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
onChange: (field: string, value: string) => void;
|
||||||
context: ElementSettingsContext;
|
context: ElementSettingsContext;
|
||||||
imageAssetOptions?: AssetOption[];
|
imageAssetOptions?: AssetOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for gallery element settings (compact version)
|
||||||
|
* Used in constructor sidebar with full header/spans/cards support
|
||||||
|
*/
|
||||||
|
export interface GallerySettingsSectionCompactProps {
|
||||||
|
// Header settings
|
||||||
|
galleryHeaderImageUrl: string;
|
||||||
|
galleryTitle: string;
|
||||||
|
galleryInfoSpans: GalleryInfoSpan[];
|
||||||
|
galleryColumns: number;
|
||||||
|
// Font settings
|
||||||
|
galleryTitleFontFamily: string;
|
||||||
|
galleryCardFontFamily: string;
|
||||||
|
// Cards
|
||||||
|
galleryCards: GalleryCard[];
|
||||||
|
imageAssetOptions: AssetOption[];
|
||||||
|
// Header handlers
|
||||||
|
onUpdateHeader: (patch: {
|
||||||
|
galleryHeaderImageUrl?: string;
|
||||||
|
galleryTitle?: string;
|
||||||
|
galleryColumns?: number;
|
||||||
|
galleryTitleFontFamily?: string;
|
||||||
|
galleryCardFontFamily?: string;
|
||||||
|
}) => void;
|
||||||
|
// Info span handlers
|
||||||
|
onAddInfoSpan: () => void;
|
||||||
|
onUpdateInfoSpan: (spanId: string, text: string) => void;
|
||||||
|
onRemoveInfoSpan: (spanId: string) => void;
|
||||||
|
// Card handlers
|
||||||
|
onAddCard: () => void;
|
||||||
|
onUpdateCard: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
|
onRemoveCard: (cardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for carousel element settings
|
* Props for carousel element settings
|
||||||
*/
|
*/
|
||||||
export interface CarouselSettingsSectionProps {
|
export interface CarouselSettingsSectionProps {
|
||||||
carouselPrevIconUrl: string;
|
carouselPrevIconUrl: string;
|
||||||
carouselNextIconUrl: string;
|
carouselNextIconUrl: string;
|
||||||
|
carouselCaptionFontFamily: string;
|
||||||
carouselSlides: CarouselSlide[];
|
carouselSlides: CarouselSlide[];
|
||||||
onAddSlide: () => void;
|
onAddSlide: () => void;
|
||||||
onRemoveSlide: (slideId: string) => void;
|
onRemoveSlide: (slideId: string) => void;
|
||||||
@ -187,6 +229,25 @@ export interface ElementSettingsTabsProps {
|
|||||||
tabs: { id: string; label: string }[];
|
tabs: { id: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for gallery carousel settings section (constructor)
|
||||||
|
*/
|
||||||
|
export interface GalleryCarouselSettingsSectionProps {
|
||||||
|
prevIconUrl: string;
|
||||||
|
nextIconUrl: string;
|
||||||
|
backIconUrl: string;
|
||||||
|
backLabel: string;
|
||||||
|
onChange: (
|
||||||
|
field:
|
||||||
|
| 'galleryCarouselPrevIconUrl'
|
||||||
|
| 'galleryCarouselNextIconUrl'
|
||||||
|
| 'galleryCarouselBackIconUrl'
|
||||||
|
| 'galleryCarouselBackLabel',
|
||||||
|
value: string,
|
||||||
|
) => void;
|
||||||
|
iconAssetOptions: AssetOption[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value normalization helpers
|
* Value normalization helpers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -102,6 +102,8 @@ interface FormState {
|
|||||||
// Tooltip settings
|
// Tooltip settings
|
||||||
tooltipTitle: string;
|
tooltipTitle: string;
|
||||||
tooltipText: string;
|
tooltipText: string;
|
||||||
|
tooltipTitleFontFamily: string;
|
||||||
|
tooltipTextFontFamily: string;
|
||||||
|
|
||||||
// Description settings
|
// Description settings
|
||||||
descriptionTitle: string;
|
descriptionTitle: string;
|
||||||
@ -123,6 +125,11 @@ interface FormState {
|
|||||||
// Carousel settings
|
// Carousel settings
|
||||||
carouselPrevIconUrl: string;
|
carouselPrevIconUrl: string;
|
||||||
carouselNextIconUrl: string;
|
carouselNextIconUrl: string;
|
||||||
|
carouselCaptionFontFamily: string;
|
||||||
|
|
||||||
|
// Gallery settings
|
||||||
|
galleryTitleFontFamily: string;
|
||||||
|
galleryCardFontFamily: string;
|
||||||
|
|
||||||
// Complex arrays
|
// Complex arrays
|
||||||
galleryCards: GalleryCard[];
|
galleryCards: GalleryCard[];
|
||||||
@ -188,6 +195,8 @@ const initialState: FormState = {
|
|||||||
reverseVideoUrl: '',
|
reverseVideoUrl: '',
|
||||||
tooltipTitle: '',
|
tooltipTitle: '',
|
||||||
tooltipText: '',
|
tooltipText: '',
|
||||||
|
tooltipTitleFontFamily: '',
|
||||||
|
tooltipTextFontFamily: '',
|
||||||
descriptionTitle: '',
|
descriptionTitle: '',
|
||||||
descriptionText: '',
|
descriptionText: '',
|
||||||
descriptionTitleFontSize: '',
|
descriptionTitleFontSize: '',
|
||||||
@ -203,6 +212,9 @@ const initialState: FormState = {
|
|||||||
mediaMuted: false,
|
mediaMuted: false,
|
||||||
carouselPrevIconUrl: '',
|
carouselPrevIconUrl: '',
|
||||||
carouselNextIconUrl: '',
|
carouselNextIconUrl: '',
|
||||||
|
carouselCaptionFontFamily: '',
|
||||||
|
galleryTitleFontFamily: '',
|
||||||
|
galleryCardFontFamily: '',
|
||||||
galleryCards: [],
|
galleryCards: [],
|
||||||
carouselSlides: [],
|
carouselSlides: [],
|
||||||
};
|
};
|
||||||
@ -295,6 +307,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
|
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
|
||||||
tooltipTitle: String(settings.tooltipTitle || ''),
|
tooltipTitle: String(settings.tooltipTitle || ''),
|
||||||
tooltipText: String(settings.tooltipText || ''),
|
tooltipText: String(settings.tooltipText || ''),
|
||||||
|
tooltipTitleFontFamily: String(settings.tooltipTitleFontFamily || ''),
|
||||||
|
tooltipTextFontFamily: String(settings.tooltipTextFontFamily || ''),
|
||||||
descriptionTitle: String(settings.descriptionTitle || ''),
|
descriptionTitle: String(settings.descriptionTitle || ''),
|
||||||
descriptionText: String(settings.descriptionText || ''),
|
descriptionText: String(settings.descriptionText || ''),
|
||||||
descriptionTitleFontSize: String(
|
descriptionTitleFontSize: String(
|
||||||
@ -318,6 +332,9 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
mediaMuted: Boolean(settings.mediaMuted),
|
mediaMuted: Boolean(settings.mediaMuted),
|
||||||
carouselPrevIconUrl: String(settings.carouselPrevIconUrl || ''),
|
carouselPrevIconUrl: String(settings.carouselPrevIconUrl || ''),
|
||||||
carouselNextIconUrl: String(settings.carouselNextIconUrl || ''),
|
carouselNextIconUrl: String(settings.carouselNextIconUrl || ''),
|
||||||
|
carouselCaptionFontFamily: String(settings.carouselCaptionFontFamily || ''),
|
||||||
|
galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''),
|
||||||
|
galleryCardFontFamily: String(settings.galleryCardFontFamily || ''),
|
||||||
galleryCards: Array.isArray(settings.galleryCards)
|
galleryCards: Array.isArray(settings.galleryCards)
|
||||||
? settings.galleryCards.map(
|
? settings.galleryCards.map(
|
||||||
(card: Record<string, unknown>, index: number) => ({
|
(card: Record<string, unknown>, index: number) => ({
|
||||||
@ -635,6 +652,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
settings.iconUrl = state.iconUrl.trim();
|
settings.iconUrl = state.iconUrl.trim();
|
||||||
settings.tooltipTitle = state.tooltipTitle.trim();
|
settings.tooltipTitle = state.tooltipTitle.trim();
|
||||||
settings.tooltipText = state.tooltipText;
|
settings.tooltipText = state.tooltipText;
|
||||||
|
settings.tooltipTitleFontFamily = state.tooltipTitleFontFamily.trim();
|
||||||
|
settings.tooltipTextFontFamily = state.tooltipTextFontFamily.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description type settings
|
// Description type settings
|
||||||
@ -666,6 +685,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
title: card.title.trim() || `Card ${index + 1}`,
|
title: card.title.trim() || `Card ${index + 1}`,
|
||||||
description: card.description,
|
description: card.description,
|
||||||
}));
|
}));
|
||||||
|
settings.galleryTitleFontFamily = state.galleryTitleFontFamily.trim();
|
||||||
|
settings.galleryCardFontFamily = state.galleryCardFontFamily.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carousel type settings
|
// Carousel type settings
|
||||||
@ -677,6 +698,7 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
|||||||
}));
|
}));
|
||||||
settings.carouselPrevIconUrl = state.carouselPrevIconUrl.trim();
|
settings.carouselPrevIconUrl = state.carouselPrevIconUrl.trim();
|
||||||
settings.carouselNextIconUrl = state.carouselNextIconUrl.trim();
|
settings.carouselNextIconUrl = state.carouselNextIconUrl.trim();
|
||||||
|
settings.carouselCaptionFontFamily = state.carouselCaptionFontFamily.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media type settings
|
// Media type settings
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import FormImagePicker from '../FormImagePicker';
|
|||||||
import { SelectField } from '../SelectField';
|
import { SelectField } from '../SelectField';
|
||||||
import { SelectFieldMany } from '../SelectFieldMany';
|
import { SelectFieldMany } from '../SelectFieldMany';
|
||||||
import { SwitchField } from '../SwitchField';
|
import { SwitchField } from '../SwitchField';
|
||||||
import { RichTextField } from '../RichTextField';
|
|
||||||
import type { FormFieldConfig } from '../../types/forms';
|
import type { FormFieldConfig } from '../../types/forms';
|
||||||
|
|
||||||
interface GenericFormFieldProps {
|
interface GenericFormFieldProps {
|
||||||
@ -71,7 +70,12 @@ const GenericFormField: React.FC<GenericFormFieldProps> = ({
|
|||||||
case 'richtext':
|
case 'richtext':
|
||||||
return (
|
return (
|
||||||
<FormField label={config.label}>
|
<FormField label={config.label}>
|
||||||
<Field name={config.name} component={RichTextField} />
|
<Field
|
||||||
|
name={config.name}
|
||||||
|
as='textarea'
|
||||||
|
placeholder={config.placeholder || config.label}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
import React, { useEffect, useId, useState } from 'react';
|
|
||||||
import { Editor } from '@tinymce/tinymce-react';
|
|
||||||
import { tinyKey } from '../config';
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
export const RichTextField = ({ options, field, form, itemRef, showField }) => {
|
|
||||||
const [value, setValue] = useState(null);
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (field.value) {
|
|
||||||
setValue(field.value);
|
|
||||||
}
|
|
||||||
}, [field.value]);
|
|
||||||
|
|
||||||
const handleChange = (value) => {
|
|
||||||
form.setFieldValue(field.name, value);
|
|
||||||
setValue(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Editor
|
|
||||||
onEditorChange={handleChange}
|
|
||||||
value={value || ''}
|
|
||||||
apiKey={tinyKey}
|
|
||||||
init={{
|
|
||||||
plugins:
|
|
||||||
'advlist autolink lists link image charmap print preview anchor' +
|
|
||||||
'searchreplace visualblocks code fullscreen' +
|
|
||||||
'insertdatetime media table paste help wordcount',
|
|
||||||
toolbar:
|
|
||||||
'undo redo | formatselect | ' +
|
|
||||||
'bold italic backcolor | alignleft aligncenter ' +
|
|
||||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
|
||||||
'removeformat | code',
|
|
||||||
|
|
||||||
content_style: `${darkMode ? 'body { color: #ffffff; }' : ''}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -21,12 +21,15 @@ interface RuntimeElementProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** Optional URL resolver for preloaded blob URLs */
|
/** Optional URL resolver for preloaded blob URLs */
|
||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
|
/** Gallery card click handler */
|
||||||
|
onGalleryCardClick?: (cardIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||||
element,
|
element,
|
||||||
onClick,
|
onClick,
|
||||||
resolveUrl,
|
resolveUrl,
|
||||||
|
onGalleryCardClick,
|
||||||
}) => {
|
}) => {
|
||||||
const xPercent = element.xPercent ?? 0;
|
const xPercent = element.xPercent ?? 0;
|
||||||
const yPercent = element.yPercent ?? 0;
|
const yPercent = element.yPercent ?? 0;
|
||||||
@ -93,7 +96,11 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
{...eventHandlers}
|
{...eventHandlers}
|
||||||
>
|
>
|
||||||
<UiElementRenderer element={element} resolveUrl={resolveUrl} />
|
<UiElementRenderer
|
||||||
|
element={element}
|
||||||
|
resolveUrl={resolveUrl}
|
||||||
|
onGalleryCardClick={onGalleryCardClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import BaseButton from './BaseButton';
|
|||||||
import CardBox from './CardBox';
|
import CardBox from './CardBox';
|
||||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
@ -36,6 +37,26 @@ import {
|
|||||||
} from '../lib/navigationHelpers';
|
} from '../lib/navigationHelpers';
|
||||||
import type { TransitionPhase } from '../types/presentation';
|
import type { TransitionPhase } from '../types/presentation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse custom_css_json from project for font styling
|
||||||
|
*/
|
||||||
|
const parseCustomCss = (
|
||||||
|
json: string | Record<string, unknown> | null | undefined,
|
||||||
|
): { fontFamily: string; fontStretch: string } => {
|
||||||
|
const defaults = { fontFamily: '', fontStretch: '' };
|
||||||
|
if (!json) return defaults;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
|
||||||
|
return {
|
||||||
|
fontFamily: String(parsed?.fontFamily || ''),
|
||||||
|
fontStretch: String(parsed?.fontStretch || ''),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface RuntimePresentationProps {
|
interface RuntimePresentationProps {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
environment: 'stage' | 'production';
|
environment: 'stage' | 'production';
|
||||||
@ -69,6 +90,10 @@ export default function RuntimePresentation({
|
|||||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
||||||
|
element: any;
|
||||||
|
initialIndex: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||||
@ -328,6 +353,16 @@ export default function RuntimePresentation({
|
|||||||
[navigateToPage, pages, transitionPhase, isBuffering],
|
[navigateToPage, pages, transitionPhase, isBuffering],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler for gallery card clicks
|
||||||
|
const handleGalleryCardClick = useCallback(
|
||||||
|
(element: any, cardIndex: number) => {
|
||||||
|
if (element.galleryCards?.length > 0) {
|
||||||
|
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// URL resolver that uses preloaded blob URLs when available (instant display)
|
// URL resolver that uses preloaded blob URLs when available (instant display)
|
||||||
const resolveUrlWithBlob = useCallback(
|
const resolveUrlWithBlob = useCallback(
|
||||||
(url: string | undefined): string => {
|
(url: string | undefined): string => {
|
||||||
@ -464,6 +499,9 @@ export default function RuntimePresentation({
|
|||||||
element={element}
|
element={element}
|
||||||
onClick={() => handleElementClick(element)}
|
onClick={() => handleElementClick(element)}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
|
onGalleryCardClick={(cardIndex) =>
|
||||||
|
handleGalleryCardClick(element, cardIndex)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -485,19 +523,6 @@ export default function RuntimePresentation({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environment badge */}
|
|
||||||
<div className='absolute top-4 left-4 z-50'>
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-bold ${
|
|
||||||
environment === 'stage'
|
|
||||||
? 'bg-yellow-500 text-black'
|
|
||||||
: 'bg-green-500 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{environment.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
||||||
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
||||||
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
||||||
@ -521,6 +546,41 @@ export default function RuntimePresentation({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gallery Carousel Overlay */}
|
||||||
|
{activeGalleryCarousel && (
|
||||||
|
<GalleryCarouselOverlay
|
||||||
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||||
|
initialIndex={activeGalleryCarousel.initialIndex}
|
||||||
|
onClose={() => setActiveGalleryCarousel(null)}
|
||||||
|
resolveUrl={resolveUrlWithBlob}
|
||||||
|
prevIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
||||||
|
}
|
||||||
|
nextIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
||||||
|
}
|
||||||
|
backIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
||||||
|
}
|
||||||
|
backLabel={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
||||||
|
}
|
||||||
|
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
||||||
|
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
||||||
|
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
||||||
|
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
||||||
|
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
||||||
|
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
||||||
|
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
||||||
|
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
||||||
|
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
||||||
|
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
||||||
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
||||||
|
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
||||||
|
isEditMode={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
385
frontend/src/components/UiElements/GalleryCarouselOverlay.tsx
Normal file
385
frontend/src/components/UiElements/GalleryCarouselOverlay.tsx
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* GalleryCarouselOverlay Component
|
||||||
|
*
|
||||||
|
* Fullscreen carousel overlay for gallery elements.
|
||||||
|
* Shows images in a slideshow with navigation buttons.
|
||||||
|
* In constructor mode, buttons are draggable for positioning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import Icon from '@mdi/react';
|
||||||
|
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
|
||||||
|
import type { GalleryCard } from '../../types/constructor';
|
||||||
|
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||||
|
|
||||||
|
interface GalleryCarouselOverlayProps {
|
||||||
|
cards: GalleryCard[];
|
||||||
|
initialIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
|
// Button icons (MDI fallback if not set)
|
||||||
|
prevIconUrl?: string;
|
||||||
|
nextIconUrl?: string;
|
||||||
|
backIconUrl?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
// Button positions (percentage-based, like canvas elements)
|
||||||
|
prevX?: number;
|
||||||
|
prevY?: number;
|
||||||
|
nextX?: number;
|
||||||
|
nextY?: number;
|
||||||
|
backX?: number;
|
||||||
|
backY?: number;
|
||||||
|
// Button dimensions (CSS values like '48px', '3rem')
|
||||||
|
// When set with custom icon, button renders like NavigationElement (icon fills full button)
|
||||||
|
prevWidth?: string;
|
||||||
|
prevHeight?: string;
|
||||||
|
nextWidth?: string;
|
||||||
|
nextHeight?: string;
|
||||||
|
backWidth?: string;
|
||||||
|
backHeight?: string;
|
||||||
|
// Constructor mode: buttons draggable
|
||||||
|
isEditMode?: boolean;
|
||||||
|
onButtonPositionChange?: (
|
||||||
|
button: 'prev' | 'next' | 'back',
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(Math.max(value, min), max);
|
||||||
|
|
||||||
|
const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||||
|
cards,
|
||||||
|
initialIndex,
|
||||||
|
onClose,
|
||||||
|
resolveUrl,
|
||||||
|
prevIconUrl,
|
||||||
|
nextIconUrl,
|
||||||
|
backIconUrl,
|
||||||
|
backLabel = 'BACK',
|
||||||
|
prevX = 5,
|
||||||
|
prevY = 50,
|
||||||
|
nextX = 95,
|
||||||
|
nextY = 50,
|
||||||
|
backX = 5,
|
||||||
|
backY = 90,
|
||||||
|
prevWidth,
|
||||||
|
prevHeight,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
backWidth,
|
||||||
|
backHeight,
|
||||||
|
isEditMode = false,
|
||||||
|
onButtonPositionChange,
|
||||||
|
}) => {
|
||||||
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
|
const [draggingButton, setDraggingButton] = useState<
|
||||||
|
'prev' | 'next' | 'back' | null
|
||||||
|
>(null);
|
||||||
|
const [positions, setPositions] = useState({
|
||||||
|
prevX,
|
||||||
|
prevY,
|
||||||
|
nextX,
|
||||||
|
nextY,
|
||||||
|
backX,
|
||||||
|
backY,
|
||||||
|
});
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const positionsRef = useRef(positions);
|
||||||
|
positionsRef.current = positions;
|
||||||
|
|
||||||
|
// Update positions when props change (e.g., when element is re-selected)
|
||||||
|
useEffect(() => {
|
||||||
|
setPositions({ prevX, prevY, nextX, nextY, backX, backY });
|
||||||
|
}, [prevX, prevY, nextX, nextY, backX, backY]);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const goToPrev = useCallback(() => {
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
|
||||||
|
}, [cards.length]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
if (cards.length === 0) return;
|
||||||
|
setCurrentIndex((prev) => (prev + 1) % cards.length);
|
||||||
|
}, [cards.length]);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
goToPrev();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
goToNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose, goToPrev, goToNext]);
|
||||||
|
|
||||||
|
// Touch swipe handling
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (isEditMode) return;
|
||||||
|
touchStartRef.current = {
|
||||||
|
x: e.touches[0].clientX,
|
||||||
|
y: e.touches[0].clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
if (isEditMode || !touchStartRef.current) return;
|
||||||
|
|
||||||
|
const touchEnd = {
|
||||||
|
x: e.changedTouches[0].clientX,
|
||||||
|
y: e.changedTouches[0].clientY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaX = touchEnd.x - touchStartRef.current.x;
|
||||||
|
const threshold = 50;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > threshold) {
|
||||||
|
if (deltaX > 0) {
|
||||||
|
goToPrev();
|
||||||
|
} else {
|
||||||
|
goToNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStartRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draggable button handling (constructor mode only)
|
||||||
|
const handleButtonDragStart = (
|
||||||
|
button: 'prev' | 'next' | 'back',
|
||||||
|
e: React.MouseEvent,
|
||||||
|
) => {
|
||||||
|
if (!isEditMode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraggingButton(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditMode || !draggingButton) return;
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const x = (e.clientX / window.innerWidth) * 100;
|
||||||
|
const y = (e.clientY / window.innerHeight) * 100;
|
||||||
|
const clampedX = clamp(x, 2, 98);
|
||||||
|
const clampedY = clamp(y, 2, 98);
|
||||||
|
|
||||||
|
setPositions((prev) => {
|
||||||
|
// Prev and next buttons share the same Y coordinate
|
||||||
|
if (draggingButton === 'prev' || draggingButton === 'next') {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[`${draggingButton}X`]: clampedX,
|
||||||
|
prevY: clampedY,
|
||||||
|
nextY: clampedY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Back button has independent position
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[`${draggingButton}X`]: clampedX,
|
||||||
|
[`${draggingButton}Y`]: clampedY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUp = () => {
|
||||||
|
if (onButtonPositionChange && draggingButton) {
|
||||||
|
const currentPositions = positionsRef.current;
|
||||||
|
const posKey = `${draggingButton}X` as keyof typeof positions;
|
||||||
|
const posKeyY = `${draggingButton}Y` as keyof typeof positions;
|
||||||
|
onButtonPositionChange(
|
||||||
|
draggingButton,
|
||||||
|
currentPositions[posKey],
|
||||||
|
currentPositions[posKeyY],
|
||||||
|
);
|
||||||
|
// For prev/next, also update the other button's Y position
|
||||||
|
if (draggingButton === 'prev') {
|
||||||
|
onButtonPositionChange('next', currentPositions.nextX, currentPositions.prevY);
|
||||||
|
} else if (draggingButton === 'next') {
|
||||||
|
onButtonPositionChange('prev', currentPositions.prevX, currentPositions.nextY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDraggingButton(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
}, [isEditMode, draggingButton, onButtonPositionChange]);
|
||||||
|
|
||||||
|
// Convert numeric value to rem CSS value
|
||||||
|
const toRem = (value?: string): string | undefined => {
|
||||||
|
if (!value || value.trim() === '') return undefined;
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return undefined;
|
||||||
|
return `${num}rem`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render navigation button
|
||||||
|
// When custom icon is set, render like NavigationElement (icon fills full button, no backdrop)
|
||||||
|
// Otherwise, use MDI fallback with backdrop styling
|
||||||
|
const renderNavButton = (
|
||||||
|
type: 'prev' | 'next' | 'back',
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
iconUrl?: string,
|
||||||
|
defaultIcon?: string,
|
||||||
|
label?: string,
|
||||||
|
buttonWidth?: string,
|
||||||
|
buttonHeight?: string,
|
||||||
|
) => {
|
||||||
|
const isDragging = draggingButton === type;
|
||||||
|
const isNavButton = type === 'prev' || type === 'next';
|
||||||
|
const hasCustomIcon = iconUrl && iconUrl.trim() !== '';
|
||||||
|
const widthRem = toRem(buttonWidth);
|
||||||
|
const heightRem = toRem(buttonHeight);
|
||||||
|
|
||||||
|
// Navigation-style rendering: custom icon fills full button (like NavigationElement)
|
||||||
|
// When custom icon is set, always use navigation style (icon only, no backdrop)
|
||||||
|
const useNavigationStyle = hasCustomIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={`absolute flex items-center justify-center transition-transform ${
|
||||||
|
isEditMode
|
||||||
|
? 'cursor-move hover:scale-110'
|
||||||
|
: 'cursor-pointer hover:scale-105'
|
||||||
|
} ${isDragging ? 'scale-110 z-[60]' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${x}%`,
|
||||||
|
top: `${y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
// Apply dimensions when set
|
||||||
|
...(widthRem && { width: widthRem }),
|
||||||
|
...(heightRem && { height: heightRem }),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEditMode) return;
|
||||||
|
if (type === 'prev') goToPrev();
|
||||||
|
else if (type === 'next') goToNext();
|
||||||
|
else onClose();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleButtonDragStart(type, e)}
|
||||||
|
>
|
||||||
|
{useNavigationStyle ? (
|
||||||
|
// Navigation-style: icon fills full button, no backdrop, no label
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={resolve(iconUrl)}
|
||||||
|
alt=''
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Default style: MDI icon with backdrop
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 ${
|
||||||
|
isNavButton
|
||||||
|
? 'rounded-full bg-black/40 p-3 backdrop-blur-sm'
|
||||||
|
: 'rounded-lg bg-black/40 px-4 py-2 backdrop-blur-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{defaultIcon && (
|
||||||
|
<Icon
|
||||||
|
path={defaultIcon}
|
||||||
|
size={isNavButton ? 1.5 : 1}
|
||||||
|
className='text-white'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label && (
|
||||||
|
<span className='text-sm font-medium text-white'>{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentCard = cards[currentIndex];
|
||||||
|
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className='fixed inset-0 z-50 overflow-hidden bg-black'
|
||||||
|
onClick={(e) => {
|
||||||
|
// Only close if clicking the background, not buttons
|
||||||
|
if (e.target === overlayRef.current && !isEditMode) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Fullscreen image */}
|
||||||
|
{imageUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={currentCard?.title || ''}
|
||||||
|
className='absolute inset-0 h-full w-full object-cover'
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prev button */}
|
||||||
|
{renderNavButton(
|
||||||
|
'prev',
|
||||||
|
positions.prevX,
|
||||||
|
positions.prevY,
|
||||||
|
prevIconUrl,
|
||||||
|
mdiChevronLeft,
|
||||||
|
undefined,
|
||||||
|
prevWidth,
|
||||||
|
prevHeight,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next button */}
|
||||||
|
{renderNavButton(
|
||||||
|
'next',
|
||||||
|
positions.nextX,
|
||||||
|
positions.nextY,
|
||||||
|
nextIconUrl,
|
||||||
|
mdiChevronRight,
|
||||||
|
undefined,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
{renderNavButton(
|
||||||
|
'back',
|
||||||
|
positions.backX,
|
||||||
|
positions.backY,
|
||||||
|
backIconUrl,
|
||||||
|
mdiArrowLeft,
|
||||||
|
backLabel,
|
||||||
|
backWidth,
|
||||||
|
backHeight,
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GalleryCarouselOverlay;
|
||||||
@ -43,6 +43,8 @@ export interface UiElementRendererProps {
|
|||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
// Gallery carousel callback
|
||||||
|
onGalleryCardClick?: (cardIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,6 +59,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
|
onGalleryCardClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { className, style } = useElementWrapperStyle({
|
const { className, style } = useElementWrapperStyle({
|
||||||
element,
|
element,
|
||||||
@ -73,7 +76,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
|||||||
return <NavigationElement {...commonProps} />;
|
return <NavigationElement {...commonProps} />;
|
||||||
}
|
}
|
||||||
if (isGalleryElementType(element.type)) {
|
if (isGalleryElementType(element.type)) {
|
||||||
return <GalleryElement {...commonProps} />;
|
return <GalleryElement {...commonProps} onCardClick={onGalleryCardClick} />;
|
||||||
}
|
}
|
||||||
if (isTooltipElementType(element.type)) {
|
if (isTooltipElementType(element.type)) {
|
||||||
return <TooltipElement {...commonProps} />;
|
return <TooltipElement {...commonProps} />;
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* GalleryElement Component
|
* GalleryElement Component
|
||||||
*
|
*
|
||||||
* Gallery element - grid of image cards.
|
* Gallery element with header image, title, info spans, and grid of image cards.
|
||||||
* Renders with unified wrapper styling + content.
|
* Renders with unified wrapper styling + content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { CanvasElement, GalleryCard } from '../../../types/constructor';
|
import type {
|
||||||
|
CanvasElement,
|
||||||
|
GalleryCard,
|
||||||
|
GalleryInfoSpan,
|
||||||
|
} from '../../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
|
|
||||||
interface GalleryElementProps {
|
interface GalleryElementProps {
|
||||||
@ -15,6 +19,7 @@ interface GalleryElementProps {
|
|||||||
resolveUrl?: (url: string | undefined) => string;
|
resolveUrl?: (url: string | undefined) => string;
|
||||||
className: string;
|
className: string;
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
|
onCardClick?: (cardIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryElement: React.FC<GalleryElementProps> = ({
|
const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||||
@ -22,29 +27,94 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
|||||||
resolveUrl,
|
resolveUrl,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
onCardClick,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||||
const cards: GalleryCard[] = element.galleryCards || [];
|
const cards: GalleryCard[] = element.galleryCards || [];
|
||||||
|
const infoSpans: GalleryInfoSpan[] = element.galleryInfoSpans || [];
|
||||||
|
const headerImageUrl = element.galleryHeaderImageUrl;
|
||||||
|
const title = element.galleryTitle;
|
||||||
|
const columns = element.galleryColumns || 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
|
<div className='flex flex-col gap-2 p-3 bg-black/60 rounded-xl min-w-[200px] backdrop-blur-sm'>
|
||||||
{cards.map((card) => (
|
{/* Header image */}
|
||||||
<div
|
{headerImageUrl && (
|
||||||
key={card.id}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
className='relative aspect-square min-w-[40px] min-h-[40px]'
|
<img
|
||||||
>
|
src={resolve(headerImageUrl)}
|
||||||
{card.imageUrl && (
|
alt=''
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
className='w-full h-auto object-cover rounded-lg'
|
||||||
<img
|
draggable={false}
|
||||||
src={resolve(card.imageUrl)}
|
/>
|
||||||
alt={card.title || ''}
|
)}
|
||||||
className='absolute inset-0 w-full h-full object-cover rounded'
|
|
||||||
draggable={false}
|
{/* Title */}
|
||||||
/>
|
{title && (
|
||||||
)}
|
<div className='bg-amber-50 text-slate-800 text-center py-2 px-3 rounded-lg font-semibold text-sm'>
|
||||||
|
{title}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Info spans */}
|
||||||
|
{infoSpans.length > 0 && (
|
||||||
|
<div
|
||||||
|
className='grid gap-2'
|
||||||
|
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{infoSpans.map((span) => (
|
||||||
|
<div
|
||||||
|
key={span.id}
|
||||||
|
className='bg-slate-700 text-amber-50 text-center py-2 px-2 rounded-lg text-xs font-medium'
|
||||||
|
>
|
||||||
|
{span.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery cards */}
|
||||||
|
{cards.length > 0 && (
|
||||||
|
<div
|
||||||
|
className='grid gap-2 w-full'
|
||||||
|
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
|
||||||
|
>
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className={`relative aspect-[4/3] min-w-[50px] min-h-[40px] ${
|
||||||
|
onCardClick
|
||||||
|
? 'cursor-pointer hover:ring-2 hover:ring-white hover:ring-offset-1 hover:ring-offset-black/50 transition-all'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onCardClick) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCardClick(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.imageUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={resolve(card.imageUrl)}
|
||||||
|
alt={card.title || ''}
|
||||||
|
className='absolute inset-0 w-full h-full object-cover rounded-lg'
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{card.title && (
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
|
<span className='text-white text-xs font-bold drop-shadow-lg'>
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,26 +11,116 @@ function extractExtensionFrom(filename) {
|
|||||||
return regex.exec(filename)[1];
|
return regex.exec(filename)[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid MIME type prefixes and specific types for each asset format
|
||||||
|
*/
|
||||||
|
const VALID_MIME_TYPES = {
|
||||||
|
image: {
|
||||||
|
prefixes: ['image/'],
|
||||||
|
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'],
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
prefixes: ['video/'],
|
||||||
|
extensions: ['mp4', 'webm', 'mov', 'avi', 'mkv', 'm4v', 'ogv'],
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
prefixes: ['audio/'],
|
||||||
|
extensions: ['mp3', 'wav', 'ogg', 'aac', 'm4a', 'flac', 'weba'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a file matches the expected asset type
|
||||||
|
* @param {File} file - The file to validate
|
||||||
|
* @param {string} expectedType - Expected type: 'image', 'video', or 'audio'
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateAssetType(file, expectedType) {
|
||||||
|
if (!expectedType || !VALID_MIME_TYPES[expectedType]) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prefixes, extensions } = VALID_MIME_TYPES[expectedType];
|
||||||
|
const mimeType = (file.type || '').toLowerCase();
|
||||||
|
const extension = extractExtensionFrom(file.name)?.toLowerCase();
|
||||||
|
|
||||||
|
// Check MIME type prefix
|
||||||
|
const hasMimeMatch = prefixes.some((prefix) => mimeType.startsWith(prefix));
|
||||||
|
|
||||||
|
// Check file extension as fallback (some browsers don't report MIME correctly)
|
||||||
|
const hasExtensionMatch = extension && extensions.includes(extension);
|
||||||
|
|
||||||
|
if (!hasMimeMatch && !hasExtensionMatch) {
|
||||||
|
const typeLabel = expectedType.charAt(0).toUpperCase() + expectedType.slice(1);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid file type. Expected ${typeLabel} file but got "${mimeType || 'unknown'}" (${file.name})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
export default class FileUploader {
|
export default class FileUploader {
|
||||||
|
/**
|
||||||
|
* Validate file against schema
|
||||||
|
* @param {File} file - File to validate
|
||||||
|
* @param {Object} schema - Validation schema
|
||||||
|
* @param {boolean} [schema.image] - Must be an image
|
||||||
|
* @param {boolean} [schema.video] - Must be a video
|
||||||
|
* @param {boolean} [schema.audio] - Must be audio
|
||||||
|
* @param {string} [schema.assetType] - Asset type: 'image', 'video', 'audio'
|
||||||
|
* @param {number} [schema.size] - Max file size in bytes
|
||||||
|
* @param {string[]} [schema.formats] - Allowed extensions
|
||||||
|
*/
|
||||||
static validate(file, schema) {
|
static validate(file, schema) {
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asset type validation (new unified approach)
|
||||||
|
if (schema.assetType) {
|
||||||
|
const result = validateAssetType(file, schema.assetType);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy image validation
|
||||||
if (schema.image) {
|
if (schema.image) {
|
||||||
if (!file.type.startsWith('image')) {
|
const result = validateAssetType(file, 'image');
|
||||||
|
if (!result.valid) {
|
||||||
throw new Error('You must upload an image');
|
throw new Error('You must upload an image');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schema.size && file.size > schema.size) {
|
// Legacy video validation
|
||||||
throw new Error('File is too big.');
|
if (schema.video) {
|
||||||
|
const result = validateAssetType(file, 'video');
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error('You must upload a video');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = extractExtensionFrom(file.name);
|
// Legacy audio validation
|
||||||
|
if (schema.audio) {
|
||||||
|
const result = validateAssetType(file, 'audio');
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error('You must upload an audio file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (schema.formats && !schema.formats.includes(extension)) {
|
// File size validation
|
||||||
throw new Error('Invalid format');
|
if (schema.size && file.size > schema.size) {
|
||||||
|
const maxSizeMB = (schema.size / (1024 * 1024)).toFixed(1);
|
||||||
|
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
throw new Error(`File is too big. Maximum ${maxSizeMB}MB, got ${fileSizeMB}MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension validation
|
||||||
|
const extension = extractExtensionFrom(file.name)?.toLowerCase();
|
||||||
|
if (schema.formats && extension && !schema.formats.includes(extension)) {
|
||||||
|
throw new Error(`Invalid format. Allowed: ${schema.formats.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,8 +289,6 @@ export default class FileUploader {
|
|||||||
|
|
||||||
const privateUrl = `${path}/${filename}`;
|
const privateUrl = `${path}/${filename}`;
|
||||||
|
|
||||||
// Debug logging removed for production builds
|
|
||||||
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,5 +18,3 @@ export const appTitle = 'Shimahara Visual';
|
|||||||
|
|
||||||
export const getPageTitle = (currentPageTitle: string) =>
|
export const getPageTitle = (currentPageTitle: string) =>
|
||||||
`${currentPageTitle} — ${appTitle}`;
|
`${currentPageTitle} — ${appTitle}`;
|
||||||
|
|
||||||
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '';
|
|
||||||
|
|||||||
@ -98,3 +98,14 @@
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Instrument Sans font utilities */
|
||||||
|
.font-instrument-condensed {
|
||||||
|
font-family: 'Instrument Sans Variable', sans-serif;
|
||||||
|
font-stretch: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-instrument {
|
||||||
|
font-family: 'Instrument Sans Variable', sans-serif;
|
||||||
|
font-stretch: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
CanvasElement,
|
CanvasElement,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
GalleryCard,
|
GalleryCard,
|
||||||
|
GalleryInfoSpan,
|
||||||
CarouselSlide,
|
CarouselSlide,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import {
|
import {
|
||||||
@ -83,6 +84,12 @@ interface UseConstructorElementsResult {
|
|||||||
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
remove: (cardId: string) => void;
|
remove: (cardId: string) => void;
|
||||||
};
|
};
|
||||||
|
/** Gallery info span operations */
|
||||||
|
galleryInfoSpans: {
|
||||||
|
add: () => void;
|
||||||
|
update: (spanId: string, text: string) => void;
|
||||||
|
remove: (spanId: string) => void;
|
||||||
|
};
|
||||||
/** Carousel slide operations */
|
/** Carousel slide operations */
|
||||||
carouselSlides: {
|
carouselSlides: {
|
||||||
add: () => void;
|
add: () => void;
|
||||||
@ -338,6 +345,41 @@ export function useConstructorElements({
|
|||||||
[selectedElement, updateSelectedElement],
|
[selectedElement, updateSelectedElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Gallery info span operations
|
||||||
|
const galleryInfoSpans = useMemo(
|
||||||
|
() => ({
|
||||||
|
add: () => {
|
||||||
|
if (!selectedElement || !isGalleryElementType(selectedElement.type))
|
||||||
|
return;
|
||||||
|
const nextSpans: GalleryInfoSpan[] = [
|
||||||
|
...(selectedElement.galleryInfoSpans || []),
|
||||||
|
{
|
||||||
|
id: createLocalId(),
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
updateSelectedElement({ galleryInfoSpans: nextSpans });
|
||||||
|
},
|
||||||
|
update: (spanId: string, text: string) => {
|
||||||
|
if (!selectedElement || !isGalleryElementType(selectedElement.type))
|
||||||
|
return;
|
||||||
|
const nextSpans = (selectedElement.galleryInfoSpans || []).map((span) =>
|
||||||
|
span.id === spanId ? { ...span, text } : span,
|
||||||
|
);
|
||||||
|
updateSelectedElement({ galleryInfoSpans: nextSpans });
|
||||||
|
},
|
||||||
|
remove: (spanId: string) => {
|
||||||
|
if (!selectedElement || !isGalleryElementType(selectedElement.type))
|
||||||
|
return;
|
||||||
|
const nextSpans = (selectedElement.galleryInfoSpans || []).filter(
|
||||||
|
(span) => span.id !== spanId,
|
||||||
|
);
|
||||||
|
updateSelectedElement({ galleryInfoSpans: nextSpans });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[selectedElement, updateSelectedElement],
|
||||||
|
);
|
||||||
|
|
||||||
// Carousel slide operations
|
// Carousel slide operations
|
||||||
const carouselSlides = useMemo(
|
const carouselSlides = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -387,6 +429,7 @@ export function useConstructorElements({
|
|||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
removeElement,
|
removeElement,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
updateElementPosition,
|
updateElementPosition,
|
||||||
normalizeNavigationType: normalizeNavigationElementType,
|
normalizeNavigationType: normalizeNavigationElementType,
|
||||||
|
|||||||
@ -274,8 +274,8 @@ export function usePageSwitch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try cached blob URL by resolved URL
|
// Fallback: try cached blob URL by resolved URL (check Cache API directly)
|
||||||
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
|
if (cache?.getCachedBlobUrl) {
|
||||||
try {
|
try {
|
||||||
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
@ -342,7 +342,8 @@ export function usePageSwitch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
|
// Try cached blob URL by resolved URL (check Cache API directly)
|
||||||
|
if (cache?.getCachedBlobUrl) {
|
||||||
try {
|
try {
|
||||||
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
|
|||||||
@ -560,6 +560,36 @@ export function usePreloadOrchestrator(
|
|||||||
return () => clearReadyBlobUrls();
|
return () => clearReadyBlobUrls();
|
||||||
}, [clearReadyBlobUrls]);
|
}, [clearReadyBlobUrls]);
|
||||||
|
|
||||||
|
// Initialize ready blob URLs from Cache API for current page's background assets
|
||||||
|
// This ensures getReadyBlobUrl works on the first render
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPageId) return;
|
||||||
|
|
||||||
|
const currentPage = pages.find((p) => p.id === currentPageId);
|
||||||
|
if (!currentPage) return;
|
||||||
|
|
||||||
|
const initializeFromCache = async () => {
|
||||||
|
const bgUrls = [
|
||||||
|
currentPage.background_image_url,
|
||||||
|
currentPage.background_video_url,
|
||||||
|
currentPage.background_audio_url,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
for (const storagePath of bgUrls) {
|
||||||
|
// Skip if already in memory
|
||||||
|
if (readyBlobUrlsRef.current.has(storagePath)) continue;
|
||||||
|
|
||||||
|
const fullUrl = resolveAssetPlaybackUrl(storagePath);
|
||||||
|
if (readyBlobUrlsRef.current.has(fullUrl)) continue;
|
||||||
|
|
||||||
|
// Try to load from Cache API
|
||||||
|
await createReadyBlobUrl(fullUrl, storagePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeFromCache();
|
||||||
|
}, [currentPageId, pages, createReadyBlobUrl]);
|
||||||
|
|
||||||
// React to page changes - preload neighbors
|
// React to page changes - preload neighbors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !currentPageId || !networkInfo.isOnline) {
|
if (!enabled || !currentPageId || !networkInfo.isOnline) {
|
||||||
|
|||||||
@ -467,6 +467,16 @@ export const buildElementSettings = (
|
|||||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||||
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
|
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
|
||||||
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
|
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
|
||||||
|
addIfNotEmpty(
|
||||||
|
settings,
|
||||||
|
'tooltipTitleFontFamily',
|
||||||
|
element.tooltipTitleFontFamily,
|
||||||
|
);
|
||||||
|
addIfNotEmpty(
|
||||||
|
settings,
|
||||||
|
'tooltipTextFontFamily',
|
||||||
|
element.tooltipTextFontFamily,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description type settings
|
// Description type settings
|
||||||
@ -512,16 +522,25 @@ export const buildElementSettings = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gallery type settings
|
// Gallery type settings
|
||||||
if (
|
if (isGalleryElementType(elementType)) {
|
||||||
isGalleryElementType(elementType) &&
|
if (Array.isArray(element.galleryCards)) {
|
||||||
Array.isArray(element.galleryCards)
|
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
||||||
) {
|
id: String(card.id || createLocalId()),
|
||||||
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
imageUrl: card.imageUrl || '',
|
||||||
id: String(card.id || createLocalId()),
|
title: card.title || `Card ${i + 1}`,
|
||||||
imageUrl: card.imageUrl || '',
|
description: card.description || '',
|
||||||
title: card.title || `Card ${i + 1}`,
|
}));
|
||||||
description: card.description || '',
|
}
|
||||||
}));
|
addIfNotEmpty(
|
||||||
|
settings,
|
||||||
|
'galleryTitleFontFamily',
|
||||||
|
element.galleryTitleFontFamily,
|
||||||
|
);
|
||||||
|
addIfNotEmpty(
|
||||||
|
settings,
|
||||||
|
'galleryCardFontFamily',
|
||||||
|
element.galleryCardFontFamily,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carousel type settings
|
// Carousel type settings
|
||||||
@ -535,6 +554,11 @@ export const buildElementSettings = (
|
|||||||
}
|
}
|
||||||
addIfNotEmpty(settings, 'carouselPrevIconUrl', element.carouselPrevIconUrl);
|
addIfNotEmpty(settings, 'carouselPrevIconUrl', element.carouselPrevIconUrl);
|
||||||
addIfNotEmpty(settings, 'carouselNextIconUrl', element.carouselNextIconUrl);
|
addIfNotEmpty(settings, 'carouselNextIconUrl', element.carouselNextIconUrl);
|
||||||
|
addIfNotEmpty(
|
||||||
|
settings,
|
||||||
|
'carouselCaptionFontFamily',
|
||||||
|
element.carouselCaptionFontFamily,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media type settings
|
// Media type settings
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export interface ElementStyleProperties {
|
|||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
|
fontStretch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,6 +69,7 @@ export const ELEMENT_STYLE_PROPS = [
|
|||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
'color',
|
'color',
|
||||||
'fontFamily',
|
'fontFamily',
|
||||||
|
'fontStretch',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
110
frontend/src/lib/fonts.ts
Normal file
110
frontend/src/lib/fonts.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Font Configuration
|
||||||
|
*
|
||||||
|
* Centralized configuration for supported fonts in the platform.
|
||||||
|
* Used in style settings dropdowns and CSS generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FontOption {
|
||||||
|
/** Unique key for the font option */
|
||||||
|
key: string;
|
||||||
|
/** Display label in dropdowns */
|
||||||
|
label: string;
|
||||||
|
/** CSS font-family value */
|
||||||
|
fontFamily: string;
|
||||||
|
/** Optional font-stretch value (for condensed variants) */
|
||||||
|
fontStretch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported fonts for UI elements
|
||||||
|
*/
|
||||||
|
export const FONT_OPTIONS: FontOption[] = [
|
||||||
|
{
|
||||||
|
key: 'instrument-sans',
|
||||||
|
label: 'Instrument Sans',
|
||||||
|
fontFamily: "'Instrument Sans Variable', sans-serif",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'instrument-sans-condensed',
|
||||||
|
label: 'Instrument Sans Condensed',
|
||||||
|
fontFamily: "'Instrument Sans Variable', sans-serif",
|
||||||
|
fontStretch: '75%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system-ui',
|
||||||
|
label: 'System UI',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'arial',
|
||||||
|
label: 'Arial',
|
||||||
|
fontFamily: 'Arial, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'helvetica',
|
||||||
|
label: 'Helvetica',
|
||||||
|
fontFamily: 'Helvetica, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'georgia',
|
||||||
|
label: 'Georgia',
|
||||||
|
fontFamily: 'Georgia, serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'times-new-roman',
|
||||||
|
label: 'Times New Roman',
|
||||||
|
fontFamily: "'Times New Roman', serif",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'monospace',
|
||||||
|
label: 'Monospace',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get font option by key
|
||||||
|
*/
|
||||||
|
export function getFontByKey(key: string): FontOption | undefined {
|
||||||
|
return FONT_OPTIONS.find((f) => f.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get font option by font-family and font-stretch values
|
||||||
|
*/
|
||||||
|
export function getFontByValues(
|
||||||
|
fontFamily: string,
|
||||||
|
fontStretch?: string,
|
||||||
|
): FontOption | undefined {
|
||||||
|
return FONT_OPTIONS.find(
|
||||||
|
(f) =>
|
||||||
|
f.fontFamily === fontFamily &&
|
||||||
|
(f.fontStretch || '') === (fontStretch || ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the font key from stored fontFamily and fontStretch values
|
||||||
|
*/
|
||||||
|
export function getFontKeyFromValues(
|
||||||
|
fontFamily?: string,
|
||||||
|
fontStretch?: string,
|
||||||
|
): string {
|
||||||
|
if (!fontFamily) return '';
|
||||||
|
const font = getFontByValues(fontFamily, fontStretch);
|
||||||
|
return font?.key || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSS style object for a font option
|
||||||
|
*/
|
||||||
|
export function getFontStyle(font: FontOption): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
fontFamily: font.fontFamily,
|
||||||
|
};
|
||||||
|
if (font.fontStretch) {
|
||||||
|
style.fontStretch = font.fontStretch;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
@ -5,12 +5,15 @@ import type { NextPage } from 'next';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { store } from '../stores/store';
|
import { store } from '../stores/store';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
// Import Instrument Sans font (self-hosted via Fontsource)
|
||||||
|
import '@fontsource-variable/instrument-sans';
|
||||||
|
import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis
|
||||||
import '../css/main.css';
|
import '../css/main.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { baseURLApi } from '../config';
|
import { baseURLApi } from '../config';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import ErrorBoundary from '../components/ErrorBoundary';
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import DevModeBadge from '../components/DevModeBadge';
|
|
||||||
import 'intro.js/introjs.css';
|
import 'intro.js/introjs.css';
|
||||||
import { appWithTranslation } from 'next-i18next';
|
import { appWithTranslation } from 'next-i18next';
|
||||||
import '../i18n';
|
import '../i18n';
|
||||||
@ -311,10 +314,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
stepsEnabled={stepsEnabled}
|
stepsEnabled={stepsEnabled}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
/>
|
/>
|
||||||
{(process.env.NODE_ENV === 'development' ||
|
|
||||||
(process.env.NODE_ENV as string) === 'dev_stage') && (
|
|
||||||
<DevModeBadge />
|
|
||||||
)}
|
|
||||||
</>,
|
</>,
|
||||||
)}
|
)}
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
|
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
|
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/assets/assetsSlice';
|
import { update, fetch } from '../../stores/assets/assetsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import ConstructorControlsPanel from '../components/Constructor/ConstructorContr
|
|||||||
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
||||||
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
||||||
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||||
|
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
||||||
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
@ -182,6 +183,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const [elementEditorTab, setElementEditorTab] = useState<
|
const [elementEditorTab, setElementEditorTab] = useState<
|
||||||
'general' | 'css' | 'effects'
|
'general' | 'css' | 'effects'
|
||||||
>('general');
|
>('general');
|
||||||
|
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
||||||
|
element: CanvasElement;
|
||||||
|
initialIndex: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
@ -200,6 +205,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateSelectedElement,
|
updateSelectedElement,
|
||||||
removeSelectedElement,
|
removeSelectedElement,
|
||||||
galleryCards,
|
galleryCards,
|
||||||
|
galleryInfoSpans,
|
||||||
carouselSlides,
|
carouselSlides,
|
||||||
updateElementPosition,
|
updateElementPosition,
|
||||||
normalizeNavigationType,
|
normalizeNavigationType,
|
||||||
@ -813,6 +819,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
description: String(card?.description || ''),
|
description: String(card?.description || ''),
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
galleryHeaderImageUrl:
|
||||||
|
typeof item.galleryHeaderImageUrl === 'string'
|
||||||
|
? item.galleryHeaderImageUrl
|
||||||
|
: undefined,
|
||||||
|
galleryTitle:
|
||||||
|
typeof item.galleryTitle === 'string'
|
||||||
|
? item.galleryTitle
|
||||||
|
: undefined,
|
||||||
|
galleryInfoSpans: Array.isArray(item.galleryInfoSpans)
|
||||||
|
? item.galleryInfoSpans.map((span: any) => ({
|
||||||
|
id: String(span?.id || createLocalId()),
|
||||||
|
text: String(span?.text || ''),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
galleryColumns:
|
||||||
|
typeof item.galleryColumns === 'number'
|
||||||
|
? item.galleryColumns
|
||||||
|
: undefined,
|
||||||
carouselSlides: Array.isArray(item.carouselSlides)
|
carouselSlides: Array.isArray(item.carouselSlides)
|
||||||
? item.carouselSlides.map((slide: any, index: number) => ({
|
? item.carouselSlides.map((slide: any, index: number) => ({
|
||||||
id: String(slide?.id || createLocalId()),
|
id: String(slide?.id || createLocalId()),
|
||||||
@ -829,6 +853,47 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
typeof item.carouselNextIconUrl === 'string'
|
typeof item.carouselNextIconUrl === 'string'
|
||||||
? item.carouselNextIconUrl
|
? item.carouselNextIconUrl
|
||||||
: '',
|
: '',
|
||||||
|
// Gallery Carousel Settings
|
||||||
|
galleryCarouselPrevIconUrl:
|
||||||
|
typeof item.galleryCarouselPrevIconUrl === 'string'
|
||||||
|
? item.galleryCarouselPrevIconUrl
|
||||||
|
: '',
|
||||||
|
galleryCarouselNextIconUrl:
|
||||||
|
typeof item.galleryCarouselNextIconUrl === 'string'
|
||||||
|
? item.galleryCarouselNextIconUrl
|
||||||
|
: '',
|
||||||
|
galleryCarouselBackIconUrl:
|
||||||
|
typeof item.galleryCarouselBackIconUrl === 'string'
|
||||||
|
? item.galleryCarouselBackIconUrl
|
||||||
|
: '',
|
||||||
|
galleryCarouselBackLabel:
|
||||||
|
typeof item.galleryCarouselBackLabel === 'string'
|
||||||
|
? item.galleryCarouselBackLabel
|
||||||
|
: '',
|
||||||
|
galleryCarouselPrevX:
|
||||||
|
typeof item.galleryCarouselPrevX === 'number'
|
||||||
|
? item.galleryCarouselPrevX
|
||||||
|
: undefined,
|
||||||
|
galleryCarouselPrevY:
|
||||||
|
typeof item.galleryCarouselPrevY === 'number'
|
||||||
|
? item.galleryCarouselPrevY
|
||||||
|
: undefined,
|
||||||
|
galleryCarouselNextX:
|
||||||
|
typeof item.galleryCarouselNextX === 'number'
|
||||||
|
? item.galleryCarouselNextX
|
||||||
|
: undefined,
|
||||||
|
galleryCarouselNextY:
|
||||||
|
typeof item.galleryCarouselNextY === 'number'
|
||||||
|
? item.galleryCarouselNextY
|
||||||
|
: undefined,
|
||||||
|
galleryCarouselBackX:
|
||||||
|
typeof item.galleryCarouselBackX === 'number'
|
||||||
|
? item.galleryCarouselBackX
|
||||||
|
: undefined,
|
||||||
|
galleryCarouselBackY:
|
||||||
|
typeof item.galleryCarouselBackY === 'number'
|
||||||
|
? item.galleryCarouselBackY
|
||||||
|
: undefined,
|
||||||
tooltipTitle:
|
tooltipTitle:
|
||||||
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
||||||
tooltipText:
|
tooltipText:
|
||||||
@ -1072,6 +1137,38 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectElementForEdit(element.id);
|
selectElementForEdit(element.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler for gallery card clicks
|
||||||
|
const handleGalleryCardClick = useCallback(
|
||||||
|
(element: CanvasElement, cardIndex: number) => {
|
||||||
|
if (element.galleryCards && element.galleryCards.length > 0) {
|
||||||
|
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler for gallery carousel button position changes (constructor only)
|
||||||
|
const handleCarouselButtonPositionChange = useCallback(
|
||||||
|
(button: 'prev' | 'next' | 'back', x: number, y: number) => {
|
||||||
|
if (!activeGalleryCarousel) return;
|
||||||
|
|
||||||
|
const positionPatch =
|
||||||
|
button === 'prev'
|
||||||
|
? { galleryCarouselPrevX: x, galleryCarouselPrevY: y }
|
||||||
|
: button === 'next'
|
||||||
|
? { galleryCarouselNextX: x, galleryCarouselNextY: y }
|
||||||
|
: { galleryCarouselBackX: x, galleryCarouselBackY: y };
|
||||||
|
|
||||||
|
updateSelectedElement(positionPatch);
|
||||||
|
|
||||||
|
// Update the active carousel element to reflect the new positions
|
||||||
|
setActiveGalleryCarousel((prev) =>
|
||||||
|
prev ? { ...prev, element: { ...prev.element, ...positionPatch } } : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[activeGalleryCarousel, updateSelectedElement],
|
||||||
|
);
|
||||||
|
|
||||||
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
const isElementVisibleOnCanvas = (element: CanvasElement) =>
|
||||||
isElementVisibleAtTime(
|
isElementVisibleAtTime(
|
||||||
canvasElapsedSec,
|
canvasElapsedSec,
|
||||||
@ -1113,13 +1210,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canvasBackgroundStyle: React.CSSProperties = {};
|
const canvasBackgroundStyle: React.CSSProperties = {};
|
||||||
// Prefer hook's blob URLs (instant display) but fall back to resolved URLs for manual changes
|
// Prefer hook's blob URLs, then try cached blob URLs, finally fall back to direct URLs
|
||||||
const backgroundImageSrc =
|
const backgroundImageSrc =
|
||||||
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
|
pageSwitch.currentBgImageUrl || resolveUrlWithBlob(backgroundImageUrl);
|
||||||
const backgroundVideoSrc =
|
const backgroundVideoSrc =
|
||||||
pageSwitch.currentBgVideoUrl || resolveAssetPlaybackUrl(backgroundVideoUrl);
|
pageSwitch.currentBgVideoUrl || resolveUrlWithBlob(backgroundVideoUrl);
|
||||||
const backgroundAudioSrc =
|
const backgroundAudioSrc =
|
||||||
pageSwitch.currentBgAudioUrl || resolveAssetPlaybackUrl(backgroundAudioUrl);
|
pageSwitch.currentBgAudioUrl || resolveUrlWithBlob(backgroundAudioUrl);
|
||||||
|
|
||||||
const hasEditorSelection =
|
const hasEditorSelection =
|
||||||
isConstructorEditMode &&
|
isConstructorEditMode &&
|
||||||
@ -1253,6 +1350,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
onClick={() => onCanvasElementClick(element)}
|
onClick={() => onCanvasElementClick(element)}
|
||||||
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
||||||
resolveUrl={resolveUrlWithBlob}
|
resolveUrl={resolveUrlWithBlob}
|
||||||
|
onGalleryCardClick={(cardIndex) =>
|
||||||
|
handleGalleryCardClick(element, cardIndex)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1312,6 +1412,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
activePageId={activePageId}
|
activePageId={activePageId}
|
||||||
onPreviewTransition={openTransitionPreview}
|
onPreviewTransition={openTransitionPreview}
|
||||||
galleryCards={galleryCards}
|
galleryCards={galleryCards}
|
||||||
|
galleryInfoSpans={galleryInfoSpans}
|
||||||
carouselSlides={carouselSlides}
|
carouselSlides={carouselSlides}
|
||||||
normalizeNavigationType={normalizeNavigationType}
|
normalizeNavigationType={normalizeNavigationType}
|
||||||
getDuration={getDuration}
|
getDuration={getDuration}
|
||||||
@ -1350,6 +1451,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
isBuffering={isReverseBuffering}
|
isBuffering={isReverseBuffering}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Gallery Carousel Overlay */}
|
||||||
|
{activeGalleryCarousel && (
|
||||||
|
<GalleryCarouselOverlay
|
||||||
|
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||||
|
initialIndex={activeGalleryCarousel.initialIndex}
|
||||||
|
onClose={() => setActiveGalleryCarousel(null)}
|
||||||
|
resolveUrl={resolveUrlWithBlob}
|
||||||
|
prevIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
|
||||||
|
}
|
||||||
|
nextIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselNextIconUrl
|
||||||
|
}
|
||||||
|
backIconUrl={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselBackIconUrl
|
||||||
|
}
|
||||||
|
backLabel={
|
||||||
|
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
|
||||||
|
}
|
||||||
|
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
|
||||||
|
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
|
||||||
|
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
|
||||||
|
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
|
||||||
|
backX={activeGalleryCarousel.element.galleryCarouselBackX}
|
||||||
|
backY={activeGalleryCarousel.element.galleryCarouselBackY}
|
||||||
|
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
|
||||||
|
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight}
|
||||||
|
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
|
||||||
|
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight}
|
||||||
|
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
|
||||||
|
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight}
|
||||||
|
isEditMode={isConstructorEditMode}
|
||||||
|
onButtonPositionChange={handleCarouselButtonPositionChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.menu-action-btn {
|
.menu-action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -314,6 +314,8 @@ const ElementTypeDefaultDetailsPage = () => {
|
|||||||
iconUrl={form.state.iconUrl}
|
iconUrl={form.state.iconUrl}
|
||||||
tooltipTitle={form.state.tooltipTitle}
|
tooltipTitle={form.state.tooltipTitle}
|
||||||
tooltipText={form.state.tooltipText}
|
tooltipText={form.state.tooltipText}
|
||||||
|
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
|
||||||
|
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
context='global'
|
context='global'
|
||||||
/>
|
/>
|
||||||
@ -349,9 +351,12 @@ const ElementTypeDefaultDetailsPage = () => {
|
|||||||
{form.isGalleryType && (
|
{form.isGalleryType && (
|
||||||
<GallerySettingsSection
|
<GallerySettingsSection
|
||||||
galleryCards={form.state.galleryCards}
|
galleryCards={form.state.galleryCards}
|
||||||
|
galleryTitleFontFamily={form.state.galleryTitleFontFamily}
|
||||||
|
galleryCardFontFamily={form.state.galleryCardFontFamily}
|
||||||
onAddCard={form.addGalleryCard}
|
onAddCard={form.addGalleryCard}
|
||||||
onRemoveCard={form.removeGalleryCard}
|
onRemoveCard={form.removeGalleryCard}
|
||||||
onUpdateCard={form.updateGalleryCard}
|
onUpdateCard={form.updateGalleryCard}
|
||||||
|
onChange={handleTypeChange}
|
||||||
context='global'
|
context='global'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -360,6 +365,7 @@ const ElementTypeDefaultDetailsPage = () => {
|
|||||||
<CarouselSettingsSection
|
<CarouselSettingsSection
|
||||||
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
||||||
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
||||||
|
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
|
||||||
carouselSlides={form.state.carouselSlides}
|
carouselSlides={form.state.carouselSlides}
|
||||||
onAddSlide={form.addCarouselSlide}
|
onAddSlide={form.addCarouselSlide}
|
||||||
onRemoveSlide={form.removeCarouselSlide}
|
onRemoveSlide={form.removeCarouselSlide}
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
|||||||
@ -501,6 +501,8 @@ const ProjectElementDefaultDetailsPage = () => {
|
|||||||
iconUrl={form.state.iconUrl}
|
iconUrl={form.state.iconUrl}
|
||||||
tooltipTitle={form.state.tooltipTitle}
|
tooltipTitle={form.state.tooltipTitle}
|
||||||
tooltipText={form.state.tooltipText}
|
tooltipText={form.state.tooltipText}
|
||||||
|
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
|
||||||
|
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
context='project'
|
context='project'
|
||||||
/>
|
/>
|
||||||
@ -534,9 +536,12 @@ const ProjectElementDefaultDetailsPage = () => {
|
|||||||
{form.isGalleryType && (
|
{form.isGalleryType && (
|
||||||
<GallerySettingsSection
|
<GallerySettingsSection
|
||||||
galleryCards={form.state.galleryCards}
|
galleryCards={form.state.galleryCards}
|
||||||
|
galleryTitleFontFamily={form.state.galleryTitleFontFamily}
|
||||||
|
galleryCardFontFamily={form.state.galleryCardFontFamily}
|
||||||
onAddCard={form.addGalleryCard}
|
onAddCard={form.addGalleryCard}
|
||||||
onRemoveCard={form.removeGalleryCard}
|
onRemoveCard={form.removeGalleryCard}
|
||||||
onUpdateCard={form.updateGalleryCard}
|
onUpdateCard={form.updateGalleryCard}
|
||||||
|
onChange={handleTypeChange}
|
||||||
context='project'
|
context='project'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -545,6 +550,7 @@ const ProjectElementDefaultDetailsPage = () => {
|
|||||||
<CarouselSettingsSection
|
<CarouselSettingsSection
|
||||||
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
||||||
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
||||||
|
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
|
||||||
carouselSlides={form.state.carouselSlides}
|
carouselSlides={form.state.carouselSlides}
|
||||||
onAddSlide={form.addCarouselSlide}
|
onAddSlide={form.addCarouselSlide}
|
||||||
onRemoveSlide={form.removeCarouselSlide}
|
onRemoveSlide={form.removeCarouselSlide}
|
||||||
|
|||||||
@ -20,13 +20,13 @@ import FormField from '../../components/FormField';
|
|||||||
import BaseDivider from '../../components/BaseDivider';
|
import BaseDivider from '../../components/BaseDivider';
|
||||||
import BaseButtons from '../../components/BaseButtons';
|
import BaseButtons from '../../components/BaseButtons';
|
||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { deleteItem, update, fetch } from '../../stores/projects/projectsSlice';
|
import { deleteItem, update, fetch } from '../../stores/projects/projectsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import type { Project } from '../../types/entities';
|
import type { Project } from '../../types/entities';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
name: '',
|
name: '',
|
||||||
@ -41,6 +41,7 @@ const initVals = {
|
|||||||
themeTextColor: '',
|
themeTextColor: '',
|
||||||
// Custom CSS fields (stored as JSON in custom_css_json)
|
// Custom CSS fields (stored as JSON in custom_css_json)
|
||||||
customFontFamily: '',
|
customFontFamily: '',
|
||||||
|
customFontStretch: '',
|
||||||
cdn_base_url: '',
|
cdn_base_url: '',
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
deleted_at_time: new Date(),
|
deleted_at_time: new Date(),
|
||||||
@ -72,14 +73,15 @@ const parseThemeConfig = (
|
|||||||
*/
|
*/
|
||||||
const parseCustomCss = (
|
const parseCustomCss = (
|
||||||
json: string | Record<string, unknown> | null | undefined,
|
json: string | Record<string, unknown> | null | undefined,
|
||||||
): { fontFamily: string } => {
|
): { fontFamily: string; fontStretch: string } => {
|
||||||
const defaults = { fontFamily: '' };
|
const defaults = { fontFamily: '', fontStretch: '' };
|
||||||
if (!json) return defaults;
|
if (!json) return defaults;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
|
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
|
||||||
return {
|
return {
|
||||||
fontFamily: String(parsed?.fontFamily || ''),
|
fontFamily: String(parsed?.fontFamily || ''),
|
||||||
|
fontStretch: String(parsed?.fontStretch || ''),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
@ -93,7 +95,7 @@ const buildThemeConfigJson = (values: {
|
|||||||
themePrimaryColor: string;
|
themePrimaryColor: string;
|
||||||
themeBackgroundColor: string;
|
themeBackgroundColor: string;
|
||||||
themeTextColor: string;
|
themeTextColor: string;
|
||||||
}): string | null => {
|
}): Record<string, string> | null => {
|
||||||
const config: Record<string, string> = {};
|
const config: Record<string, string> = {};
|
||||||
|
|
||||||
if (values.themePrimaryColor.trim()) {
|
if (values.themePrimaryColor.trim()) {
|
||||||
@ -106,7 +108,7 @@ const buildThemeConfigJson = (values: {
|
|||||||
config.textColor = values.themeTextColor.trim();
|
config.textColor = values.themeTextColor.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(config).length > 0 ? JSON.stringify(config) : null;
|
return Object.keys(config).length > 0 ? config : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,14 +116,18 @@ const buildThemeConfigJson = (values: {
|
|||||||
*/
|
*/
|
||||||
const buildCustomCssJson = (values: {
|
const buildCustomCssJson = (values: {
|
||||||
customFontFamily: string;
|
customFontFamily: string;
|
||||||
}): string | null => {
|
customFontStretch: string;
|
||||||
|
}): Record<string, string> | null => {
|
||||||
const config: Record<string, string> = {};
|
const config: Record<string, string> = {};
|
||||||
|
|
||||||
if (values.customFontFamily.trim()) {
|
if (values.customFontFamily.trim()) {
|
||||||
config.fontFamily = values.customFontFamily.trim();
|
config.fontFamily = values.customFontFamily.trim();
|
||||||
}
|
}
|
||||||
|
if (values.customFontStretch.trim()) {
|
||||||
|
config.fontStretch = values.customFontStretch.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(config).length > 0 ? JSON.stringify(config) : null;
|
return Object.keys(config).length > 0 ? config : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditProjectsPage = () => {
|
const EditProjectsPage = () => {
|
||||||
@ -222,6 +228,7 @@ const EditProjectsPage = () => {
|
|||||||
themeBackgroundColor: themeConfig.backgroundColor,
|
themeBackgroundColor: themeConfig.backgroundColor,
|
||||||
themeTextColor: themeConfig.textColor,
|
themeTextColor: themeConfig.textColor,
|
||||||
customFontFamily: customCss.fontFamily,
|
customFontFamily: customCss.fontFamily,
|
||||||
|
customFontStretch: customCss.fontStretch,
|
||||||
cdn_base_url: String(projectData.cdn_base_url || ''),
|
cdn_base_url: String(projectData.cdn_base_url || ''),
|
||||||
is_deleted: Boolean(projectData.is_deleted),
|
is_deleted: Boolean(projectData.is_deleted),
|
||||||
deleted_at_time: projectData.deleted_at_time
|
deleted_at_time: projectData.deleted_at_time
|
||||||
@ -241,6 +248,7 @@ const EditProjectsPage = () => {
|
|||||||
|
|
||||||
const custom_css_json = buildCustomCssJson({
|
const custom_css_json = buildCustomCssJson({
|
||||||
customFontFamily: data.customFontFamily,
|
customFontFamily: data.customFontFamily,
|
||||||
|
customFontStretch: data.customFontStretch,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prepare data for API (exclude expanded fields, include JSON)
|
// Prepare data for API (exclude expanded fields, include JSON)
|
||||||
@ -251,8 +259,8 @@ const EditProjectsPage = () => {
|
|||||||
logo_url: data.logo_url,
|
logo_url: data.logo_url,
|
||||||
favicon_url: data.favicon_url,
|
favicon_url: data.favicon_url,
|
||||||
og_image_url: data.og_image_url,
|
og_image_url: data.og_image_url,
|
||||||
theme_config_json: theme_config_json as string | undefined,
|
theme_config_json: theme_config_json,
|
||||||
custom_css_json: custom_css_json as string | undefined,
|
custom_css_json: custom_css_json,
|
||||||
cdn_base_url: data.cdn_base_url,
|
cdn_base_url: data.cdn_base_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -308,7 +316,9 @@ const EditProjectsPage = () => {
|
|||||||
<Field
|
<Field
|
||||||
name='description'
|
name='description'
|
||||||
id='description'
|
id='description'
|
||||||
component={RichTextField}
|
as='textarea'
|
||||||
|
rows={4}
|
||||||
|
placeholder='Project description'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@ -427,10 +437,34 @@ const EditProjectsPage = () => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label='Custom Font Family'>
|
<FormField label='Custom Font Family'>
|
||||||
<Field
|
<Field name='customFontFamily'>
|
||||||
name='customFontFamily'
|
{({ field, form }: { field: { value: string }; form: { setFieldValue: (name: string, value: string) => void; values: typeof initVals } }) => (
|
||||||
placeholder='e.g. Montserrat, sans-serif'
|
<select
|
||||||
/>
|
className='w-full rounded border border-gray-300 px-3 py-2'
|
||||||
|
value={getFontKeyFromValues(form.values.customFontFamily, form.values.customFontStretch)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const fontKey = e.target.value;
|
||||||
|
if (!fontKey) {
|
||||||
|
form.setFieldValue('customFontFamily', '');
|
||||||
|
form.setFieldValue('customFontStretch', '');
|
||||||
|
} else {
|
||||||
|
const font = getFontByKey(fontKey);
|
||||||
|
if (font) {
|
||||||
|
form.setFieldValue('customFontFamily', font.fontFamily);
|
||||||
|
form.setFieldValue('customFontStretch', font.fontStretch || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value=''>Not set</option>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<option key={font.key} value={font.key}>
|
||||||
|
{font.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label='CDN Base URL'>
|
<FormField label='CDN Base URL'>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import BaseDivider from '../../components/BaseDivider';
|
|||||||
import BaseButtons from '../../components/BaseButtons';
|
import BaseButtons from '../../components/BaseButtons';
|
||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
import { RichTextField } from '../../components/RichTextField';
|
|
||||||
|
|
||||||
import { create } from '../../stores/projects/projectsSlice';
|
import { create } from '../../stores/projects/projectsSlice';
|
||||||
import { useAppDispatch } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
@ -80,7 +79,9 @@ const ProjectsNew = () => {
|
|||||||
<Field
|
<Field
|
||||||
name='description'
|
name='description'
|
||||||
id='description'
|
id='description'
|
||||||
component={RichTextField}
|
as='textarea'
|
||||||
|
rows={4}
|
||||||
|
placeholder='Project description'
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,14 @@ export interface GalleryCard {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery info span (brief note badge)
|
||||||
|
*/
|
||||||
|
export interface GalleryInfoSpan {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carousel slide item
|
* Carousel slide item
|
||||||
*/
|
*/
|
||||||
@ -84,11 +92,21 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
galleryCards?: GalleryCard[];
|
galleryCards?: GalleryCard[];
|
||||||
|
// Gallery header settings
|
||||||
|
galleryHeaderImageUrl?: string;
|
||||||
|
galleryTitle?: string;
|
||||||
|
galleryInfoSpans?: GalleryInfoSpan[];
|
||||||
|
galleryColumns?: number;
|
||||||
|
galleryTitleFontFamily?: string;
|
||||||
|
galleryCardFontFamily?: string;
|
||||||
carouselSlides?: CarouselSlide[];
|
carouselSlides?: CarouselSlide[];
|
||||||
|
carouselCaptionFontFamily?: string;
|
||||||
carouselPrevIconUrl?: string;
|
carouselPrevIconUrl?: string;
|
||||||
carouselNextIconUrl?: string;
|
carouselNextIconUrl?: string;
|
||||||
tooltipTitle?: string;
|
tooltipTitle?: string;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
|
tooltipTitleFontFamily?: string;
|
||||||
|
tooltipTextFontFamily?: string;
|
||||||
descriptionTitle?: string;
|
descriptionTitle?: string;
|
||||||
descriptionText?: string;
|
descriptionText?: string;
|
||||||
descriptionTitleFontSize?: string;
|
descriptionTitleFontSize?: string;
|
||||||
@ -109,6 +127,27 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||||
reverseVideoUrl?: string;
|
reverseVideoUrl?: string;
|
||||||
transitionDurationSec?: number;
|
transitionDurationSec?: number;
|
||||||
|
// Gallery Carousel Settings
|
||||||
|
galleryCarouselPrevIconUrl?: string;
|
||||||
|
galleryCarouselNextIconUrl?: string;
|
||||||
|
galleryCarouselBackIconUrl?: string;
|
||||||
|
galleryCarouselBackLabel?: string;
|
||||||
|
// Prev button position (percentage)
|
||||||
|
galleryCarouselPrevX?: number;
|
||||||
|
galleryCarouselPrevY?: number;
|
||||||
|
// Next button position (percentage)
|
||||||
|
galleryCarouselNextX?: number;
|
||||||
|
galleryCarouselNextY?: number;
|
||||||
|
// Back button position (percentage)
|
||||||
|
galleryCarouselBackX?: number;
|
||||||
|
galleryCarouselBackY?: number;
|
||||||
|
// Button dimensions (CSS values like '48px', '3rem')
|
||||||
|
galleryCarouselPrevWidth?: string;
|
||||||
|
galleryCarouselPrevHeight?: string;
|
||||||
|
galleryCarouselNextWidth?: string;
|
||||||
|
galleryCarouselNextHeight?: string;
|
||||||
|
galleryCarouselBackWidth?: string;
|
||||||
|
galleryCarouselBackHeight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -359,6 +398,11 @@ export interface EditorCollectionOpsProps {
|
|||||||
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
remove: (cardId: string) => void;
|
remove: (cardId: string) => void;
|
||||||
};
|
};
|
||||||
|
galleryInfoSpans: {
|
||||||
|
add: () => void;
|
||||||
|
update: (spanId: string, text: string) => void;
|
||||||
|
remove: (spanId: string) => void;
|
||||||
|
};
|
||||||
carouselSlides: {
|
carouselSlides: {
|
||||||
add: () => void;
|
add: () => void;
|
||||||
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
|
|||||||
@ -13,6 +13,10 @@ module.exports = {
|
|||||||
gray: 'gray'
|
gray: 'gray'
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
'instrument': ['"Instrument Sans Variable"', 'sans-serif'],
|
||||||
|
'instrument-condensed': ['"Instrument Sans Variable"', 'sans-serif'],
|
||||||
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
'-1': '-1'
|
'-1': '-1'
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user