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 || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||
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: {
|
||||
saltRounds: 12,
|
||||
|
||||
@ -2,6 +2,9 @@ const express = require('express');
|
||||
const passport = require('passport');
|
||||
const bodyParser = require('body-parser');
|
||||
const services = require('../services/file/');
|
||||
const { isValidPath, createErrorResponse } = require('../services/file');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 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
|
||||
router.post('/presign', jsonParser, async (req, res) => {
|
||||
const log = req.log || logger;
|
||||
const { urls } = req.body || {};
|
||||
|
||||
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) {
|
||||
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(
|
||||
(url) => typeof url !== 'string' || !url.trim(),
|
||||
);
|
||||
if (invalidUrls.length > 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'All URLs must be non-empty strings' });
|
||||
return res.status(400).json(createErrorResponse('All URLs must be non-empty strings', 'INVALID_URL_FORMAT'));
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const presignedUrls = await services.generatePresignedUrls(urls);
|
||||
res.json({ presignedUrls });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate presigned URLs', error);
|
||||
res.status(500).json({ error: 'Failed to generate presigned URLs' });
|
||||
log.error({ err: error, urlCount: urls.length }, '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 { 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',
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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');
|
||||
@ -11,6 +17,7 @@ const { pipeline } = require('stream/promises');
|
||||
const { format } = require('util');
|
||||
|
||||
const config = require('../config');
|
||||
const { logger } = require('../utils/logger');
|
||||
const S3StorageProvider = require('./file/S3StorageProvider');
|
||||
const LocalStorageProvider = require('./file/LocalStorageProvider');
|
||||
const UploadSessionManager = require('./file/UploadSessionManager');
|
||||
@ -52,7 +59,22 @@ const getS3Provider = () => {
|
||||
accessKeyId: config.s3.accessKeyId,
|
||||
secretAccessKey: config.s3.secretAccessKey,
|
||||
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;
|
||||
};
|
||||
@ -91,21 +113,100 @@ const getUploadSessionManager = () => {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
const uploadFile = async (folder, req, res) => {
|
||||
const provider = getFileStorageProvider();
|
||||
const log = req.log || logger;
|
||||
|
||||
try {
|
||||
const processFile = require('../middlewares/upload');
|
||||
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;
|
||||
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}`;
|
||||
let publicUrl = '';
|
||||
@ -131,29 +232,54 @@ const uploadFile = async (folder, req, res) => {
|
||||
publicUrl = `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`;
|
||||
}
|
||||
|
||||
log.info({ provider, privateUrl }, 'File uploaded successfully');
|
||||
|
||||
return res.status(200).send({
|
||||
message: `Uploaded the file successfully: ${privateUrl}`,
|
||||
url: publicUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error', error);
|
||||
return res.status(500).send({ message: `Could not upload the file. ${error.message || error}` });
|
||||
log.error({ err: error, provider }, 'Failed to upload file');
|
||||
return res.status(500).send(createErrorResponse(`Could not upload the file. ${error.message || error}`, 'UPLOAD_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (req, res) => {
|
||||
const provider = getFileStorageProvider();
|
||||
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');
|
||||
|
||||
// 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 {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (provider === 's3') {
|
||||
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.contentLength) res.setHeader('Content-Length', result.contentLength);
|
||||
|
||||
if (typeof result.body.pipe === 'function') {
|
||||
result.body.pipe(res);
|
||||
@ -163,6 +289,8 @@ const downloadFile = async (req, res) => {
|
||||
} else {
|
||||
res.send(result.body);
|
||||
}
|
||||
|
||||
log.debug({ provider, privateUrl, duration: Date.now() - startTime }, 'File downloaded');
|
||||
} else if (provider === 'gcloud') {
|
||||
const { bucket, hash } = getGCloudBucket();
|
||||
const file = bucket.file(`${hash}/${privateUrl}`);
|
||||
@ -170,20 +298,50 @@ const downloadFile = async (req, res) => {
|
||||
if (exists) {
|
||||
file.createReadStream().pipe(res);
|
||||
} else {
|
||||
res.status(404).send({ message: 'File not found' });
|
||||
res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND'));
|
||||
}
|
||||
} else {
|
||||
res.download(path.join(config.uploadDir, privateUrl));
|
||||
}
|
||||
} catch (error) {
|
||||
const statusCode = error?.name === 'NoSuchKey' ? 404 : 500;
|
||||
return res.status(statusCode).send({ message: `Could not download the file. ${error.message || error}` });
|
||||
// Don't log abort errors as they're expected when client disconnects
|
||||
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();
|
||||
|
||||
try {
|
||||
@ -199,8 +357,17 @@ const deleteFile = async (privateUrl) => {
|
||||
const local = getLocalProvider();
|
||||
await local.delete(privateUrl);
|
||||
}
|
||||
|
||||
logger.debug({ provider, privateUrl }, 'File deleted successfully');
|
||||
return { success: true };
|
||||
} 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 log = req.log || logger;
|
||||
|
||||
try {
|
||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||
|
||||
@ -231,9 +400,9 @@ const initUploadSession = async (req, res) => {
|
||||
const size = Number(req.body?.size);
|
||||
const contentType = String(req.body?.contentType || '').trim();
|
||||
|
||||
if (!folder || !filename) return res.status(400).send({ message: 'Invalid folder or filename' });
|
||||
if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send({ message: 'Invalid totalChunks' });
|
||||
if (!Number.isFinite(size) || size < 0) return res.status(400).send({ message: 'Invalid file size' });
|
||||
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(createErrorResponse('Invalid totalChunks', 'INVALID_INPUT'));
|
||||
if (!Number.isFinite(size) || size < 0) return res.status(400).send(createErrorResponse('Invalid file size', 'INVALID_INPUT'));
|
||||
|
||||
const sessionId = sessionManager.createSession({
|
||||
userId: req.currentUser.id,
|
||||
@ -244,14 +413,16 @@ const initUploadSession = async (req, res) => {
|
||||
contentType,
|
||||
});
|
||||
|
||||
log.info({ sessionId, folder, filename, totalChunks, size }, 'Upload session initialized');
|
||||
|
||||
return res.status(200).send({
|
||||
sessionId,
|
||||
uploadedChunks: [],
|
||||
totalChunks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize upload session', error);
|
||||
return res.status(500).send({ message: 'Failed to initialize upload session' });
|
||||
log.error({ err: error }, '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 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);
|
||||
|
||||
return res.status(200).send({
|
||||
@ -273,12 +444,15 @@ const getUploadSession = async (req, res) => {
|
||||
status: sessionManager.isComplete(sessionId) ? 'complete' : 'uploading',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get upload session', error);
|
||||
return res.status(500).send({ message: 'Failed to get upload session' });
|
||||
const log = req.log || logger;
|
||||
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 log = req.log || logger;
|
||||
|
||||
try {
|
||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||
|
||||
@ -286,15 +460,15 @@ const uploadChunk = async (req, res) => {
|
||||
const chunkIndex = Number(req.params.chunkIndex);
|
||||
|
||||
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 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 (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
|
||||
const chunks = [];
|
||||
@ -311,12 +485,14 @@ const uploadChunk = async (req, res) => {
|
||||
totalChunks: session.totalChunks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to upload chunk', error);
|
||||
return res.status(500).send({ message: 'Failed to upload chunk' });
|
||||
log.error({ err: error }, 'Failed to upload chunk');
|
||||
return res.status(500).send(createErrorResponse('Failed to upload chunk', 'CHUNK_UPLOAD_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeUploadSession = async (req, res) => {
|
||||
const log = req.log || logger;
|
||||
|
||||
try {
|
||||
if (!req.currentUser?.id) return res.sendStatus(403);
|
||||
|
||||
@ -324,13 +500,13 @@ const finalizeUploadSession = async (req, res) => {
|
||||
const sessionManager = getUploadSessionManager();
|
||||
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);
|
||||
|
||||
// Verify all chunks exist
|
||||
for (let i = 0; i < session.totalChunks; 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
|
||||
sessionManager.removeSession(sessionId);
|
||||
|
||||
log.info({ sessionId, provider, privateUrl }, 'Upload session finalized');
|
||||
|
||||
return res.status(200).send({
|
||||
message: `Uploaded the file successfully: ${privateUrl}`,
|
||||
privateUrl,
|
||||
url: publicUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to finalize upload session', error);
|
||||
return res.status(500).send({ message: 'Failed to finalize upload session' });
|
||||
log.error({ err: error }, '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
|
||||
// ============================================================================
|
||||
|
||||
const PRESIGN_EXPIRY_SECONDS = 3600;
|
||||
const getPresignExpirySeconds = () => config.s3.presignExpirySeconds || 3600;
|
||||
|
||||
const generatePresignedUrls = async (urls) => {
|
||||
const provider = getFileStorageProvider();
|
||||
@ -396,10 +574,11 @@ const generatePresignedUrls = async (urls) => {
|
||||
|
||||
const s3 = getS3Provider();
|
||||
const presignedUrls = {};
|
||||
const expirySeconds = getPresignExpirySeconds();
|
||||
|
||||
await Promise.all(
|
||||
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,
|
||||
// Presigned URLs
|
||||
generatePresignedUrls,
|
||||
// Utilities (for testing/routes)
|
||||
isValidPath,
|
||||
createErrorResponse,
|
||||
getS3ErrorStatusCode,
|
||||
};
|
||||
|
||||
@ -3,8 +3,15 @@
|
||||
*
|
||||
* AWS S3 storage implementation following the Strategy Pattern.
|
||||
* 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 {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
@ -15,8 +22,49 @@ const {
|
||||
HeadObjectCommand,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||
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 {
|
||||
/**
|
||||
* @param {Object} options
|
||||
@ -25,12 +73,40 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
* @param {string} [options.accessKeyId] - AWS access key ID
|
||||
* @param {string} [options.secretAccessKey] - AWS secret access key
|
||||
* @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 = {}) {
|
||||
super();
|
||||
this.bucket = options.bucket;
|
||||
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({
|
||||
region: options.region || 'us-east-1',
|
||||
credentials:
|
||||
@ -40,13 +116,67 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
secretAccessKey: options.secretAccessKey,
|
||||
}
|
||||
: 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() {
|
||||
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
|
||||
*/
|
||||
@ -61,10 +191,12 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
* @param {string} key - Storage key/path
|
||||
* @param {Buffer|ReadableStream} data - File data
|
||||
* @param {Object} options - Upload options
|
||||
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||
* @returns {Promise<{ key: string, url?: string }>}
|
||||
*/
|
||||
async upload(key, data, options = {}) {
|
||||
const fullKey = this.buildKey(key);
|
||||
const { signal, ...uploadOptions } = options;
|
||||
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
@ -72,15 +204,16 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
Body: data,
|
||||
};
|
||||
|
||||
if (options.contentType) {
|
||||
params.ContentType = options.contentType;
|
||||
if (uploadOptions.contentType) {
|
||||
params.ContentType = uploadOptions.contentType;
|
||||
}
|
||||
|
||||
if (options.metadata) {
|
||||
params.Metadata = options.metadata;
|
||||
if (uploadOptions.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 {
|
||||
key: fullKey,
|
||||
@ -91,51 +224,68 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
/**
|
||||
* Download a file from S3
|
||||
* @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 { signal } = options;
|
||||
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
const output = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fullKey,
|
||||
}),
|
||||
sendOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
body: output.Body,
|
||||
contentType: output.ContentType,
|
||||
contentLength: output.ContentLength,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from S3
|
||||
* @param {string} key - Storage key/path
|
||||
* @param {Object} [options] - Delete options
|
||||
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delete(key) {
|
||||
async delete(key, options = {}) {
|
||||
const fullKey = this.buildKey(key);
|
||||
const { signal } = options;
|
||||
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fullKey,
|
||||
}),
|
||||
sendOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files from S3
|
||||
* @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) {
|
||||
return;
|
||||
return { deleted: [], errors: [] };
|
||||
}
|
||||
|
||||
const { signal } = options;
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
const objects = keys.map((key) => ({ Key: this.buildKey(key) }));
|
||||
const deleted = [];
|
||||
const errors = [];
|
||||
|
||||
// S3 DeleteObjects supports max 1000 objects per request
|
||||
const chunks = [];
|
||||
@ -144,29 +294,49 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await this.client.send(
|
||||
const result = await this.client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
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
|
||||
* @param {string} key - Storage key/path
|
||||
* @param {Object} [options] - Options
|
||||
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(key) {
|
||||
async exists(key, options = {}) {
|
||||
const fullKey = this.buildKey(key);
|
||||
const { signal } = options;
|
||||
|
||||
try {
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
await this.client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fullKey,
|
||||
}),
|
||||
sendOptions,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -180,10 +350,14 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
/**
|
||||
* List files with a given prefix
|
||||
* @param {string} prefix - Key prefix
|
||||
* @param {Object} [options] - Options
|
||||
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||
* @returns {Promise<string[]>} Array of keys
|
||||
*/
|
||||
async list(prefix) {
|
||||
async list(prefix, options = {}) {
|
||||
const fullPrefix = this.buildKey(prefix);
|
||||
const { signal } = options;
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
const keys = [];
|
||||
let continuationToken = null;
|
||||
|
||||
@ -197,7 +371,10 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
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) {
|
||||
keys.push(...result.Contents.map((obj) => obj.Key));
|
||||
@ -251,6 +428,23 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
getPrefix() {
|
||||
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;
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource-variable/instrument-sans": "^5.2.8",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mui/material": "^6.3.0",
|
||||
@ -17,7 +18,6 @@
|
||||
"@reduxjs/toolkit": "^2.1.0",
|
||||
"@serwist/next": "^9.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
"apexcharts": "^5.0.0",
|
||||
"axios": "^1.8.4",
|
||||
"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,
|
||||
});
|
||||
|
||||
// Validate file type based on section's expected asset format
|
||||
const validationSchema = { assetType: section.assetFormat };
|
||||
|
||||
const remoteFile = await FileUploader.uploadChunked(
|
||||
`assets/${projectId}`,
|
||||
file,
|
||||
{},
|
||||
validationSchema,
|
||||
{
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
maxRetries: 3,
|
||||
|
||||
@ -18,6 +18,8 @@ interface CanvasElementProps {
|
||||
onMouseDown?: (event: React.MouseEvent) => void;
|
||||
/** Optional URL resolver for preloaded blob URLs */
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
/** Gallery card click handler */
|
||||
onGalleryCardClick?: (cardIndex: number) => void;
|
||||
}
|
||||
|
||||
const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
@ -28,6 +30,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
onClick,
|
||||
onMouseDown,
|
||||
resolveUrl,
|
||||
onGalleryCardClick,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@ -53,6 +56,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
isSelected={isSelected}
|
||||
isEditMode={isEditMode}
|
||||
isDisabled={isDisabled}
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
MediaSettingsSectionCompact,
|
||||
GallerySettingsSectionCompact,
|
||||
CarouselSettingsSectionCompact,
|
||||
GalleryCarouselSettingsSectionCompact,
|
||||
extractNumericValue,
|
||||
} from '../ElementSettings';
|
||||
import BackgroundSettingsEditor from './BackgroundSettingsEditor';
|
||||
@ -37,6 +38,7 @@ import type {
|
||||
CanvasElement,
|
||||
CanvasElementType,
|
||||
GalleryCard,
|
||||
GalleryInfoSpan,
|
||||
CarouselSlide,
|
||||
} from '../../types/constructor';
|
||||
|
||||
@ -128,6 +130,11 @@ interface ElementEditorPanelProps {
|
||||
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||
remove: (cardId: string) => void;
|
||||
};
|
||||
galleryInfoSpans: {
|
||||
add: () => void;
|
||||
update: (spanId: string, text: string) => void;
|
||||
remove: (spanId: string) => void;
|
||||
};
|
||||
carouselSlides: {
|
||||
add: () => void;
|
||||
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||
@ -236,6 +243,7 @@ export function ElementEditorPanel({
|
||||
activePageId,
|
||||
onPreviewTransition,
|
||||
galleryCards,
|
||||
galleryInfoSpans,
|
||||
carouselSlides,
|
||||
normalizeNavigationType,
|
||||
getDuration,
|
||||
@ -412,6 +420,12 @@ export function ElementEditorPanel({
|
||||
iconUrl={selectedElement.iconUrl || ''}
|
||||
tooltipTitle={selectedElement.tooltipTitle || ''}
|
||||
tooltipText={selectedElement.tooltipText || ''}
|
||||
tooltipTitleFontFamily={
|
||||
selectedElement.tooltipTitleFontFamily || ''
|
||||
}
|
||||
tooltipTextFontFamily={
|
||||
selectedElement.tooltipTextFontFamily || ''
|
||||
}
|
||||
iconAssetOptions={iconAssetOptions}
|
||||
onChange={(prop, value) =>
|
||||
onUpdateElement({ [prop]: value })
|
||||
@ -479,13 +493,67 @@ export function ElementEditorPanel({
|
||||
|
||||
{selectedElement &&
|
||||
isGalleryElementType(selectedElement.type) && (
|
||||
<GallerySettingsSectionCompact
|
||||
galleryCards={selectedElement.galleryCards || []}
|
||||
imageAssetOptions={imageAssetOptions}
|
||||
onAddCard={galleryCards.add}
|
||||
onUpdateCard={galleryCards.update}
|
||||
onRemoveCard={galleryCards.remove}
|
||||
/>
|
||||
<>
|
||||
<GallerySettingsSectionCompact
|
||||
galleryHeaderImageUrl={
|
||||
selectedElement.galleryHeaderImageUrl || ''
|
||||
}
|
||||
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 &&
|
||||
@ -498,6 +566,9 @@ export function ElementEditorPanel({
|
||||
carouselNextIconUrl={
|
||||
selectedElement.carouselNextIconUrl || ''
|
||||
}
|
||||
carouselCaptionFontFamily={
|
||||
selectedElement.carouselCaptionFontFamily || ''
|
||||
}
|
||||
iconAssetOptions={iconAssetOptions}
|
||||
imageAssetOptions={imageAssetOptions}
|
||||
onUpdateElement={onUpdateElement}
|
||||
@ -540,6 +611,7 @@ export function ElementEditorPanel({
|
||||
backgroundColor: selectedElement.backgroundColor || '',
|
||||
color: selectedElement.color || '',
|
||||
fontFamily: selectedElement.fontFamily || '',
|
||||
fontStretch: selectedElement.fontStretch || '',
|
||||
}}
|
||||
onChange={(prop, value) =>
|
||||
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 FormField from '../FormField';
|
||||
import type { CarouselSettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
|
||||
carouselPrevIconUrl,
|
||||
carouselNextIconUrl,
|
||||
carouselCaptionFontFamily,
|
||||
carouselSlides,
|
||||
onAddSlide,
|
||||
onRemoveSlide,
|
||||
@ -86,6 +88,24 @@ const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
|
||||
)}
|
||||
</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'>
|
||||
<h3 className='text-sm font-semibold'>Carousel slides</h3>
|
||||
<BaseButton
|
||||
|
||||
@ -8,16 +8,19 @@
|
||||
import React from 'react';
|
||||
import type { CarouselSlide, AssetOption } from '../../types/constructor';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
interface CarouselSettingsSectionCompactProps {
|
||||
carouselSlides: CarouselSlide[];
|
||||
carouselPrevIconUrl: string;
|
||||
carouselNextIconUrl: string;
|
||||
carouselCaptionFontFamily: string;
|
||||
iconAssetOptions: AssetOption[];
|
||||
imageAssetOptions: AssetOption[];
|
||||
onUpdateElement: (patch: {
|
||||
carouselPrevIconUrl?: string;
|
||||
carouselNextIconUrl?: string;
|
||||
carouselCaptionFontFamily?: string;
|
||||
}) => void;
|
||||
onAddSlide: () => void;
|
||||
onUpdateSlide: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||
@ -30,6 +33,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
carouselSlides,
|
||||
carouselPrevIconUrl,
|
||||
carouselNextIconUrl,
|
||||
carouselCaptionFontFamily,
|
||||
iconAssetOptions,
|
||||
imageAssetOptions,
|
||||
onUpdateElement,
|
||||
@ -81,6 +85,24 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
</option>
|
||||
))}
|
||||
</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 className='flex items-center justify-between'>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import React from 'react';
|
||||
import FormField from '../FormField';
|
||||
import type { DescriptionSettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
||||
iconUrl,
|
||||
@ -85,22 +86,34 @@ const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label='Title font family'>
|
||||
<input
|
||||
<select
|
||||
value={descriptionTitleFontFamily}
|
||||
onChange={(event) =>
|
||||
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 label='Text font family'>
|
||||
<input
|
||||
<select
|
||||
value={descriptionTextFontFamily}
|
||||
onChange={(event) =>
|
||||
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 label='Title color'>
|
||||
<input
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import React from 'react';
|
||||
import type { AssetOption } from '../../types/constructor';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
interface DescriptionSettingsSectionCompactProps {
|
||||
iconUrl: string;
|
||||
@ -119,28 +120,40 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Title font family
|
||||
</label>
|
||||
<input
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={descriptionTitleFontFamily}
|
||||
onChange={(event) =>
|
||||
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>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Text font family
|
||||
</label>
|
||||
<input
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={descriptionTextFontFamily}
|
||||
onChange={(event) =>
|
||||
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>
|
||||
|
||||
@ -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 FormField from '../FormField';
|
||||
import type { GallerySettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
|
||||
galleryCards,
|
||||
galleryTitleFontFamily,
|
||||
galleryCardFontFamily,
|
||||
onAddCard,
|
||||
onRemoveCard,
|
||||
onUpdateCard,
|
||||
onChange,
|
||||
context,
|
||||
imageAssetOptions = [],
|
||||
}) => {
|
||||
@ -24,6 +28,41 @@ const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<h3 className='text-sm font-semibold'>Gallery cards</h3>
|
||||
<BaseButton
|
||||
|
||||
@ -2,16 +2,42 @@
|
||||
* GallerySettingsSectionCompact
|
||||
*
|
||||
* 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 type { GalleryCard, AssetOption } from '../../types/constructor';
|
||||
import type {
|
||||
GalleryCard,
|
||||
GalleryInfoSpan,
|
||||
AssetOption,
|
||||
} from '../../types/constructor';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
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;
|
||||
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;
|
||||
@ -20,14 +46,148 @@ interface GallerySettingsSectionCompactProps {
|
||||
const GallerySettingsSectionCompact: React.FC<
|
||||
GallerySettingsSectionCompactProps
|
||||
> = ({
|
||||
galleryHeaderImageUrl,
|
||||
galleryTitle,
|
||||
galleryInfoSpans,
|
||||
galleryColumns,
|
||||
galleryTitleFontFamily,
|
||||
galleryCardFontFamily,
|
||||
galleryCards,
|
||||
imageAssetOptions,
|
||||
onUpdateHeader,
|
||||
onAddInfoSpan,
|
||||
onUpdateInfoSpan,
|
||||
onRemoveInfoSpan,
|
||||
onAddCard,
|
||||
onUpdateCard,
|
||||
onRemoveCard,
|
||||
}) => {
|
||||
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'>
|
||||
<p className='text-[11px] font-semibold text-gray-600'>Gallery cards</p>
|
||||
<button
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import React from 'react';
|
||||
import FormField from '../FormField';
|
||||
import type { StyleSettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||
|
||||
const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
|
||||
values,
|
||||
@ -241,11 +242,29 @@ const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label='Font family'>
|
||||
<input
|
||||
value={values.fontFamily || ''}
|
||||
onChange={(event) => onChange('fontFamily', event.target.value)}
|
||||
placeholder='e.g. Montserrat, sans-serif'
|
||||
/>
|
||||
<select
|
||||
value={getFontKeyFromValues(values.fontFamily, values.fontStretch)}
|
||||
onChange={(event) => {
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import type { StyleSettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||
|
||||
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
values,
|
||||
@ -341,12 +342,30 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Font family
|
||||
</label>
|
||||
<input
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values.fontFamily || ''}
|
||||
onChange={(e) => onChange('fontFamily', e.target.value)}
|
||||
placeholder='Montserrat, sans-serif'
|
||||
/>
|
||||
value={getFontKeyFromValues(values.fontFamily, values.fontStretch)}
|
||||
onChange={(e) => {
|
||||
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>
|
||||
);
|
||||
|
||||
@ -7,11 +7,14 @@
|
||||
import React from 'react';
|
||||
import FormField from '../FormField';
|
||||
import type { TooltipSettingsSectionProps } from './types';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
|
||||
iconUrl,
|
||||
tooltipTitle,
|
||||
tooltipText,
|
||||
tooltipTitleFontFamily,
|
||||
tooltipTextFontFamily,
|
||||
onChange,
|
||||
context,
|
||||
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'
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,18 +8,29 @@
|
||||
import React from 'react';
|
||||
import type { AssetOption } from '../../types/constructor';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
|
||||
interface TooltipSettingsSectionCompactProps {
|
||||
iconUrl: string;
|
||||
tooltipTitle: string;
|
||||
tooltipText: string;
|
||||
tooltipTitleFontFamily: string;
|
||||
tooltipTextFontFamily: string;
|
||||
iconAssetOptions: AssetOption[];
|
||||
onChange: (prop: string, value: string) => void;
|
||||
}
|
||||
|
||||
const TooltipSettingsSectionCompact: React.FC<
|
||||
TooltipSettingsSectionCompactProps
|
||||
> = ({ iconUrl, tooltipTitle, tooltipText, iconAssetOptions, onChange }) => {
|
||||
> = ({
|
||||
iconUrl,
|
||||
tooltipTitle,
|
||||
tooltipText,
|
||||
tooltipTitleFontFamily,
|
||||
tooltipTextFontFamily,
|
||||
iconAssetOptions,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
@ -66,6 +77,46 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
onChange={(event) => onChange('tooltipText', event.target.value)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,6 +27,7 @@ export { default as GallerySettingsSection } from './GallerySettingsSection';
|
||||
export { default as GallerySettingsSectionCompact } from './GallerySettingsSectionCompact';
|
||||
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
|
||||
export { default as CarouselSettingsSectionCompact } from './CarouselSettingsSectionCompact';
|
||||
export { default as GalleryCarouselSettingsSectionCompact } from './GalleryCarouselSettingsSectionCompact';
|
||||
|
||||
// Hook
|
||||
export { useElementSettingsForm } from './useElementSettingsForm';
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
CanvasElement,
|
||||
CanvasElementType,
|
||||
GalleryCard,
|
||||
GalleryInfoSpan,
|
||||
CarouselSlide,
|
||||
AssetOption,
|
||||
} from '../../types/constructor';
|
||||
@ -103,6 +104,8 @@ export interface TooltipSettingsSectionProps {
|
||||
iconUrl: string;
|
||||
tooltipTitle: string;
|
||||
tooltipText: string;
|
||||
tooltipTitleFontFamily: string;
|
||||
tooltipTextFontFamily: string;
|
||||
onChange: (field: string, value: string) => void;
|
||||
context: ElementSettingsContext;
|
||||
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 {
|
||||
galleryCards: GalleryCard[];
|
||||
galleryTitleFontFamily: string;
|
||||
galleryCardFontFamily: string;
|
||||
onAddCard: () => void;
|
||||
onRemoveCard: (cardId: string) => void;
|
||||
onUpdateCard: (
|
||||
@ -154,16 +160,52 @@ export interface GallerySettingsSectionProps {
|
||||
field: keyof GalleryCard,
|
||||
value: string,
|
||||
) => void;
|
||||
onChange: (field: string, value: string) => void;
|
||||
context: ElementSettingsContext;
|
||||
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
|
||||
*/
|
||||
export interface CarouselSettingsSectionProps {
|
||||
carouselPrevIconUrl: string;
|
||||
carouselNextIconUrl: string;
|
||||
carouselCaptionFontFamily: string;
|
||||
carouselSlides: CarouselSlide[];
|
||||
onAddSlide: () => void;
|
||||
onRemoveSlide: (slideId: string) => void;
|
||||
@ -187,6 +229,25 @@ export interface ElementSettingsTabsProps {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -102,6 +102,8 @@ interface FormState {
|
||||
// Tooltip settings
|
||||
tooltipTitle: string;
|
||||
tooltipText: string;
|
||||
tooltipTitleFontFamily: string;
|
||||
tooltipTextFontFamily: string;
|
||||
|
||||
// Description settings
|
||||
descriptionTitle: string;
|
||||
@ -123,6 +125,11 @@ interface FormState {
|
||||
// Carousel settings
|
||||
carouselPrevIconUrl: string;
|
||||
carouselNextIconUrl: string;
|
||||
carouselCaptionFontFamily: string;
|
||||
|
||||
// Gallery settings
|
||||
galleryTitleFontFamily: string;
|
||||
galleryCardFontFamily: string;
|
||||
|
||||
// Complex arrays
|
||||
galleryCards: GalleryCard[];
|
||||
@ -188,6 +195,8 @@ const initialState: FormState = {
|
||||
reverseVideoUrl: '',
|
||||
tooltipTitle: '',
|
||||
tooltipText: '',
|
||||
tooltipTitleFontFamily: '',
|
||||
tooltipTextFontFamily: '',
|
||||
descriptionTitle: '',
|
||||
descriptionText: '',
|
||||
descriptionTitleFontSize: '',
|
||||
@ -203,6 +212,9 @@ const initialState: FormState = {
|
||||
mediaMuted: false,
|
||||
carouselPrevIconUrl: '',
|
||||
carouselNextIconUrl: '',
|
||||
carouselCaptionFontFamily: '',
|
||||
galleryTitleFontFamily: '',
|
||||
galleryCardFontFamily: '',
|
||||
galleryCards: [],
|
||||
carouselSlides: [],
|
||||
};
|
||||
@ -295,6 +307,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
|
||||
tooltipTitle: String(settings.tooltipTitle || ''),
|
||||
tooltipText: String(settings.tooltipText || ''),
|
||||
tooltipTitleFontFamily: String(settings.tooltipTitleFontFamily || ''),
|
||||
tooltipTextFontFamily: String(settings.tooltipTextFontFamily || ''),
|
||||
descriptionTitle: String(settings.descriptionTitle || ''),
|
||||
descriptionText: String(settings.descriptionText || ''),
|
||||
descriptionTitleFontSize: String(
|
||||
@ -318,6 +332,9 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
mediaMuted: Boolean(settings.mediaMuted),
|
||||
carouselPrevIconUrl: String(settings.carouselPrevIconUrl || ''),
|
||||
carouselNextIconUrl: String(settings.carouselNextIconUrl || ''),
|
||||
carouselCaptionFontFamily: String(settings.carouselCaptionFontFamily || ''),
|
||||
galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''),
|
||||
galleryCardFontFamily: String(settings.galleryCardFontFamily || ''),
|
||||
galleryCards: Array.isArray(settings.galleryCards)
|
||||
? settings.galleryCards.map(
|
||||
(card: Record<string, unknown>, index: number) => ({
|
||||
@ -635,6 +652,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
settings.iconUrl = state.iconUrl.trim();
|
||||
settings.tooltipTitle = state.tooltipTitle.trim();
|
||||
settings.tooltipText = state.tooltipText;
|
||||
settings.tooltipTitleFontFamily = state.tooltipTitleFontFamily.trim();
|
||||
settings.tooltipTextFontFamily = state.tooltipTextFontFamily.trim();
|
||||
}
|
||||
|
||||
// Description type settings
|
||||
@ -666,6 +685,8 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
title: card.title.trim() || `Card ${index + 1}`,
|
||||
description: card.description,
|
||||
}));
|
||||
settings.galleryTitleFontFamily = state.galleryTitleFontFamily.trim();
|
||||
settings.galleryCardFontFamily = state.galleryCardFontFamily.trim();
|
||||
}
|
||||
|
||||
// Carousel type settings
|
||||
@ -677,6 +698,7 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
}));
|
||||
settings.carouselPrevIconUrl = state.carouselPrevIconUrl.trim();
|
||||
settings.carouselNextIconUrl = state.carouselNextIconUrl.trim();
|
||||
settings.carouselCaptionFontFamily = state.carouselCaptionFontFamily.trim();
|
||||
}
|
||||
|
||||
// Media type settings
|
||||
|
||||
@ -14,7 +14,6 @@ import FormImagePicker from '../FormImagePicker';
|
||||
import { SelectField } from '../SelectField';
|
||||
import { SelectFieldMany } from '../SelectFieldMany';
|
||||
import { SwitchField } from '../SwitchField';
|
||||
import { RichTextField } from '../RichTextField';
|
||||
import type { FormFieldConfig } from '../../types/forms';
|
||||
|
||||
interface GenericFormFieldProps {
|
||||
@ -71,7 +70,12 @@ const GenericFormField: React.FC<GenericFormFieldProps> = ({
|
||||
case 'richtext':
|
||||
return (
|
||||
<FormField label={config.label}>
|
||||
<Field name={config.name} component={RichTextField} />
|
||||
<Field
|
||||
name={config.name}
|
||||
as='textarea'
|
||||
placeholder={config.placeholder || config.label}
|
||||
rows={6}
|
||||
/>
|
||||
</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;
|
||||
/** Optional URL resolver for preloaded blob URLs */
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
/** Gallery card click handler */
|
||||
onGalleryCardClick?: (cardIndex: number) => void;
|
||||
}
|
||||
|
||||
const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
element,
|
||||
onClick,
|
||||
resolveUrl,
|
||||
onGalleryCardClick,
|
||||
}) => {
|
||||
const xPercent = element.xPercent ?? 0;
|
||||
const yPercent = element.yPercent ?? 0;
|
||||
@ -93,7 +96,11 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
tabIndex={0}
|
||||
{...eventHandlers}
|
||||
>
|
||||
<UiElementRenderer element={element} resolveUrl={resolveUrl} />
|
||||
<UiElementRenderer
|
||||
element={element}
|
||||
resolveUrl={resolveUrl}
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ import BaseButton from './BaseButton';
|
||||
import CardBox from './CardBox';
|
||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import RuntimeElement from './RuntimeElement';
|
||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
@ -36,6 +37,26 @@ import {
|
||||
} from '../lib/navigationHelpers';
|
||||
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 {
|
||||
projectSlug: string;
|
||||
environment: 'stage' | 'production';
|
||||
@ -69,6 +90,10 @@ export default function RuntimePresentation({
|
||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||
useState(false);
|
||||
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
||||
element: any;
|
||||
initialIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||
@ -328,6 +353,16 @@ export default function RuntimePresentation({
|
||||
[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)
|
||||
const resolveUrlWithBlob = useCallback(
|
||||
(url: string | undefined): string => {
|
||||
@ -464,6 +499,9 @@ export default function RuntimePresentation({
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -485,19 +523,6 @@ export default function RuntimePresentation({
|
||||
/>
|
||||
</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 */}
|
||||
{/* 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 */}
|
||||
@ -521,6 +546,41 @@ export default function RuntimePresentation({
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
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;
|
||||
isEditMode?: boolean;
|
||||
isDisabled?: boolean;
|
||||
// Gallery carousel callback
|
||||
onGalleryCardClick?: (cardIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -57,6 +59,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
||||
isSelected = false,
|
||||
isEditMode = false,
|
||||
isDisabled = false,
|
||||
onGalleryCardClick,
|
||||
}) => {
|
||||
const { className, style } = useElementWrapperStyle({
|
||||
element,
|
||||
@ -73,7 +76,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
||||
return <NavigationElement {...commonProps} />;
|
||||
}
|
||||
if (isGalleryElementType(element.type)) {
|
||||
return <GalleryElement {...commonProps} />;
|
||||
return <GalleryElement {...commonProps} onCardClick={onGalleryCardClick} />;
|
||||
}
|
||||
if (isTooltipElementType(element.type)) {
|
||||
return <TooltipElement {...commonProps} />;
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React 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';
|
||||
|
||||
interface GalleryElementProps {
|
||||
@ -15,6 +19,7 @@ interface GalleryElementProps {
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
onCardClick?: (cardIndex: number) => void;
|
||||
}
|
||||
|
||||
const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
@ -22,29 +27,94 @@ const GalleryElement: React.FC<GalleryElementProps> = ({
|
||||
resolveUrl,
|
||||
className,
|
||||
style,
|
||||
onCardClick,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const cards: GalleryCard[] = element.galleryCards || [];
|
||||
const infoSpans: GalleryInfoSpan[] = element.galleryInfoSpans || [];
|
||||
const headerImageUrl = element.galleryHeaderImageUrl;
|
||||
const title = element.galleryTitle;
|
||||
const columns = element.galleryColumns || 3;
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded min-w-[150px]'>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className='relative aspect-square min-w-[40px] min-h-[40px]'
|
||||
>
|
||||
{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'
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<div className='flex flex-col gap-2 p-3 bg-black/60 rounded-xl min-w-[200px] backdrop-blur-sm'>
|
||||
{/* Header image */}
|
||||
{headerImageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolve(headerImageUrl)}
|
||||
alt=''
|
||||
className='w-full h-auto object-cover rounded-lg'
|
||||
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>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@ -11,26 +11,116 @@ function extractExtensionFrom(filename) {
|
||||
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 {
|
||||
/**
|
||||
* 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) {
|
||||
if (!schema) {
|
||||
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 (!file.type.startsWith('image')) {
|
||||
const result = validateAssetType(file, 'image');
|
||||
if (!result.valid) {
|
||||
throw new Error('You must upload an image');
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.size && file.size > schema.size) {
|
||||
throw new Error('File is too big.');
|
||||
// Legacy video validation
|
||||
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)) {
|
||||
throw new Error('Invalid format');
|
||||
// File size validation
|
||||
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}`;
|
||||
|
||||
// Debug logging removed for production builds
|
||||
|
||||
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,5 +18,3 @@ export const appTitle = 'Shimahara Visual';
|
||||
|
||||
export const getPageTitle = (currentPageTitle: string) =>
|
||||
`${currentPageTitle} — ${appTitle}`;
|
||||
|
||||
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '';
|
||||
|
||||
@ -98,3 +98,14 @@
|
||||
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,
|
||||
CanvasElementType,
|
||||
GalleryCard,
|
||||
GalleryInfoSpan,
|
||||
CarouselSlide,
|
||||
} from '../types/constructor';
|
||||
import {
|
||||
@ -83,6 +84,12 @@ interface UseConstructorElementsResult {
|
||||
update: (cardId: string, patch: Partial<GalleryCard>) => 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 */
|
||||
carouselSlides: {
|
||||
add: () => void;
|
||||
@ -338,6 +345,41 @@ export function useConstructorElements({
|
||||
[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
|
||||
const carouselSlides = useMemo(
|
||||
() => ({
|
||||
@ -387,6 +429,7 @@ export function useConstructorElements({
|
||||
removeSelectedElement,
|
||||
removeElement,
|
||||
galleryCards,
|
||||
galleryInfoSpans,
|
||||
carouselSlides,
|
||||
updateElementPosition,
|
||||
normalizeNavigationType: normalizeNavigationElementType,
|
||||
|
||||
@ -274,8 +274,8 @@ export function usePageSwitch(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try cached blob URL by resolved URL
|
||||
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
|
||||
// Fallback: try cached blob URL by resolved URL (check Cache API directly)
|
||||
if (cache?.getCachedBlobUrl) {
|
||||
try {
|
||||
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
||||
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 {
|
||||
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
||||
if (blobUrl) {
|
||||
|
||||
@ -560,6 +560,36 @@ export function usePreloadOrchestrator(
|
||||
return () => 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
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentPageId || !networkInfo.isOnline) {
|
||||
|
||||
@ -467,6 +467,16 @@ export const buildElementSettings = (
|
||||
addIfNotEmpty(settings, 'iconUrl', element.iconUrl);
|
||||
addIfNotEmpty(settings, 'tooltipTitle', element.tooltipTitle);
|
||||
addIfNotEmpty(settings, 'tooltipText', element.tooltipText);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'tooltipTitleFontFamily',
|
||||
element.tooltipTitleFontFamily,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'tooltipTextFontFamily',
|
||||
element.tooltipTextFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
// Description type settings
|
||||
@ -512,16 +522,25 @@ export const buildElementSettings = (
|
||||
}
|
||||
|
||||
// Gallery type settings
|
||||
if (
|
||||
isGalleryElementType(elementType) &&
|
||||
Array.isArray(element.galleryCards)
|
||||
) {
|
||||
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
||||
id: String(card.id || createLocalId()),
|
||||
imageUrl: card.imageUrl || '',
|
||||
title: card.title || `Card ${i + 1}`,
|
||||
description: card.description || '',
|
||||
}));
|
||||
if (isGalleryElementType(elementType)) {
|
||||
if (Array.isArray(element.galleryCards)) {
|
||||
settings.galleryCards = element.galleryCards.map((card, i) => ({
|
||||
id: String(card.id || createLocalId()),
|
||||
imageUrl: card.imageUrl || '',
|
||||
title: card.title || `Card ${i + 1}`,
|
||||
description: card.description || '',
|
||||
}));
|
||||
}
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'galleryTitleFontFamily',
|
||||
element.galleryTitleFontFamily,
|
||||
);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'galleryCardFontFamily',
|
||||
element.galleryCardFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
// Carousel type settings
|
||||
@ -535,6 +554,11 @@ export const buildElementSettings = (
|
||||
}
|
||||
addIfNotEmpty(settings, 'carouselPrevIconUrl', element.carouselPrevIconUrl);
|
||||
addIfNotEmpty(settings, 'carouselNextIconUrl', element.carouselNextIconUrl);
|
||||
addIfNotEmpty(
|
||||
settings,
|
||||
'carouselCaptionFontFamily',
|
||||
element.carouselCaptionFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
// Media type settings
|
||||
|
||||
@ -37,6 +37,7 @@ export interface ElementStyleProperties {
|
||||
backgroundColor?: string;
|
||||
color?: string;
|
||||
fontFamily?: string;
|
||||
fontStretch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -68,6 +69,7 @@ export const ELEMENT_STYLE_PROPS = [
|
||||
'backgroundColor',
|
||||
'color',
|
||||
'fontFamily',
|
||||
'fontStretch',
|
||||
] 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 { store } from '../stores/store';
|
||||
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 axios from 'axios';
|
||||
import { baseURLApi } from '../config';
|
||||
import { useRouter } from 'next/router';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import DevModeBadge from '../components/DevModeBadge';
|
||||
import 'intro.js/introjs.css';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import '../i18n';
|
||||
@ -311,10 +314,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
{(process.env.NODE_ENV === 'development' ||
|
||||
(process.env.NODE_ENV as string) === 'dev_stage') && (
|
||||
<DevModeBadge />
|
||||
)}
|
||||
</>,
|
||||
)}
|
||||
</DownloadProvider>
|
||||
|
||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
|
||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
|
||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { update, fetch } from '../../stores/assets/assetsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
|
||||
@ -16,6 +16,7 @@ import ConstructorControlsPanel from '../components/Constructor/ConstructorContr
|
||||
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
||||
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
||||
import CanvasElementComponent from '../components/Constructor/CanvasElement';
|
||||
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
|
||||
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
@ -182,6 +183,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const [elementEditorTab, setElementEditorTab] = useState<
|
||||
'general' | 'css' | 'effects'
|
||||
>('general');
|
||||
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
|
||||
element: CanvasElement;
|
||||
initialIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||
@ -200,6 +205,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
updateSelectedElement,
|
||||
removeSelectedElement,
|
||||
galleryCards,
|
||||
galleryInfoSpans,
|
||||
carouselSlides,
|
||||
updateElementPosition,
|
||||
normalizeNavigationType,
|
||||
@ -813,6 +819,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
description: String(card?.description || ''),
|
||||
}))
|
||||
: 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)
|
||||
? item.carouselSlides.map((slide: any, index: number) => ({
|
||||
id: String(slide?.id || createLocalId()),
|
||||
@ -829,6 +853,47 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
typeof item.carouselNextIconUrl === 'string'
|
||||
? 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:
|
||||
typeof item.tooltipTitle === 'string' ? item.tooltipTitle : '',
|
||||
tooltipText:
|
||||
@ -1072,6 +1137,38 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
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) =>
|
||||
isElementVisibleAtTime(
|
||||
canvasElapsedSec,
|
||||
@ -1113,13 +1210,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
);
|
||||
|
||||
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 =
|
||||
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
|
||||
pageSwitch.currentBgImageUrl || resolveUrlWithBlob(backgroundImageUrl);
|
||||
const backgroundVideoSrc =
|
||||
pageSwitch.currentBgVideoUrl || resolveAssetPlaybackUrl(backgroundVideoUrl);
|
||||
pageSwitch.currentBgVideoUrl || resolveUrlWithBlob(backgroundVideoUrl);
|
||||
const backgroundAudioSrc =
|
||||
pageSwitch.currentBgAudioUrl || resolveAssetPlaybackUrl(backgroundAudioUrl);
|
||||
pageSwitch.currentBgAudioUrl || resolveUrlWithBlob(backgroundAudioUrl);
|
||||
|
||||
const hasEditorSelection =
|
||||
isConstructorEditMode &&
|
||||
@ -1253,6 +1350,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onClick={() => onCanvasElementClick(element)}
|
||||
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@ -1312,6 +1412,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
activePageId={activePageId}
|
||||
onPreviewTransition={openTransitionPreview}
|
||||
galleryCards={galleryCards}
|
||||
galleryInfoSpans={galleryInfoSpans}
|
||||
carouselSlides={carouselSlides}
|
||||
normalizeNavigationType={normalizeNavigationType}
|
||||
getDuration={getDuration}
|
||||
@ -1350,6 +1451,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
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>{`
|
||||
.menu-action-btn {
|
||||
width: 100%;
|
||||
|
||||
@ -314,6 +314,8 @@ const ElementTypeDefaultDetailsPage = () => {
|
||||
iconUrl={form.state.iconUrl}
|
||||
tooltipTitle={form.state.tooltipTitle}
|
||||
tooltipText={form.state.tooltipText}
|
||||
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
|
||||
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
|
||||
onChange={handleTypeChange}
|
||||
context='global'
|
||||
/>
|
||||
@ -349,9 +351,12 @@ const ElementTypeDefaultDetailsPage = () => {
|
||||
{form.isGalleryType && (
|
||||
<GallerySettingsSection
|
||||
galleryCards={form.state.galleryCards}
|
||||
galleryTitleFontFamily={form.state.galleryTitleFontFamily}
|
||||
galleryCardFontFamily={form.state.galleryCardFontFamily}
|
||||
onAddCard={form.addGalleryCard}
|
||||
onRemoveCard={form.removeGalleryCard}
|
||||
onUpdateCard={form.updateGalleryCard}
|
||||
onChange={handleTypeChange}
|
||||
context='global'
|
||||
/>
|
||||
)}
|
||||
@ -360,6 +365,7 @@ const ElementTypeDefaultDetailsPage = () => {
|
||||
<CarouselSettingsSection
|
||||
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
||||
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
||||
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
|
||||
carouselSlides={form.state.carouselSlides}
|
||||
onAddSlide={form.addCarouselSlide}
|
||||
onRemoveSlide={form.removeCarouselSlide}
|
||||
|
||||
@ -23,7 +23,6 @@ import FormImagePicker from '../../components/FormImagePicker';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
|
||||
@ -501,6 +501,8 @@ const ProjectElementDefaultDetailsPage = () => {
|
||||
iconUrl={form.state.iconUrl}
|
||||
tooltipTitle={form.state.tooltipTitle}
|
||||
tooltipText={form.state.tooltipText}
|
||||
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
|
||||
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
|
||||
onChange={handleTypeChange}
|
||||
context='project'
|
||||
/>
|
||||
@ -534,9 +536,12 @@ const ProjectElementDefaultDetailsPage = () => {
|
||||
{form.isGalleryType && (
|
||||
<GallerySettingsSection
|
||||
galleryCards={form.state.galleryCards}
|
||||
galleryTitleFontFamily={form.state.galleryTitleFontFamily}
|
||||
galleryCardFontFamily={form.state.galleryCardFontFamily}
|
||||
onAddCard={form.addGalleryCard}
|
||||
onRemoveCard={form.removeGalleryCard}
|
||||
onUpdateCard={form.updateGalleryCard}
|
||||
onChange={handleTypeChange}
|
||||
context='project'
|
||||
/>
|
||||
)}
|
||||
@ -545,6 +550,7 @@ const ProjectElementDefaultDetailsPage = () => {
|
||||
<CarouselSettingsSection
|
||||
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
|
||||
carouselNextIconUrl={form.state.carouselNextIconUrl}
|
||||
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
|
||||
carouselSlides={form.state.carouselSlides}
|
||||
onAddSlide={form.addCarouselSlide}
|
||||
onRemoveSlide={form.removeCarouselSlide}
|
||||
|
||||
@ -20,13 +20,13 @@ import FormField from '../../components/FormField';
|
||||
import BaseDivider from '../../components/BaseDivider';
|
||||
import BaseButtons from '../../components/BaseButtons';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { deleteItem, update, fetch } from '../../stores/projects/projectsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { Project } from '../../types/entities';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
@ -41,6 +41,7 @@ const initVals = {
|
||||
themeTextColor: '',
|
||||
// Custom CSS fields (stored as JSON in custom_css_json)
|
||||
customFontFamily: '',
|
||||
customFontStretch: '',
|
||||
cdn_base_url: '',
|
||||
is_deleted: false,
|
||||
deleted_at_time: new Date(),
|
||||
@ -72,14 +73,15 @@ const parseThemeConfig = (
|
||||
*/
|
||||
const parseCustomCss = (
|
||||
json: string | Record<string, unknown> | null | undefined,
|
||||
): { fontFamily: string } => {
|
||||
const defaults = { fontFamily: '' };
|
||||
): { 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;
|
||||
@ -93,7 +95,7 @@ const buildThemeConfigJson = (values: {
|
||||
themePrimaryColor: string;
|
||||
themeBackgroundColor: string;
|
||||
themeTextColor: string;
|
||||
}): string | null => {
|
||||
}): Record<string, string> | null => {
|
||||
const config: Record<string, string> = {};
|
||||
|
||||
if (values.themePrimaryColor.trim()) {
|
||||
@ -106,7 +108,7 @@ const buildThemeConfigJson = (values: {
|
||||
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: {
|
||||
customFontFamily: string;
|
||||
}): string | null => {
|
||||
customFontStretch: string;
|
||||
}): Record<string, string> | null => {
|
||||
const config: Record<string, string> = {};
|
||||
|
||||
if (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 = () => {
|
||||
@ -222,6 +228,7 @@ const EditProjectsPage = () => {
|
||||
themeBackgroundColor: themeConfig.backgroundColor,
|
||||
themeTextColor: themeConfig.textColor,
|
||||
customFontFamily: customCss.fontFamily,
|
||||
customFontStretch: customCss.fontStretch,
|
||||
cdn_base_url: String(projectData.cdn_base_url || ''),
|
||||
is_deleted: Boolean(projectData.is_deleted),
|
||||
deleted_at_time: projectData.deleted_at_time
|
||||
@ -241,6 +248,7 @@ const EditProjectsPage = () => {
|
||||
|
||||
const custom_css_json = buildCustomCssJson({
|
||||
customFontFamily: data.customFontFamily,
|
||||
customFontStretch: data.customFontStretch,
|
||||
});
|
||||
|
||||
// Prepare data for API (exclude expanded fields, include JSON)
|
||||
@ -251,8 +259,8 @@ const EditProjectsPage = () => {
|
||||
logo_url: data.logo_url,
|
||||
favicon_url: data.favicon_url,
|
||||
og_image_url: data.og_image_url,
|
||||
theme_config_json: theme_config_json as string | undefined,
|
||||
custom_css_json: custom_css_json as string | undefined,
|
||||
theme_config_json: theme_config_json,
|
||||
custom_css_json: custom_css_json,
|
||||
cdn_base_url: data.cdn_base_url,
|
||||
};
|
||||
|
||||
@ -308,7 +316,9 @@ const EditProjectsPage = () => {
|
||||
<Field
|
||||
name='description'
|
||||
id='description'
|
||||
component={RichTextField}
|
||||
as='textarea'
|
||||
rows={4}
|
||||
placeholder='Project description'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@ -427,10 +437,34 @@ const EditProjectsPage = () => {
|
||||
</FormField>
|
||||
|
||||
<FormField label='Custom Font Family'>
|
||||
<Field
|
||||
name='customFontFamily'
|
||||
placeholder='e.g. Montserrat, sans-serif'
|
||||
/>
|
||||
<Field name='customFontFamily'>
|
||||
{({ field, form }: { field: { value: string }; form: { setFieldValue: (name: string, value: string) => void; values: typeof initVals } }) => (
|
||||
<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 label='CDN Base URL'>
|
||||
|
||||
@ -19,7 +19,6 @@ import BaseDivider from '../../components/BaseDivider';
|
||||
import BaseButtons from '../../components/BaseButtons';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import { RichTextField } from '../../components/RichTextField';
|
||||
|
||||
import { create } from '../../stores/projects/projectsSlice';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
@ -80,7 +79,9 @@ const ProjectsNew = () => {
|
||||
<Field
|
||||
name='description'
|
||||
id='description'
|
||||
component={RichTextField}
|
||||
as='textarea'
|
||||
rows={4}
|
||||
placeholder='Project description'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@ -41,6 +41,14 @@ export interface GalleryCard {
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery info span (brief note badge)
|
||||
*/
|
||||
export interface GalleryInfoSpan {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carousel slide item
|
||||
*/
|
||||
@ -84,11 +92,21 @@ export interface CanvasElement extends BaseCanvasElement {
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
galleryCards?: GalleryCard[];
|
||||
// Gallery header settings
|
||||
galleryHeaderImageUrl?: string;
|
||||
galleryTitle?: string;
|
||||
galleryInfoSpans?: GalleryInfoSpan[];
|
||||
galleryColumns?: number;
|
||||
galleryTitleFontFamily?: string;
|
||||
galleryCardFontFamily?: string;
|
||||
carouselSlides?: CarouselSlide[];
|
||||
carouselCaptionFontFamily?: string;
|
||||
carouselPrevIconUrl?: string;
|
||||
carouselNextIconUrl?: string;
|
||||
tooltipTitle?: string;
|
||||
tooltipText?: string;
|
||||
tooltipTitleFontFamily?: string;
|
||||
tooltipTextFontFamily?: string;
|
||||
descriptionTitle?: string;
|
||||
descriptionText?: string;
|
||||
descriptionTitleFontSize?: string;
|
||||
@ -109,6 +127,27 @@ export interface CanvasElement extends BaseCanvasElement {
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl?: string;
|
||||
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;
|
||||
remove: (cardId: string) => void;
|
||||
};
|
||||
galleryInfoSpans: {
|
||||
add: () => void;
|
||||
update: (spanId: string, text: string) => void;
|
||||
remove: (spanId: string) => void;
|
||||
};
|
||||
carouselSlides: {
|
||||
add: () => void;
|
||||
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||
|
||||
@ -13,6 +13,10 @@ module.exports = {
|
||||
gray: 'gray'
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'instrument': ['"Instrument Sans Variable"', 'sans-serif'],
|
||||
'instrument-condensed': ['"Instrument Sans Variable"', 'sans-serif'],
|
||||
},
|
||||
zIndex: {
|
||||
'-1': '-1'
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user