updates gallery and carousel settings

This commit is contained in:
Dmitri 2026-03-30 21:28:00 +04:00
parent 7b21006086
commit 024c04e05a
54 changed files with 2619 additions and 1074 deletions

View File

@ -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,

View File

@ -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'));
}
});

View File

@ -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;

View File

@ -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,
};

View File

@ -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;

View File

@ -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

View File

@ -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,

View File

@ -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>
);

View File

@ -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)

View File

@ -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 modepages 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;

View File

@ -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

View File

@ -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'>

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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';

View File

@ -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
*/

View File

@ -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

View File

@ -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>
);

View File

@ -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; }' : ''}`,
}}
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
</>
);

View 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;

View File

@ -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} />;

View File

@ -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>
);

View File

@ -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}`;
}
}

View File

@ -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 || '';

View File

@ -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%;
}

View File

@ -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,

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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
View 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;
}

View File

@ -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>

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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%;

View File

@ -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}

View File

@ -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';

View File

@ -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}

View File

@ -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'>

View File

@ -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>

View File

@ -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;

View File

@ -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