diff --git a/backend/src/config.js b/backend/src/config.js index 4ee80c9..060e7ee 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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, diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index cf52d71..329f0c1 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -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')); } }); diff --git a/backend/src/services/assets.js b/backend/src/services/assets.js index 14c3d9e..fc02a5c 100644 --- a/backend/src/services/assets.js +++ b/backend/src/services/assets.js @@ -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; diff --git a/backend/src/services/file.js b/backend/src/services/file.js index af59570..374c620 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -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, }; diff --git a/backend/src/services/file/S3StorageProvider.js b/backend/src/services/file/S3StorageProvider.js index b908bb3..ef92351 100644 --- a/backend/src/services/file/S3StorageProvider.js +++ b/backend/src/services/file/S3StorageProvider.js @@ -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} */ - 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} + * @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} */ - 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} 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; diff --git a/frontend/package.json b/frontend/package.json index f9a2912..deca983 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 16d4e31..65c5cf2 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},r=e=>[s.prefix,e,s.suffix].filter(e=>e&&e.length>0).join("-"),n={updateDetails:e=>{var t=t=>{let a=e[t];"string"==typeof a&&(s[t]=a)};for(let e of Object.keys(s))t(e)},getGoogleAnalyticsName:e=>e||r(s.googleAnalytics),getPrecacheName:e=>e||r(s.precache),getRuntimeName:e=>e||r(s.runtime)};class i extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}}function c(e){return new Promise(t=>setTimeout(t,e))}let o=new Set;function l(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function h(e,t,a,s){let r=l(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===l(i.url,a))return e.match(i,s)}class u{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}let d=async()=>{for(let e of o)await e()},m="-precache-",g=async(e,t=m)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},p=(e,t)=>{let a=t();return e.waitUntil(a),a},f=(e,t)=>t.some(t=>e instanceof t),w=new WeakMap,y=new WeakMap,_=new WeakMap,b={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return w.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return x(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function x(e){if(e instanceof IDBRequest){let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(x(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)});return _.set(t,e),t}if(y.has(e))return y.get(e);let s=function(e){if("function"==typeof e)return(a||(a=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(R(this),t),x(this.request)}:function(...t){return x(e.apply(R(this),t))};return(e instanceof IDBTransaction&&function(e){if(w.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});w.set(e,t)}(e),f(e,t||(t=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,b):e}(e);return s!==e&&(y.set(e,s),_.set(s,e)),s}let R=e=>_.get(e);function v(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=x(i);return s&&i.addEventListener("upgradeneeded",e=>{s(x(i.result),e.oldVersion,e.newVersion,x(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let E=["get","getKey","getAll","getAllKeys","count"],S=["put","add","delete","clear"],q=new Map;function N(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(q.get(t))return q.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=S.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||E.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return q.set(t,n),n}b=(e=>({...e,get:(t,a,s)=>N(t,a)||e.get(t,a,s),has:(t,a)=>!!N(t,a)||e.has(t,a)}))(b);let C=["continue","continuePrimaryKey","advance"],D={},T=new WeakMap,A=new WeakMap,P={get(e,t){if(!C.includes(t))return e[t];let a=D[t];return a||(a=D[t]=function(...e){T.set(this,A.get(this)[t](...e))}),a}};async function*k(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,P);for(A.set(a,t),_.set(a,R(t));t;)yield a,t=await (T.get(a)||t.continue()),T.delete(a)}function U(e,t){return t===Symbol.asyncIterator&&f(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&f(e,[IDBIndex,IDBObjectStore])}b=(e=>({...e,get:(t,a,s)=>U(t,a)?k:e.get(t,a,s),has:(t,a)=>U(t,a)||e.has(t,a)}))(b);let I=async(t,a)=>{let s=null;if(t.url&&(s=new URL(t.url).origin),s!==self.location.origin)throw new i("cross-origin-copy-response",{origin:s});let r=t.clone(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},c=a?a(n):n,o=!function(){if(void 0===e){let t=new Response("");if("body"in t)try{new Response(t.body),e=!0}catch{e=!1}e=!1}return e}()?await r.blob():r.body;return new Response(o,c)},L="requests",F="queueName";class W{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(L,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){let e=await this.getDb(),t=await e.transaction(L).store.openCursor();return t?.value.id}async getAllEntriesByQueueName(e){let t=await this.getDb();return await t.getAllFromIndex(L,F,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(L,F,IDBKeyRange.only(e))}async deleteEntry(e){let t=await this.getDb();await t.delete(L,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){let a=await this.getDb(),s=await a.transaction(L).store.index(F).openCursor(e,t);return s?.value}async getDb(){return this._db||(this._db=await v("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(L)&&e.deleteObjectStore(L),e.createObjectStore(L,{autoIncrement:!0,keyPath:"id"}).createIndex(F,F,{unique:!1})}}class M{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new W}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}}let O=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];class B{_requestData;static async fromRequest(e){let t={url:e.url,headers:{}};for(let a of("GET"!==e.method&&(t.body=await e.clone().arrayBuffer()),e.headers.forEach((e,a)=>{t.headers[a]=e}),O))void 0!==e[a]&&(t[a]=e[a]);return new B(t)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new B(this.toObject())}}let K="serwist-background-sync",j=new Set,H=e=>{let t={request:new B(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};class ${_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(j.has(e))throw new i("duplicate-queue-name",{name:e});j.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new M(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(H(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await B.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):H(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new i("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${K}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${K}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return j}}class G{_queue;constructor(e,t){this._queue=new $(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}}let z={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function V(e){return"string"==typeof e?new Request(e):e}class Q{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(let a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new u,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=V(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new i("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let n=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=V(e),{cacheName:s,matchOptions:r}=this._strategy,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,event:this.event})||void 0;return t}async cachePut(e,t){let a=V(e);await c(0);let s=await this.getCacheKey(a,"write");if(!t)throw new i("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:n,matchOptions:o}=this._strategy,l=await self.caches.open(n),u=this.hasCallback("cacheDidUpdate"),m=u?await h(l,s.clone(),["__WB_REVISION__"],o):null;try{await l.put(s,u?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await d(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,oldResponse:m,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=V(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}}class J{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=n.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new Q(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t),n=this._awaitComplete(r,s,a,t);return[r,n]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new i("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}}class X extends J{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(z),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let c=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!c)throw new i("no-response",{url:e.url});return c}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}}class Y extends J{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=c(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new i("no-response",{url:e.url,error:a});return s}}let Z=e=>e&&"object"==typeof e?e:{handle:e};class ee{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=Z(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=Z(e)}}class et extends J{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await I(e):e};constructor(e={}){e.cacheName=n.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=!1!==e.fallbackToNetwork,this.plugins.push(et.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new i("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new i("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(let[a,s]of this.plugins.entries())s!==et.copyRedirectedCacheableResponsesPlugin&&(s===et.defaultPrecacheCacheabilityPlugin&&(e=a),s.cacheWillUpdate&&t++);0===t?this.plugins.push(et.defaultPrecacheCacheabilityPlugin):t>1&&null!==e&&this.plugins.splice(e,1)}}class ea extends ee{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}}class es extends ee{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}}let er=e=>{if(!e)throw new i("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new i("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};class en{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}}let ei=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).flat().sort((e,t)=>e.indexe.result)};"undefined"!=typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let ec="cache-entries",eo=e=>{let t=new URL(e,location.href);return t.hash="",t.href};class el{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eo(e)}`}_upgradeDb(e){let t=e.createObjectStore(ec,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),x(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eo(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(ec,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){let t=await this.getDb(),a=await t.get(ec,this._getId(e));return a?.timestamp}async expireEntries(e,t){let a=await this.getDb(),s=await a.transaction(ec,"readwrite").store.index("timestamp").openCursor(null,"prev"),r=[],n=0;for(;s;){let a=s.value;a.cacheName===this._cacheName&&(e&&a.timestamp=t?(s.delete(),r.push(a.url)):n++),s=await s.continue()}return r}async getDb(){return this._db||(this._db=await v("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}}class eh{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new el(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||tthis.deleteCacheAndMetadata(),o.add(t))}_getCacheExpiration(e){if(e===n.getRuntimeName())throw new i("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new eh(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}}let ed="www.google-analytics.com",em="www.googletagmanager.com",eg=/^\/(\w+\/)?collect/,ep=({serwist:e,cacheName:t,...a})=>{let s=n.getGoogleAnalyticsName(t),r=new G("serwist-google-analytics",{maxRetentionTime:2880,onSync:(e=>async({queue:t})=>{let a;for(;a=await t.shiftRequest();){let{request:s,timestamp:r}=a,n=new URL(s.url);try{let t="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,a=r-(Number(t.get("qt"))||0),i=Date.now()-a;if(t.set("qt",String(i)),e.parameterOverrides)for(let a of Object.keys(e.parameterOverrides)){let s=e.parameterOverrides[a];t.set(a,s)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,t),await fetch(new Request(n.origin+n.pathname,{body:t.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(a),e}}})(a)});for(let t of[new ee(({url:e})=>e.hostname===em&&"/gtm.js"===e.pathname,new X({cacheName:s}),"GET"),new ee(({url:e})=>e.hostname===ed&&"/analytics.js"===e.pathname,new X({cacheName:s}),"GET"),new ee(({url:e})=>e.hostname===em&&"/gtag/js"===e.pathname,new X({cacheName:s}),"GET"),...(e=>{let t=({url:e})=>e.hostname===ed&&eg.test(e.pathname),a=new Y({plugins:[e]});return[new ee(t,a,"GET"),new ee(t,a,"POST")]})(r)])e.registerRoute(t)};class ef{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}}let ew=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new i("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new i("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new i("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new i("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new i("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),c=r.slice(n.start,n.end),o=c.size,l=new Response(c,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",String(o)),l.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};class ey{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ew(e,t):t}class e_ extends J{async _handle(e,t){let a,s=await t.cacheMatch(e);if(!s)try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new i("no-response",{url:e.url,error:a});return s}}class eb extends J{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(z)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new i("no-response",{url:e.url,error:a});return r}}class ex extends ee{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t){let a=e.getIntegrityForPrecacheKey(t);return{cacheKey:t,integrity:a}}}},e.precacheStrategy)}}class eR{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}}class ev{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:i,clientsClaim:c=!1,runtimeCaching:o,offlineAnalyticsConfig:l,disableDevLogs:h=!1,fallbacks:u,requestRules:d}={}){var m,p;let{precacheStrategyOptions:f,precacheRouteOptions:w,precacheMiscOptions:y}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:i,fallbackToNetwork:c,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:g,navigateFallbackAllowlist:p,navigateFallbackDenylist:f}=t??{};return{precacheStrategyOptions:{cacheName:n.getPrecacheName(a),plugins:[...s,new eR({precacheController:e})],fetchOptions:r,matchOptions:i,fallbackToNetwork:c},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:g,navigateFallbackAllowlist:p,navigateFallbackDenylist:f}}})(this,t);if(this._concurrentPrecaching=y.concurrency,this._precacheStrategy=new et(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=d,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==i&&(m={prefix:i},n.updateDetails(m)),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),c&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),y.cleanupOutdatedCaches&&(p=f.cacheName,self.addEventListener("activate",e=>{e.waitUntil(g(n.getPrecacheName(p)).then(e=>{}))})),this.registerRoute(new ex(this,w)),y.navigateFallback&&this.registerRoute(new ea(this.createHandlerBoundToUrl(y.navigateFallback),{allowlist:y.navigateFallbackAllowlist,denylist:y.navigateFallbackDenylist})),void 0!==l&&("boolean"==typeof l?l&&ep({serwist:this}):ep({...l,serwist:this})),void 0!==o){if(void 0!==u){let e=new ef({fallbackUrls:u.entries,serwist:this});o.forEach(t=>{t.handler instanceof J&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(let e of o)this.registerCapture(e.matcher,e.handler,e.method)}h&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=er(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new i("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new i("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),p(e,async()=>{let t=new en;this.precacheStrategy.plugins.push(t),await ei(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return p(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,Z(e))}setCatchHandler(e){this._catchHandler=Z(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new ee(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new es(e,t,a);if("function"==typeof e)return new ee(e,t,a);if(e instanceof ee)return e;throw new i("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new i("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new i("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new i("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}}let eE={rscPrefetch:"pages-rsc-prefetch",rsc:"pages-rsc",html:"pages"},eS=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new e_({cacheName:"google-fonts-webfonts",plugins:[new eu({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new e_({cacheName:"next-static-js-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new e_({cacheName:"static-audio-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:mp4|webm)$/i,handler:new e_({cacheName:"static-video-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new eu({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new X({cacheName:"next-data",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new X({cacheName:"static-data-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new Y({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new X({cacheName:"apis",plugins:[new eu({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rscPrefetch,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rsc,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.html,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new X({cacheName:"others",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new X({cacheName:"cross-origin",plugins:[new eu({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new Y}],eq={cacheNames:{static:"tour-builder-static-v1",dynamic:"tour-builder-dynamic-v1",assets:"tour-builder-assets-v1"}},eN=[".png",".jpg",".jpeg",".gif",".webp",".svg",".ico",".mp4",".webm",".mov",".mp3",".wav",".ogg",".m4a",".woff",".woff2",".ttf",".eot",".css",".js"],eC=e=>{let t=new URL(e.url);return[".mp4",".webm",".mov"].some(e=>t.pathname.toLowerCase().endsWith(e))},eD=new ev({precacheEntries:[{'revision':'c3faf668069c3cb2f1e1cd175e1fc48e','url':'/_next/static/-qNcYJ7Wcn_tk8pWIYQ1n/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/-qNcYJ7Wcn_tk8pWIYQ1n/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1232-ee492d53bb7b1303.js'},{'revision':null,'url':'/_next/static/chunks/1268-d5a614f357cba985.js'},{'revision':null,'url':'/_next/static/chunks/1818-bbd302304458b10c.js'},{'revision':null,'url':'/_next/static/chunks/1d2671aa.f600276eb687cd2d.js'},{'revision':null,'url':'/_next/static/chunks/223-5e7611f0ad4bb7f3.js'},{'revision':null,'url':'/_next/static/chunks/2841-39edd30f6ab34b9c.js'},{'revision':null,'url':'/_next/static/chunks/3643-cd854ecad4d47645.js'},{'revision':null,'url':'/_next/static/chunks/400-ac29014eab06ed0b.js'},{'revision':null,'url':'/_next/static/chunks/4102-215fc1bab02cea97.js'},{'revision':null,'url':'/_next/static/chunks/4166-356f635e40069ff3.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5541.e90be83844a6de15.js'},{'revision':null,'url':'/_next/static/chunks/6d2b60a9-eb6c7fd9a57c4f19.js'},{'revision':null,'url':'/_next/static/chunks/7574-43336482db7d6cfd.js'},{'revision':null,'url':'/_next/static/chunks/764-1456dc10fb4078c8.js'},{'revision':null,'url':'/_next/static/chunks/7e42aecb-94f8c450c54b9556.js'},{'revision':null,'url':'/_next/static/chunks/8230-251e23b7a24ff307.js'},{'revision':null,'url':'/_next/static/chunks/8232-06043c4b3efac0d3.js'},{'revision':null,'url':'/_next/static/chunks/8317-4e9c090ccc089653.js'},{'revision':null,'url':'/_next/static/chunks/8628-6ecd4d0a7ec3b342.js'},{'revision':null,'url':'/_next/static/chunks/8666-852bd0a04615812e.js'},{'revision':null,'url':'/_next/static/chunks/9375.2c053880845a1e8b.js'},{'revision':null,'url':'/_next/static/chunks/9848-799d062feeef8c3c.js'},{'revision':null,'url':'/_next/static/chunks/fa3de7d5.7acfffddc0ff677e.js'},{'revision':null,'url':'/_next/static/chunks/framework-4dea986807dc9d02.js'},{'revision':null,'url':'/_next/static/chunks/main-f35af714f1aa0b25.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-530d8245847656c6.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/%5Baccess_logsId%5D-688fe121d2a3e151.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-7ec8a23f417ac912.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-list-b7b1e92379006764.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-new-1c0b08a5d9c083df.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-table-97bbef6e9c64cb6a.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-view-e9058d9f7f414484.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-6dfd4d820b40f92f.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-35f0ff408591ee62.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-list-cf3da7bb076aa6f0.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-new-3665f1d0e6e84dd4.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-table-ee30957ff8d738c7.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-view-d0e55c5f9f8ff1b7.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-af254426d65f0864.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-95476f307d3e3e94.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-01bfcf82c04c952d.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-new-4befa34e01efba55.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-table-5c322503011d3c30.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-view-ecd33f0c9c5f2966.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-774a302fa4690032.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-177a4d237aa74b5a.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults-e13fbe5ce27ee189.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults/%5Bid%5D-6f1c78f00f1397a2.js'},{'revision':null,'url':'/_next/static/chunks/pages/error-e0bd1c24aeb3d601.js'},{'revision':null,'url':'/_next/static/chunks/pages/forgot-78b70d91742fd5b1.js'},{'revision':null,'url':'/_next/static/chunks/pages/index-53587e829512c859.js'},{'revision':null,'url':'/_next/static/chunks/pages/login-79ec11c8552cf796.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D-23f54142e6a62921.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-cbc6a60fdcc891cc.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-b0f11bedbd1544e8.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-173fccb641758e1d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-list-155df5771dec31f4.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-new-215d5a028e89340a.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-table-f664d12fd7bc2c5d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-view-ceec75a08ed58013.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-70893e3e835a6cc8.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-aea0eed1941705cc.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-list-736e04880898639a.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-new-baaf991bd748200f.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-table-0147792af7b6e2ca.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-view-5e152f00e477a011.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-53ea2331c015449b.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-b56ae759d0e63eff.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults-a79e41b69ca7d7e5.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults/%5Bid%5D-2d4b81f712acdb63.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-78dee88c3e15735a.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-6ea2d13513ddfcb8.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-list-7444bf0ff2adcf19.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-new-ba6d6a3f8515b0d7.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-table-b985f5c250b58ae0.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-view-f4c68e34b1e8903b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-dc1dab1d0d17cd7f.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-9257888337040240.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-list-12773f6237f73890.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-new-2e8763e625895fa4.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-table-cfeb32ab7462cc52.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-view-f8ba1dac35c3b387.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-2b8acadd75b0cc80.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-454008daf9b9622b.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-4890011ca9c3a3df.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-7bca321e9df7086e.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-be4bc62f893cb6e7.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-8275d95d8f621ec7.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-94002b63deed1d14.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-f19a2b0e32f8b264.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-1f25acfd1f8ea84b.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-new-967699a276f0fc1f.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-table-b7cb7261af5db570.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-view-65ffccfa3403aaa8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-0179dd292b427e90.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-9e5afbdc298d0452.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-list-ae78031d0263b1b8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-new-393d3dda89d132c0.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-table-b9a9d80285f6ccf5.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-view-ded2ce3f13d768fd.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-1c566d0bbee23701.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-1065d4b022ea646e.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-list-bb2e8266272a34b3.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-new-f88f533479cddf80.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-table-2ae06818d79da9ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-view-bb8dbddc92922eae.js'},{'revision':null,'url':'/_next/static/chunks/pages/search-f2874b266789f090.js'},{'revision':null,'url':'/_next/static/chunks/pages/terms-of-use-7d00698d6183bde4.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/%5Btour_pagesId%5D-c5dc8a35f6edb1ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-fef411d3437c13e0.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-d9b4b468929c770b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-new-59a0c6ab6948843b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-table-20ae6c4ea35a9ffe.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-view-98001111fa9b7868.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-41de512c34ec1310.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-d213af1348769a80.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-list-2185908a5a705b0e.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-new-5b4a2f9ec6e25d41.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-table-584067057f84845c.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-view-d7131a71727f174d.js'},{'revision':null,'url':'/_next/static/chunks/pages/verify-email-3c9a0ef6c6d5daff.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-81a31cf6f7504908.js'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':null,'url':'/_next/static/css/f4ec745d367e5bc9.css'},{'revision':'43778b43fb039fdd5b0510561dc952ac','url':'/assets/vm-shot-2026-03-17T04-16-09-161Z.jpg'},{'revision':'3395d49fe2b96221471b4db0f5caf9e7','url':'/assets/vm-shot-2026-03-17T04-19-03-565Z.jpg'},{'revision':'62127cd8f85821f3e570a377a8a7e14b','url':'/assets/vm-shot-2026-03-17T04-36-56-252Z.jpg'},{'revision':'145c2d7e4ef298391258c6d8a8aaaece','url':'/assets/vm-shot-2026-03-17T04-45-14-111Z.jpg'},{'revision':'c6aae6521f08c847e370764d1c9c613d','url':'/assets/vm-shot-2026-03-17T04-50-58-546Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg'},{'revision':'73de6cac0695249f4e59ce778a8e6e74','url':'/assets/vm-shot-2026-03-19T06-13-19-234Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-13-54-729Z.jpg'},{'revision':'ca6cbfcc74b52f00eef0f2adc8e65456','url':'/assets/vm-shot-2026-03-24T14-29-20-260Z.jpg'},{'revision':'0957c5365895c5ba31b10a84f4e45929','url':'/data-sources/clients.json'},{'revision':'5703d42f7838705ecf87510c4032b20c','url':'/data-sources/history.json'},{'revision':'5404a85badad8210a634ce41bb511545','url':'/favicon.svg'},{'revision':'508520242399a6b1fec65430901f4e6f','url':'/locales/de/common.json'},{'revision':'0a64739b954a93627749ffcb846fceaa','url':'/locales/en/common.json'},{'revision':'34715e25a3bcbab44f84232ce082d2ee','url':'/locales/es/common.json'},{'revision':'772a72f35589c06bdd8d9179c86459e6','url':'/locales/fr/common.json'},{'revision':'c67326edc61e0b5cf87e509ea7553466','url':'/manifest.json'},{'revision':'1254c9c72aa64724c203d26278712800','url':'/offline.html'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:[{matcher:e=>{let{request:t}=e,a=new URL(t.url);return"image"===t.destination||"font"===t.destination||[".css",".js",".woff",".woff2"].some(e=>a.pathname.endsWith(e))},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{request:t}=e;return eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cachedResponseWillBeUsed:async e=>{let{cachedResponse:t,request:a}=e;if(!t)return null;let s=a.headers.get("range");if(!s)return t;let r=s.match(/bytes=(\d+)-(\d*)/);if(!r)return t;let n=parseInt(r[1],10),i=r[2]?parseInt(r[2],10):void 0,c=await t.blob(),o=void 0!==i?c.slice(n,i+1):c.slice(n);return new Response(o,{status:206,statusText:"Partial Content",headers:{"Content-Type":t.headers.get("Content-Type")||"video/mp4","Content-Length":String(o.size),"Content-Range":"bytes ".concat(n,"-").concat(void 0!==i?i:c.size-1,"/").concat(c.size),"Accept-Ranges":"bytes"}})},cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{url:t}=e;return t.pathname.startsWith("/api/")},handler:new X({cacheName:"api-cache",networkTimeoutSeconds:10})},{matcher:e=>{let{request:t}=e;return(e=>{let t=new URL(e.url);return(!t.pathname.startsWith("/api/")||!!t.pathname.includes("/file/download"))&&!!(eN.some(e=>t.pathname.toLowerCase().endsWith(e))||t.pathname.includes("/file/download")||t.hostname.includes("amazonaws.com")||t.hostname.includes("cloudfront.net"))})(t)&&!eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},...eS]});self.addEventListener("message",e=>{let{type:t,payload:a}=e.data||{};switch(t){case"CACHE_ASSETS":Array.isArray(null==a?void 0:a.urls)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>Promise.all(a.urls.map(t=>fetch(t).then(a=>{if(200===a.status)return e.put(t,a)}).catch(e=>{console.warn("[SW] Failed to cache asset:",t,e)})))));break;case"CACHE_VIDEO_CHUNK":(null==a?void 0:a.url)&&(null==a?void 0:a.chunk)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>{let t=new Response(a.chunk,{headers:{"Content-Type":a.contentType||"video/mp4","Content-Length":String(a.chunk.byteLength)}});return e.put(a.url,t)}));break;case"CLEAR_CACHE":e.waitUntil(Promise.all([caches.delete(eq.cacheNames.dynamic),caches.delete(eq.cacheNames.assets)]).then(()=>{console.log("[SW] Caches cleared")}));break;case"GET_CACHE_STATUS":e.waitUntil(caches.open(eq.cacheNames.assets).then(t=>t.keys().then(t=>{let a=e.source;null==a||a.postMessage({type:"CACHE_STATUS",payload:{cachedCount:t.length,urls:t.map(e=>e.url)}})})));break;case"SKIP_WAITING":self.skipWaiting()}}),eD.addEventListeners(),console.log("[SW] Serwist service worker loaded")})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),p(e,async()=>{let t=new en;this.precacheStrategy.plugins.push(t),await ei(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return p(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,Z(e))}setCatchHandler(e){this._catchHandler=Z(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new ee(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new es(e,t,a);if("function"==typeof e)return new ee(e,t,a);if(e instanceof ee)return e;throw new i("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new i("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new i("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new i("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}}let eE={rscPrefetch:"pages-rsc-prefetch",rsc:"pages-rsc",html:"pages"},eS=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new e_({cacheName:"google-fonts-webfonts",plugins:[new eu({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new e_({cacheName:"next-static-js-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new e_({cacheName:"static-audio-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:mp4|webm)$/i,handler:new e_({cacheName:"static-video-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new eu({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new X({cacheName:"next-data",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new X({cacheName:"static-data-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new Y({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new X({cacheName:"apis",plugins:[new eu({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rscPrefetch,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rsc,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.html,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new X({cacheName:"others",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new X({cacheName:"cross-origin",plugins:[new eu({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new Y}],eq={cacheNames:{static:"tour-builder-static-v1",dynamic:"tour-builder-dynamic-v1",assets:"tour-builder-assets-v1"}},eN=[".png",".jpg",".jpeg",".gif",".webp",".svg",".ico",".mp4",".webm",".mov",".mp3",".wav",".ogg",".m4a",".woff",".woff2",".ttf",".eot",".css",".js"],eC=e=>{let t=new URL(e.url);return[".mp4",".webm",".mov"].some(e=>t.pathname.toLowerCase().endsWith(e))},eD=new ev({precacheEntries:[{'revision':'1eca24a2efed1530548b4d4c82676183','url':'/_next/static/X9wNeEW7GM0_Nt8Lcx2L-/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/X9wNeEW7GM0_Nt8Lcx2L-/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1232-ee492d53bb7b1303.js'},{'revision':null,'url':'/_next/static/chunks/1818-bbd302304458b10c.js'},{'revision':null,'url':'/_next/static/chunks/1d2671aa.f600276eb687cd2d.js'},{'revision':null,'url':'/_next/static/chunks/223-5e7611f0ad4bb7f3.js'},{'revision':null,'url':'/_next/static/chunks/2841-4ff34a4bd0bc362f.js'},{'revision':null,'url':'/_next/static/chunks/3643-cd854ecad4d47645.js'},{'revision':null,'url':'/_next/static/chunks/400-ac29014eab06ed0b.js'},{'revision':null,'url':'/_next/static/chunks/4140-a34f33566d3ef2ac.js'},{'revision':null,'url':'/_next/static/chunks/4166-356f635e40069ff3.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5371-1944e2cec48f4711.js'},{'revision':null,'url':'/_next/static/chunks/5541.e90be83844a6de15.js'},{'revision':null,'url':'/_next/static/chunks/6609-2f6820005bc8085f.js'},{'revision':null,'url':'/_next/static/chunks/6d2b60a9-eb6c7fd9a57c4f19.js'},{'revision':null,'url':'/_next/static/chunks/7574-633beb847705113e.js'},{'revision':null,'url':'/_next/static/chunks/7e42aecb-94f8c450c54b9556.js'},{'revision':null,'url':'/_next/static/chunks/8230-251e23b7a24ff307.js'},{'revision':null,'url':'/_next/static/chunks/8232-06043c4b3efac0d3.js'},{'revision':null,'url':'/_next/static/chunks/8317-4e9c090ccc089653.js'},{'revision':null,'url':'/_next/static/chunks/8628-4294e63d8a7907d5.js'},{'revision':null,'url':'/_next/static/chunks/9375.2c053880845a1e8b.js'},{'revision':null,'url':'/_next/static/chunks/9848-799d062feeef8c3c.js'},{'revision':null,'url':'/_next/static/chunks/fa3de7d5.7acfffddc0ff677e.js'},{'revision':null,'url':'/_next/static/chunks/framework-4dea986807dc9d02.js'},{'revision':null,'url':'/_next/static/chunks/main-f35af714f1aa0b25.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-530d8245847656c6.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/%5Baccess_logsId%5D-688fe121d2a3e151.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-7ec8a23f417ac912.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-list-b7b1e92379006764.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-new-1c0b08a5d9c083df.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-table-97bbef6e9c64cb6a.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-view-e9058d9f7f414484.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-6dfd4d820b40f92f.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-35f0ff408591ee62.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-list-cf3da7bb076aa6f0.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-new-3665f1d0e6e84dd4.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-table-ee30957ff8d738c7.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-view-d0e55c5f9f8ff1b7.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-af254426d65f0864.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-95476f307d3e3e94.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-ad0b4baf2c5e1eb6.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-new-4befa34e01efba55.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-table-5c322503011d3c30.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-view-ecd33f0c9c5f2966.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-18af45651de7de4d.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-177a4d237aa74b5a.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults-e13fbe5ce27ee189.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults/%5Bid%5D-78d73e2712788afc.js'},{'revision':null,'url':'/_next/static/chunks/pages/error-e0bd1c24aeb3d601.js'},{'revision':null,'url':'/_next/static/chunks/pages/forgot-78b70d91742fd5b1.js'},{'revision':null,'url':'/_next/static/chunks/pages/index-53587e829512c859.js'},{'revision':null,'url':'/_next/static/chunks/pages/login-79ec11c8552cf796.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D-a7a5ae690dabe31c.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-5e29945f59baac19.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-b0f11bedbd1544e8.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-173fccb641758e1d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-list-155df5771dec31f4.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-new-215d5a028e89340a.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-table-f664d12fd7bc2c5d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-view-ceec75a08ed58013.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-70893e3e835a6cc8.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-aea0eed1941705cc.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-list-736e04880898639a.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-new-baaf991bd748200f.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-table-0147792af7b6e2ca.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-view-5e152f00e477a011.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-53ea2331c015449b.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-b56ae759d0e63eff.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults-a79e41b69ca7d7e5.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults/%5Bid%5D-8d123ac53f5ce803.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-78dee88c3e15735a.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-6ea2d13513ddfcb8.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-list-7444bf0ff2adcf19.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-new-ba6d6a3f8515b0d7.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-table-b985f5c250b58ae0.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-view-f4c68e34b1e8903b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-dc1dab1d0d17cd7f.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-9257888337040240.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-list-12773f6237f73890.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-new-2e8763e625895fa4.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-table-cfeb32ab7462cc52.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-view-f8ba1dac35c3b387.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-2b8acadd75b0cc80.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-2ff47ef12c5af864.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-4890011ca9c3a3df.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-0d1a4d21258ac9e3.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-be4bc62f893cb6e7.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-8275d95d8f621ec7.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-94002b63deed1d14.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-f19a2b0e32f8b264.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-1f25acfd1f8ea84b.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-new-967699a276f0fc1f.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-table-b7cb7261af5db570.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-view-65ffccfa3403aaa8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-0179dd292b427e90.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-9e5afbdc298d0452.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-list-ae78031d0263b1b8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-new-393d3dda89d132c0.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-table-b9a9d80285f6ccf5.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-view-ded2ce3f13d768fd.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-1c566d0bbee23701.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-1065d4b022ea646e.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-list-bb2e8266272a34b3.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-new-f88f533479cddf80.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-table-2ae06818d79da9ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-view-bb8dbddc92922eae.js'},{'revision':null,'url':'/_next/static/chunks/pages/search-f2874b266789f090.js'},{'revision':null,'url':'/_next/static/chunks/pages/terms-of-use-7d00698d6183bde4.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/%5Btour_pagesId%5D-c5dc8a35f6edb1ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-fef411d3437c13e0.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-d9b4b468929c770b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-new-59a0c6ab6948843b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-table-20ae6c4ea35a9ffe.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-view-98001111fa9b7868.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-41de512c34ec1310.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-d213af1348769a80.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-list-2185908a5a705b0e.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-new-5b4a2f9ec6e25d41.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-table-584067057f84845c.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-view-d7131a71727f174d.js'},{'revision':null,'url':'/_next/static/chunks/pages/verify-email-3c9a0ef6c6d5daff.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9bfc6282834605db.js'},{'revision':null,'url':'/_next/static/css/70c038b5a5cd26cc.css'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wdth-normal.a718fc63.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wght-normal.7db92424.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wdth-normal.68c3c527.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wght-normal.ae05c57c.woff2'},{'revision':'43778b43fb039fdd5b0510561dc952ac','url':'/assets/vm-shot-2026-03-17T04-16-09-161Z.jpg'},{'revision':'3395d49fe2b96221471b4db0f5caf9e7','url':'/assets/vm-shot-2026-03-17T04-19-03-565Z.jpg'},{'revision':'62127cd8f85821f3e570a377a8a7e14b','url':'/assets/vm-shot-2026-03-17T04-36-56-252Z.jpg'},{'revision':'145c2d7e4ef298391258c6d8a8aaaece','url':'/assets/vm-shot-2026-03-17T04-45-14-111Z.jpg'},{'revision':'c6aae6521f08c847e370764d1c9c613d','url':'/assets/vm-shot-2026-03-17T04-50-58-546Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg'},{'revision':'73de6cac0695249f4e59ce778a8e6e74','url':'/assets/vm-shot-2026-03-19T06-13-19-234Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-13-54-729Z.jpg'},{'revision':'ca6cbfcc74b52f00eef0f2adc8e65456','url':'/assets/vm-shot-2026-03-24T14-29-20-260Z.jpg'},{'revision':'0957c5365895c5ba31b10a84f4e45929','url':'/data-sources/clients.json'},{'revision':'5703d42f7838705ecf87510c4032b20c','url':'/data-sources/history.json'},{'revision':'5404a85badad8210a634ce41bb511545','url':'/favicon.svg'},{'revision':'508520242399a6b1fec65430901f4e6f','url':'/locales/de/common.json'},{'revision':'0a64739b954a93627749ffcb846fceaa','url':'/locales/en/common.json'},{'revision':'34715e25a3bcbab44f84232ce082d2ee','url':'/locales/es/common.json'},{'revision':'772a72f35589c06bdd8d9179c86459e6','url':'/locales/fr/common.json'},{'revision':'c67326edc61e0b5cf87e509ea7553466','url':'/manifest.json'},{'revision':'1254c9c72aa64724c203d26278712800','url':'/offline.html'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:[{matcher:e=>{let{request:t}=e,a=new URL(t.url);return"image"===t.destination||"font"===t.destination||[".css",".js",".woff",".woff2"].some(e=>a.pathname.endsWith(e))},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{request:t}=e;return eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cachedResponseWillBeUsed:async e=>{let{cachedResponse:t,request:a}=e;if(!t)return null;let s=a.headers.get("range");if(!s)return t;let r=s.match(/bytes=(\d+)-(\d*)/);if(!r)return t;let n=parseInt(r[1],10),i=r[2]?parseInt(r[2],10):void 0,c=await t.blob(),o=void 0!==i?c.slice(n,i+1):c.slice(n);return new Response(o,{status:206,statusText:"Partial Content",headers:{"Content-Type":t.headers.get("Content-Type")||"video/mp4","Content-Length":String(o.size),"Content-Range":"bytes ".concat(n,"-").concat(void 0!==i?i:c.size-1,"/").concat(c.size),"Accept-Ranges":"bytes"}})},cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{url:t}=e;return t.pathname.startsWith("/api/")},handler:new X({cacheName:"api-cache",networkTimeoutSeconds:10})},{matcher:e=>{let{request:t}=e;return(e=>{let t=new URL(e.url);return(!t.pathname.startsWith("/api/")||!!t.pathname.includes("/file/download"))&&!!(eN.some(e=>t.pathname.toLowerCase().endsWith(e))||t.pathname.includes("/file/download")||t.hostname.includes("amazonaws.com")||t.hostname.includes("cloudfront.net"))})(t)&&!eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},...eS]});self.addEventListener("message",e=>{let{type:t,payload:a}=e.data||{};switch(t){case"CACHE_ASSETS":Array.isArray(null==a?void 0:a.urls)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>Promise.all(a.urls.map(t=>fetch(t).then(a=>{if(200===a.status)return e.put(t,a)}).catch(e=>{console.warn("[SW] Failed to cache asset:",t,e)})))));break;case"CACHE_VIDEO_CHUNK":(null==a?void 0:a.url)&&(null==a?void 0:a.chunk)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>{let t=new Response(a.chunk,{headers:{"Content-Type":a.contentType||"video/mp4","Content-Length":String(a.chunk.byteLength)}});return e.put(a.url,t)}));break;case"CLEAR_CACHE":e.waitUntil(Promise.all([caches.delete(eq.cacheNames.dynamic),caches.delete(eq.cacheNames.assets)]).then(()=>{console.log("[SW] Caches cleared")}));break;case"GET_CACHE_STATUS":e.waitUntil(caches.open(eq.cacheNames.assets).then(t=>t.keys().then(t=>{let a=e.source;null==a||a.postMessage({type:"CACHE_STATUS",payload:{cachedCount:t.length,urls:t.map(e=>e.url)}})})));break;case"SKIP_WAITING":self.skipWaiting()}}),eD.addEventListeners(),console.log("[SW] Serwist service worker loaded")})(); \ No newline at end of file diff --git a/frontend/src/components/Assets/useAssetUploader.ts b/frontend/src/components/Assets/useAssetUploader.ts index 855dcca..4273274 100644 --- a/frontend/src/components/Assets/useAssetUploader.ts +++ b/frontend/src/components/Assets/useAssetUploader.ts @@ -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, diff --git a/frontend/src/components/Constructor/CanvasElement.tsx b/frontend/src/components/Constructor/CanvasElement.tsx index 165e4d9..3bfbd9d 100644 --- a/frontend/src/components/Constructor/CanvasElement.tsx +++ b/frontend/src/components/Constructor/CanvasElement.tsx @@ -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 = ({ @@ -28,6 +30,7 @@ const CanvasElement: React.FC = ({ onClick, onMouseDown, resolveUrl, + onGalleryCardClick, }) => { return ( ); diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index c80fc3c..bb1957d 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -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) => 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) => 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) && ( - + <> + onUpdateElement(patch)} + onAddInfoSpan={galleryInfoSpans.add} + onUpdateInfoSpan={galleryInfoSpans.update} + onRemoveInfoSpan={galleryInfoSpans.remove} + onAddCard={galleryCards.add} + onUpdateCard={galleryCards.update} + onRemoveCard={galleryCards.remove} + /> + + )} {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) diff --git a/frontend/src/components/DevModeBadge.tsx b/frontend/src/components/DevModeBadge.tsx deleted file mode 100644 index 402d0d0..0000000 --- a/frontend/src/components/DevModeBadge.tsx +++ /dev/null @@ -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({ - position: 'fixed', - bottom: '20px', - left: '70px', - background: 'rgba(0, 0, 0, 0.85)', - color: 'white', - padding: '15px', - borderRadius: '8px', - fontFamily: 'sans-serif', - fontSize: '14px', - lineHeight: '1.5', - textAlign: 'left', - zIndex: 2147483647, - boxShadow: '0 4px 10px rgba(0, 0, 0, 0.3)', - whiteSpace: 'pre-wrap', - transition: - 'width 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), padding 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease-in-out', // Improved transition for width - opacity: 0, - pointerEvents: 'none', - width: '340px', - maxWidth: '340px', - height: 'auto', - overflow: 'hidden', - cursor: 'pointer', - }); - - const fullText = `🚧 Your app is running in development mode. -Current request is compiling and may take a few moments. - -💡 Tip: Set up a stable environment to run your app in production mode—pages will load instantly without compilation delays.`; - - const collapsedText = '🚧 DEV stage'; - - useEffect(() => { - if (compilationStatus === 'ready') { - setIsCollapsed(true); - } else { - setIsCollapsed(false); - } - }, [compilationStatus]); - - useEffect(() => { - if ( - process.env.NODE_ENV === 'development' || - (process.env.NODE_ENV as string) === 'dev_stage' - ) { - setIsVisible(true); - - setBadgeStyles((prev) => ({ - ...prev, - opacity: 1, - width: '120px', - maxWidth: '120px', - padding: '6px 10px', - borderRadius: '18px', - whiteSpace: 'nowrap', - fontSize: '12px', - cursor: 'pointer', - pointerEvents: 'auto', - })); - } else { - setIsVisible(false); - setBadgeStyles((prev) => ({ ...prev, opacity: 0 })); - } - }, []); - - useEffect(() => { - if (!isVisible) return; - - if (isCollapsed) { - setBadgeStyles((prev) => ({ - ...prev, - width: '140px', - maxWidth: '160px', - padding: '6px 20px', - borderRadius: '18px', - whiteSpace: 'nowrap', - fontSize: '12px', - })); - } else { - setBadgeStyles((prev) => ({ - ...prev, - width: '340px', - maxWidth: '340px', - padding: '15px', - borderRadius: '8px', - whiteSpace: 'pre-wrap', - fontSize: '14px', - })); - } - }, [isCollapsed, isVisible]); - - const handleToggleCollapse = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsCollapsed((prev) => !prev); - }; - - if (!isVisible) { - return null; - } - - return ( -
- - - {!isCollapsed &&
{fullText}
} - {isCollapsed && ( -
{collapsedText}
- )} -
- ); -}; - -export default DevModeBadge; diff --git a/frontend/src/components/ElementSettings/CarouselSettingsSection.tsx b/frontend/src/components/ElementSettings/CarouselSettingsSection.tsx index 691e34f..ed4db79 100644 --- a/frontend/src/components/ElementSettings/CarouselSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/CarouselSettingsSection.tsx @@ -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 = ({ carouselPrevIconUrl, carouselNextIconUrl, + carouselCaptionFontFamily, carouselSlides, onAddSlide, onRemoveSlide, @@ -86,6 +88,24 @@ const CarouselSettingsSection: React.FC = ({ )} +
+ + + +
+

Carousel slides

void; onAddSlide: () => void; onUpdateSlide: (slideId: string, patch: Partial) => void; @@ -30,6 +33,7 @@ const CarouselSettingsSectionCompact: React.FC< carouselSlides, carouselPrevIconUrl, carouselNextIconUrl, + carouselCaptionFontFamily, iconAssetOptions, imageAssetOptions, onUpdateElement, @@ -81,6 +85,24 @@ const CarouselSettingsSectionCompact: React.FC< ))} + +
+ + +
diff --git a/frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx b/frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx index 385c762..490533b 100644 --- a/frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/DescriptionSettingsSection.tsx @@ -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 = ({ iconUrl, @@ -85,22 +86,34 @@ const DescriptionSettingsSection: React.FC = ({ /> - onChange('descriptionTitleFontFamily', event.target.value) } - placeholder='e.g. Arial, sans-serif' - /> + > + + {FONT_OPTIONS.map((font) => ( + + ))} + - onChange('descriptionTextFontFamily', event.target.value) } - placeholder='e.g. Arial, sans-serif' - /> + > + + {FONT_OPTIONS.map((font) => ( + + ))} + Title font family - onChange('descriptionTitleFontFamily', event.target.value) } - placeholder='e.g. Arial, Helvetica, sans-serif' - /> + > + + {FONT_OPTIONS.map((font) => ( + + ))} +
- onChange('descriptionTextFontFamily', event.target.value) } - placeholder='e.g. Arial, Helvetica, sans-serif' - /> + > + + {FONT_OPTIONS.map((font) => ( + + ))} +
diff --git a/frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx new file mode 100644 index 0000000..061f664 --- /dev/null +++ b/frontend/src/components/ElementSettings/GalleryCarouselSettingsSectionCompact.tsx @@ -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 ( +
+
+

+ Carousel navigation +

+ + {/* Previous button */} +

Previous

+ + {prevIconUrl && ( +
+ + onUpdateElement({ galleryCarouselPrevWidth: event.target.value }) + } + /> + + onUpdateElement({ galleryCarouselPrevHeight: event.target.value }) + } + /> +
+ )} + + {/* Next button */} +

Next

+ + {nextIconUrl && ( +
+ + onUpdateElement({ galleryCarouselNextWidth: event.target.value }) + } + /> + + onUpdateElement({ galleryCarouselNextHeight: event.target.value }) + } + /> +
+ )} + + {/* Back button */} +

Back

+ + {backIconUrl ? ( +
+ + onUpdateElement({ galleryCarouselBackWidth: event.target.value }) + } + /> + + onUpdateElement({ galleryCarouselBackHeight: event.target.value }) + } + /> +
+ ) : ( + + onUpdateElement({ galleryCarouselBackLabel: event.target.value }) + } + /> + )} + +

+ Set icon + dimensions for navigation-style buttons. Drag to reposition. +

+
+
+ ); +}; + +export default GalleryCarouselSettingsSectionCompact; diff --git a/frontend/src/components/ElementSettings/GallerySettingsSection.tsx b/frontend/src/components/ElementSettings/GallerySettingsSection.tsx index c72b6d7..c070504 100644 --- a/frontend/src/components/ElementSettings/GallerySettingsSection.tsx +++ b/frontend/src/components/ElementSettings/GallerySettingsSection.tsx @@ -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 = ({ galleryCards, + galleryTitleFontFamily, + galleryCardFontFamily, onAddCard, onRemoveCard, onUpdateCard, + onChange, context, imageAssetOptions = [], }) => { @@ -24,6 +28,41 @@ const GallerySettingsSection: React.FC = ({ return ( +

Gallery settings

+ +
+ + + + + + +
+

Gallery cards

void; + onAddInfoSpan: () => void; + onUpdateInfoSpan: (spanId: string, text: string) => void; + onRemoveInfoSpan: (spanId: string) => void; + // Card handlers onAddCard: () => void; onUpdateCard: (cardId: string, patch: Partial) => 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 ( -
+
+ {/* Header Settings */} +
+

Gallery header

+ + + + + onUpdateHeader({ galleryTitle: event.target.value }) + } + /> + +
+ + + onUpdateHeader({ + galleryColumns: Math.max(1, Math.min(6, parseInt(event.target.value) || 3)), + }) + } + /> +
+ +
+ + +
+ +
+ + +
+
+ + {/* Info Spans */} +
+
+

Info spans

+ +
+ + {galleryInfoSpans.map((span, index) => ( +
+ onUpdateInfoSpan(span.id, event.target.value)} + /> + +
+ ))} + + {galleryInfoSpans.length === 0 && ( +

+ Add spans for brief notes (capacity, price, etc.) +

+ )} +
+ + {/* Gallery Cards */}

Gallery cards

diff --git a/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx index b4efe5e..c359040 100644 --- a/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/StyleSettingsSectionCompact.tsx @@ -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 = ({ values, @@ -341,12 +342,30 @@ const StyleSettingsSectionCompact: React.FC = ({ - 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 || ''); + } + } + }} + > + + {FONT_OPTIONS.map((font) => ( + + ))} +
); diff --git a/frontend/src/components/ElementSettings/TooltipSettingsSection.tsx b/frontend/src/components/ElementSettings/TooltipSettingsSection.tsx index 45f9787..89977bd 100644 --- a/frontend/src/components/ElementSettings/TooltipSettingsSection.tsx +++ b/frontend/src/components/ElementSettings/TooltipSettingsSection.tsx @@ -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 = ({ iconUrl, tooltipTitle, tooltipText, + tooltipTitleFontFamily, + tooltipTextFontFamily, onChange, context, iconAssetOptions = [], @@ -57,6 +60,39 @@ const TooltipSettingsSection: React.FC = ({ className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900' /> + +
+ + + + + + +
); }; diff --git a/frontend/src/components/ElementSettings/TooltipSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/TooltipSettingsSectionCompact.tsx index 92ab4d4..b27b470 100644 --- a/frontend/src/components/ElementSettings/TooltipSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/TooltipSettingsSectionCompact.tsx @@ -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 (
@@ -66,6 +77,46 @@ const TooltipSettingsSectionCompact: React.FC< onChange={(event) => onChange('tooltipText', event.target.value)} />
+ +
+ + +
+ +
+ + +
); }; diff --git a/frontend/src/components/ElementSettings/index.ts b/frontend/src/components/ElementSettings/index.ts index df16e08..b64cce8 100644 --- a/frontend/src/components/ElementSettings/index.ts +++ b/frontend/src/components/ElementSettings/index.ts @@ -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'; diff --git a/frontend/src/components/ElementSettings/types.ts b/frontend/src/components/ElementSettings/types.ts index 9f2c14a..012b9d7 100644 --- a/frontend/src/components/ElementSettings/types.ts +++ b/frontend/src/components/ElementSettings/types.ts @@ -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) => 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 */ diff --git a/frontend/src/components/ElementSettings/useElementSettingsForm.ts b/frontend/src/components/ElementSettings/useElementSettingsForm.ts index 4e4ad51..f945827 100644 --- a/frontend/src/components/ElementSettings/useElementSettingsForm.ts +++ b/frontend/src/components/ElementSettings/useElementSettingsForm.ts @@ -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, 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 diff --git a/frontend/src/components/Generic/GenericFormField.tsx b/frontend/src/components/Generic/GenericFormField.tsx index 86428d9..2a4f749 100644 --- a/frontend/src/components/Generic/GenericFormField.tsx +++ b/frontend/src/components/Generic/GenericFormField.tsx @@ -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 = ({ case 'richtext': return ( - + ); diff --git a/frontend/src/components/RichTextField.tsx b/frontend/src/components/RichTextField.tsx deleted file mode 100644 index 29b43a1..0000000 --- a/frontend/src/components/RichTextField.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index ce9eee1..0bcb576 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -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 = ({ element, onClick, resolveUrl, + onGalleryCardClick, }) => { const xPercent = element.xPercent ?? 0; const yPercent = element.yPercent ?? 0; @@ -93,7 +96,11 @@ const RuntimeElement: React.FC = ({ tabIndex={0} {...eventHandlers} > - + ); }; diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index b743ce3..91d6b81 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -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 | 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(null); const lastInitializedPageIdRef = useRef(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) + } /> ))} @@ -485,19 +523,6 @@ export default function RuntimePresentation({ /> - {/* Environment badge */} -
- - {environment.toUpperCase()} - -
- {/* 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({ /> )} + + {/* Gallery Carousel Overlay */} + {activeGalleryCarousel && ( + 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} + /> + )} ); diff --git a/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx b/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx new file mode 100644 index 0000000..d208484 --- /dev/null +++ b/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx @@ -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 = ({ + 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(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 ( + + ); + }; + + const currentCard = cards[currentIndex]; + const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; + + return ( +
{ + // 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 + {currentCard?.title + )} + + {/* 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, + )} + +
+ ); +}; + +export default GalleryCarouselOverlay; diff --git a/frontend/src/components/UiElements/UiElementRenderer.tsx b/frontend/src/components/UiElements/UiElementRenderer.tsx index 3df31fb..458c9f8 100644 --- a/frontend/src/components/UiElements/UiElementRenderer.tsx +++ b/frontend/src/components/UiElements/UiElementRenderer.tsx @@ -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 = ({ isSelected = false, isEditMode = false, isDisabled = false, + onGalleryCardClick, }) => { const { className, style } = useElementWrapperStyle({ element, @@ -73,7 +76,7 @@ export const UiElementRenderer: React.FC = ({ return ; } if (isGalleryElementType(element.type)) { - return ; + return ; } if (isTooltipElementType(element.type)) { return ; diff --git a/frontend/src/components/UiElements/elements/GalleryElement.tsx b/frontend/src/components/UiElements/elements/GalleryElement.tsx index 4f139b8..170549e 100644 --- a/frontend/src/components/UiElements/elements/GalleryElement.tsx +++ b/frontend/src/components/UiElements/elements/GalleryElement.tsx @@ -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 = ({ @@ -22,29 +27,94 @@ const GalleryElement: React.FC = ({ 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 (
-
- {cards.map((card) => ( -
- {card.imageUrl && ( - // eslint-disable-next-line @next/next/no-img-element - {card.title - )} +
+ {/* Header image */} + {headerImageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {/* Title */} + {title && ( +
+ {title}
- ))} + )} + + {/* Info spans */} + {infoSpans.length > 0 && ( +
+ {infoSpans.map((span) => ( +
+ {span.text} +
+ ))} +
+ )} + + {/* Gallery cards */} + {cards.length > 0 && ( +
+ {cards.map((card, index) => ( +
{ + if (onCardClick) { + e.stopPropagation(); + onCardClick(index); + } + }} + > + {card.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {card.title + )} + {card.title && ( +
+ + {card.title} + +
+ )} +
+ ))} +
+ )}
); diff --git a/frontend/src/components/Uploaders/UploadService.js b/frontend/src/components/Uploaders/UploadService.js index 1a191ea..54563fc 100644 --- a/frontend/src/components/Uploaders/UploadService.js +++ b/frontend/src/components/Uploaders/UploadService.js @@ -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}`; } } diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 47c849a..906af83 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -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 || ''; diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 49ab188..4e57dc6 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -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%; +} diff --git a/frontend/src/hooks/useConstructorElements.ts b/frontend/src/hooks/useConstructorElements.ts index 719bf56..306d457 100644 --- a/frontend/src/hooks/useConstructorElements.ts +++ b/frontend/src/hooks/useConstructorElements.ts @@ -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) => 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, diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts index 76ef715..abd9f60 100644 --- a/frontend/src/hooks/usePageSwitch.ts +++ b/frontend/src/hooks/usePageSwitch.ts @@ -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) { diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index 3963224..8faaf4a 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -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) { diff --git a/frontend/src/lib/elementDefaults.ts b/frontend/src/lib/elementDefaults.ts index 934c762..ee53ebf 100644 --- a/frontend/src/lib/elementDefaults.ts +++ b/frontend/src/lib/elementDefaults.ts @@ -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 diff --git a/frontend/src/lib/elementStyles.ts b/frontend/src/lib/elementStyles.ts index a966603..bf4efd0 100644 --- a/frontend/src/lib/elementStyles.ts +++ b/frontend/src/lib/elementStyles.ts @@ -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; /** diff --git a/frontend/src/lib/fonts.ts b/frontend/src/lib/fonts.ts new file mode 100644 index 0000000..31240b3 --- /dev/null +++ b/frontend/src/lib/fonts.ts @@ -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; +} diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index bf5ba3d..73734b7 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -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') && ( - - )} , )} diff --git a/frontend/src/pages/access_logs/[access_logsId].tsx b/frontend/src/pages/access_logs/[access_logsId].tsx index 5c39fe4..3680897 100644 --- a/frontend/src/pages/access_logs/[access_logsId].tsx +++ b/frontend/src/pages/access_logs/[access_logsId].tsx @@ -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'; diff --git a/frontend/src/pages/asset_variants/[asset_variantsId].tsx b/frontend/src/pages/asset_variants/[asset_variantsId].tsx index e5f2e00..765d647 100644 --- a/frontend/src/pages/asset_variants/[asset_variantsId].tsx +++ b/frontend/src/pages/asset_variants/[asset_variantsId].tsx @@ -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'; diff --git a/frontend/src/pages/assets/[assetsId].tsx b/frontend/src/pages/assets/[assetsId].tsx index fa2ac7c..36842cf 100644 --- a/frontend/src/pages/assets/[assetsId].tsx +++ b/frontend/src/pages/assets/[assetsId].tsx @@ -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'; diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index ede9385..c7efa4f 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -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(() => { @@ -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 && ( + 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} + /> + )} +