simplified "go back" functionality

This commit is contained in:
Dmitri 2026-04-14 13:17:31 +04:00
parent 5035788ffc
commit 0987c87c1d
30 changed files with 380 additions and 313 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -18,7 +18,8 @@ const config = {
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7', prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
// Timeout configuration (in milliseconds) // Timeout configuration (in milliseconds)
connectionTimeout: parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000, connectionTimeout:
parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000,
requestTimeout: parseInt(process.env.AWS_S3_REQUEST_TIMEOUT, 10) || 30000, requestTimeout: parseInt(process.env.AWS_S3_REQUEST_TIMEOUT, 10) || 30000,
// Retry configuration // Retry configuration
maxAttempts: parseInt(process.env.AWS_S3_MAX_ATTEMPTS, 10) || 3, maxAttempts: parseInt(process.env.AWS_S3_MAX_ATTEMPTS, 10) || 3,
@ -26,7 +27,8 @@ const config = {
maxSockets: parseInt(process.env.AWS_S3_MAX_SOCKETS, 10) || 50, maxSockets: parseInt(process.env.AWS_S3_MAX_SOCKETS, 10) || 50,
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false', keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false',
// Presigned URL expiry (in seconds) // Presigned URL expiry (in seconds)
presignExpirySeconds: parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600, presignExpirySeconds:
parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600,
}, },
bcrypt: { bcrypt: {
saltRounds: 12, saltRounds: 12,

View File

@ -131,7 +131,9 @@ class GenericDBApi {
} }
// Apply custom transformers // Apply custom transformers
for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) { for (const [field, transformer] of Object.entries(
this.FIELD_TRANSFORMERS,
)) {
if (mapped[field] !== undefined) { if (mapped[field] !== undefined) {
mapped[field] = transformer(mapped[field]); mapped[field] = transformer(mapped[field]);
} }

View File

@ -35,14 +35,7 @@ class ProjectsDBApi extends GenericDBApi {
} }
static get CSV_FIELDS() { static get CSV_FIELDS() {
return [ return ['id', 'name', 'slug', 'description', 'logo_url', 'createdAt'];
'id',
'name',
'slug',
'description',
'logo_url',
'createdAt',
];
} }
static get AUTOCOMPLETE_FIELD() { static get AUTOCOMPLETE_FIELD() {
@ -63,7 +56,8 @@ class ProjectsDBApi extends GenericDBApi {
favicon_url: data.favicon_url || null, favicon_url: data.favicon_url || null,
og_image_url: data.og_image_url || null, og_image_url: data.og_image_url || null,
design_width: data.design_width !== undefined ? data.design_width : null, design_width: data.design_width !== undefined ? data.design_width : null,
design_height: data.design_height !== undefined ? data.design_height : null, design_height:
data.design_height !== undefined ? data.design_height : null,
}; };
} }

View File

@ -94,8 +94,7 @@ class Tour_pagesDBApi extends GenericDBApi {
data.background_video_end_time !== undefined data.background_video_end_time !== undefined
? data.background_video_end_time ? data.background_video_end_time
: null, : null,
design_width: design_width: data.design_width !== undefined ? data.design_width : null,
data.design_width !== undefined ? data.design_width : null,
design_height: design_height:
data.design_height !== undefined ? data.design_height : null, data.design_height !== undefined ? data.design_height : null,
requires_auth: data.requires_auth || false, requires_auth: data.requires_auth || false,

View File

@ -60,6 +60,8 @@ module.exports = {
async down(_queryInterface, _Sequelize) { async down(_queryInterface, _Sequelize) {
// Cannot restore deleted duplicates // Cannot restore deleted duplicates
console.log('Down migration not applicable - duplicates cannot be restored.'); console.log(
'Down migration not applicable - duplicates cannot be restored.',
);
}, },
}; };

View File

@ -63,9 +63,7 @@ module.exports = {
}, },
); );
console.log( console.log(`Deleted ${idsToDelete.length} invalid element_type_defaults.`);
`Deleted ${idsToDelete.length} invalid element_type_defaults.`,
);
console.log( console.log(
`Deleted ${deletedProjectDefaults.length} invalid project_element_defaults.`, `Deleted ${deletedProjectDefaults.length} invalid project_element_defaults.`,
); );

View File

@ -18,11 +18,15 @@ module.exports = {
allowNull: false, allowNull: false,
defaultValue: true, defaultValue: true,
}); });
await queryInterface.addColumn('tour_pages', 'background_video_start_time', { await queryInterface.addColumn(
type: Sequelize.DECIMAL(10, 1), 'tour_pages',
allowNull: true, 'background_video_start_time',
defaultValue: null, {
}); type: Sequelize.DECIMAL(10, 1),
allowNull: true,
defaultValue: null,
},
);
await queryInterface.addColumn('tour_pages', 'background_video_end_time', { await queryInterface.addColumn('tour_pages', 'background_video_end_time', {
type: Sequelize.DECIMAL(10, 1), type: Sequelize.DECIMAL(10, 1),
allowNull: true, allowNull: true,
@ -31,10 +35,19 @@ module.exports = {
}, },
async down(queryInterface, _Sequelize) { async down(queryInterface, _Sequelize) {
await queryInterface.removeColumn('tour_pages', 'background_video_autoplay'); await queryInterface.removeColumn(
'tour_pages',
'background_video_autoplay',
);
await queryInterface.removeColumn('tour_pages', 'background_video_loop'); await queryInterface.removeColumn('tour_pages', 'background_video_loop');
await queryInterface.removeColumn('tour_pages', 'background_video_muted'); await queryInterface.removeColumn('tour_pages', 'background_video_muted');
await queryInterface.removeColumn('tour_pages', 'background_video_start_time'); await queryInterface.removeColumn(
await queryInterface.removeColumn('tour_pages', 'background_video_end_time'); 'tour_pages',
'background_video_start_time',
);
await queryInterface.removeColumn(
'tour_pages',
'background_video_end_time',
);
}, },
}; };

View File

@ -27,11 +27,17 @@ router.post('/presign', jsonParser, async (req, res) => {
const { urls } = req.body || {}; const { urls } = req.body || {};
if (!Array.isArray(urls) || urls.length === 0) { if (!Array.isArray(urls) || urls.length === 0) {
return res.status(400).json(createErrorResponse('urls array required', 'MISSING_URLS')); return res
.status(400)
.json(createErrorResponse('urls array required', 'MISSING_URLS'));
} }
if (urls.length > 50) { if (urls.length > 50) {
return res.status(400).json(createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS')); return res
.status(400)
.json(
createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'),
);
} }
// Validate that all URLs are non-empty strings // Validate that all URLs are non-empty strings
@ -39,24 +45,43 @@ router.post('/presign', jsonParser, async (req, res) => {
(url) => typeof url !== 'string' || !url.trim(), (url) => typeof url !== 'string' || !url.trim(),
); );
if (invalidUrls.length > 0) { if (invalidUrls.length > 0) {
return res.status(400).json(createErrorResponse('All URLs must be non-empty strings', 'INVALID_URL_FORMAT')); return res
.status(400)
.json(
createErrorResponse(
'All URLs must be non-empty strings',
'INVALID_URL_FORMAT',
),
);
} }
// Validate paths for security (no traversal, no protocols) // Validate paths for security (no traversal, no protocols)
const unsafeUrls = urls.filter((url) => !isValidPath(url)); const unsafeUrls = urls.filter((url) => !isValidPath(url));
if (unsafeUrls.length > 0) { if (unsafeUrls.length > 0) {
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected'); log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
return res.status(400).json(createErrorResponse('Invalid file paths detected', 'INVALID_PATH', { return res.status(400).json(
invalidPaths: unsafeUrls, createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
})); invalidPaths: unsafeUrls,
}),
);
} }
try { try {
const presignedUrls = await services.generatePresignedUrls(urls); const presignedUrls = await services.generatePresignedUrls(urls);
res.json({ presignedUrls }); res.json({ presignedUrls });
} catch (error) { } catch (error) {
log.error({ err: error, urlCount: urls.length }, 'Failed to generate presigned URLs'); log.error(
res.status(500).json(createErrorResponse('Failed to generate presigned URLs', 'PRESIGN_ERROR')); { err: error, urlCount: urls.length },
'Failed to generate presigned URLs',
);
res
.status(500)
.json(
createErrorResponse(
'Failed to generate presigned URLs',
'PRESIGN_ERROR',
),
);
} }
}); });

View File

@ -13,7 +13,7 @@ const router = createEntityRouter('users', UsersService, UsersDBApi, {
// Note: This needs to be added BEFORE the router is exported // Note: This needs to be added BEFORE the router is exported
// The factory already registered this route, so we add middleware to sanitize // The factory already registered this route, so we add middleware to sanitize
const originalGetById = router.stack.find( const originalGetById = router.stack.find(
(layer) => layer.route?.path === '/:id' && layer.route?.methods?.get (layer) => layer.route?.path === '/:id' && layer.route?.methods?.get,
); );
if (originalGetById) { if (originalGetById) {

View File

@ -93,12 +93,14 @@ class AssetsService extends BaseService {
// Pre-generate reversed video for video assets (async, doesn't block response) // Pre-generate reversed video for video assets (async, doesn't block response)
if (assetType === 'video' && data.storage_key) { if (assetType === 'video' && data.storage_key) {
AssetsService.preGenerateReversedVideo(asset, currentUser).catch((err) => { AssetsService.preGenerateReversedVideo(asset, currentUser).catch(
logger.error( (err) => {
{ err, assetId: asset.id }, logger.error(
'Failed to pre-generate reversed video (non-blocking)', { err, assetId: asset.id },
); 'Failed to pre-generate reversed video (non-blocking)',
}); );
},
);
} }
return asset; return asset;
@ -131,7 +133,9 @@ class AssetsService extends BaseService {
// Check if reversed variant already exists (shouldn't happen on create, but safety check) // Check if reversed variant already exists (shouldn't happen on create, but safety check)
const existingAsset = await AssetsDBApi.findBy({ id: asset.id }); const existingAsset = await AssetsDBApi.findBy({ id: asset.id });
const variants = existingAsset?.asset_variants_asset || []; const variants = existingAsset?.asset_variants_asset || [];
const existingReversed = variants.find((v) => v.variant_type === 'reversed'); const existingReversed = variants.find(
(v) => v.variant_type === 'reversed',
);
if (existingReversed) { if (existingReversed) {
log.debug('Reversed variant already exists'); log.debug('Reversed variant already exists');
@ -172,7 +176,10 @@ class AssetsService extends BaseService {
); );
log.info( log.info(
{ reversedKey, sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2) }, {
reversedKey,
sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2),
},
'Pre-generated reversed video successfully', 'Pre-generated reversed video successfully',
); );

View File

@ -33,18 +33,25 @@ let gcloudHash = null;
let uploadSessionManager = null; let uploadSessionManager = null;
const getFileStorageProvider = () => { const getFileStorageProvider = () => {
const provider = (process.env.FILE_STORAGE_PROVIDER || '').trim().toLowerCase(); const provider = (process.env.FILE_STORAGE_PROVIDER || '')
.trim()
.toLowerCase();
if (provider) return provider; if (provider) return provider;
const hasS3 = Boolean( const hasS3 = Boolean(
config.s3.bucket && config.s3.region && config.s3.bucket &&
config.s3.accessKeyId && config.s3.secretAccessKey config.s3.region &&
config.s3.accessKeyId &&
config.s3.secretAccessKey,
); );
if (hasS3) return 's3'; if (hasS3) return 's3';
const hasGCloud = Boolean( const hasGCloud = Boolean(
process.env.GC_PROJECT_ID && process.env.GC_CLIENT_EMAIL && process.env.GC_PROJECT_ID &&
process.env.GC_PRIVATE_KEY && config.gcloud.bucket && config.gcloud.hash process.env.GC_CLIENT_EMAIL &&
process.env.GC_PRIVATE_KEY &&
config.gcloud.bucket &&
config.gcloud.hash,
); );
if (hasGCloud) return 'gcloud'; if (hasGCloud) return 'gcloud';
@ -67,14 +74,17 @@ const getS3Provider = () => {
keepAlive: config.s3.keepAlive, keepAlive: config.s3.keepAlive,
}); });
logger.info({ logger.info(
provider: 's3', {
bucket: config.s3.bucket, provider: 's3',
region: config.s3.region, bucket: config.s3.bucket,
connectionTimeout: config.s3.connectionTimeout, region: config.s3.region,
requestTimeout: config.s3.requestTimeout, connectionTimeout: config.s3.connectionTimeout,
maxAttempts: config.s3.maxAttempts, requestTimeout: config.s3.requestTimeout,
}, 'S3 storage provider initialized'); maxAttempts: config.s3.maxAttempts,
},
'S3 storage provider initialized',
);
} }
return s3Provider; return s3Provider;
}; };
@ -144,7 +154,11 @@ const getErrorMessage = (error, operation = 'process') => {
const errorName = error?.name || ''; const errorName = error?.name || '';
const errorCode = error?.code || ''; const errorCode = error?.code || '';
if (errorName === 'NoSuchKey' || errorName === 'NotFound' || errorName === 'NoSuchBucket') { if (
errorName === 'NoSuchKey' ||
errorName === 'NotFound' ||
errorName === 'NoSuchBucket'
) {
return 'File not found'; return 'File not found';
} }
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') { if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
@ -203,17 +217,25 @@ const uploadFile = async (folder, req, res) => {
const processFile = require('../middlewares/upload'); const processFile = require('../middlewares/upload');
await processFile(req, res); await processFile(req, res);
if (!req.file) return res.status(400).send(createErrorResponse('Please upload a file!', 'MISSING_FILE')); if (!req.file)
return res
.status(400)
.send(createErrorResponse('Please upload a file!', 'MISSING_FILE'));
const filename = req.body.filename; const filename = req.body.filename;
if (!filename) return res.status(400).send(createErrorResponse('Missing filename', 'MISSING_FILENAME')); if (!filename)
return res
.status(400)
.send(createErrorResponse('Missing filename', 'MISSING_FILENAME'));
const privateUrl = `${folder}/${filename}`; const privateUrl = `${folder}/${filename}`;
let publicUrl = ''; let publicUrl = '';
if (provider === 's3') { if (provider === 's3') {
const s3 = getS3Provider(); const s3 = getS3Provider();
const result = await s3.upload(privateUrl, req.file.buffer, { contentType: req.file.mimetype }); const result = await s3.upload(privateUrl, req.file.buffer, {
contentType: req.file.mimetype,
});
publicUrl = result.url; publicUrl = result.url;
} else if (provider === 'gcloud') { } else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket(); const { bucket, hash } = getGCloudBucket();
@ -225,7 +247,9 @@ const uploadFile = async (folder, req, res) => {
blobStream.on('finish', resolve); blobStream.on('finish', resolve);
blobStream.end(req.file.buffer); blobStream.end(req.file.buffer);
}); });
publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`,
);
} else { } else {
const local = getLocalProvider(); const local = getLocalProvider();
await local.upload(privateUrl, req.file.buffer); await local.upload(privateUrl, req.file.buffer);
@ -240,7 +264,14 @@ const uploadFile = async (folder, req, res) => {
}); });
} catch (error) { } catch (error) {
log.error({ err: error, provider }, 'Failed to upload file'); 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')); return res
.status(500)
.send(
createErrorResponse(
`Could not upload the file. ${error.message || error}`,
'UPLOAD_ERROR',
),
);
} }
}; };
@ -249,12 +280,22 @@ const downloadFile = async (req, res) => {
const privateUrl = req.query.privateUrl; const privateUrl = req.query.privateUrl;
const log = req.log || logger; const log = req.log || logger;
if (!privateUrl) return res.status(400).send(createErrorResponse('Missing privateUrl parameter', 'MISSING_PARAMETER')); if (!privateUrl)
return res
.status(400)
.send(
createErrorResponse(
'Missing privateUrl parameter',
'MISSING_PARAMETER',
),
);
// Validate path // Validate path
if (!isValidPath(privateUrl)) { if (!isValidPath(privateUrl)) {
log.warn({ privateUrl }, 'Invalid file path requested'); log.warn({ privateUrl }, 'Invalid file path requested');
return res.status(400).send(createErrorResponse('Invalid file path', 'INVALID_PATH')); return res
.status(400)
.send(createErrorResponse('Invalid file path', 'INVALID_PATH'));
} }
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@ -279,7 +320,8 @@ const downloadFile = async (req, res) => {
const result = await s3.download(privateUrl, { signal }); const result = await s3.download(privateUrl, { signal });
if (result.contentType) res.setHeader('Content-Type', result.contentType); if (result.contentType) res.setHeader('Content-Type', result.contentType);
if (result.contentLength) res.setHeader('Content-Length', result.contentLength); if (result.contentLength)
res.setHeader('Content-Length', result.contentLength);
if (typeof result.body.pipe === 'function') { if (typeof result.body.pipe === 'function') {
result.body.pipe(res); result.body.pipe(res);
@ -290,7 +332,10 @@ const downloadFile = async (req, res) => {
res.send(result.body); res.send(result.body);
} }
log.debug({ provider, privateUrl, duration: Date.now() - startTime }, 'File downloaded'); log.debug(
{ provider, privateUrl, duration: Date.now() - startTime },
'File downloaded',
);
} else if (provider === 'gcloud') { } else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket(); const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`); const file = bucket.file(`${hash}/${privateUrl}`);
@ -298,7 +343,9 @@ const downloadFile = async (req, res) => {
if (exists) { if (exists) {
file.createReadStream().pipe(res); file.createReadStream().pipe(res);
} else { } else {
res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND')); res
.status(404)
.send(createErrorResponse('File not found', 'NOT_FOUND'));
} }
} else { } else {
res.download(path.join(config.uploadDir, privateUrl)); res.download(path.join(config.uploadDir, privateUrl));
@ -316,17 +363,24 @@ const downloadFile = async (req, res) => {
const statusCode = provider === 's3' ? getS3ErrorStatusCode(error) : 500; const statusCode = provider === 's3' ? getS3ErrorStatusCode(error) : 500;
const errorMessage = getErrorMessage(error, 'download'); const errorMessage = getErrorMessage(error, 'download');
log.error({ log.error(
err: error, {
provider, err: error,
privateUrl, provider,
statusCode, privateUrl,
errorName: error?.name, statusCode,
errorCode: error?.code, errorName: error?.name,
}, 'Failed to download file'); errorCode: error?.code,
},
'Failed to download file',
);
if (!res.headersSent) { if (!res.headersSent) {
return res.status(statusCode).send(createErrorResponse(errorMessage, error?.name || 'DOWNLOAD_ERROR')); return res
.status(statusCode)
.send(
createErrorResponse(errorMessage, error?.name || 'DOWNLOAD_ERROR'),
);
} }
} }
}; };
@ -339,7 +393,8 @@ const downloadFile = async (req, res) => {
* @returns {Promise<{ success: boolean, error?: Error }>} * @returns {Promise<{ success: boolean, error?: Error }>}
*/ */
const deleteFile = async (privateUrl, options = {}) => { const deleteFile = async (privateUrl, options = {}) => {
if (!privateUrl) return { success: false, error: new Error('Missing privateUrl') }; if (!privateUrl)
return { success: false, error: new Error('Missing privateUrl') };
const { throwOnError = false } = options; const { throwOnError = false } = options;
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
@ -435,11 +490,15 @@ const uploadBuffer = async (privateUrl, buffer, options = {}) => {
blobStream.on('finish', resolve); blobStream.on('finish', resolve);
blobStream.end(buffer); blobStream.end(buffer);
}); });
return { url: `https://storage.googleapis.com/${bucket.name}/${blob.name}` }; return {
url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`,
};
} else { } else {
const local = getLocalProvider(); const local = getLocalProvider();
await local.upload(privateUrl, buffer); await local.upload(privateUrl, buffer);
return { url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}` }; return {
url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`,
};
} }
}; };
@ -448,13 +507,15 @@ const uploadBuffer = async (privateUrl, buffer, options = {}) => {
// ============================================================================ // ============================================================================
const sanitizeFolder = (folder) => { const sanitizeFolder = (folder) => {
const value = String(folder || '').trim().replace(/^\/+|\/+$/g, ''); const value = String(folder || '')
return (!value || value.includes('..')) ? null : value; .trim()
.replace(/^\/+|\/+$/g, '');
return !value || value.includes('..') ? null : value;
}; };
const sanitizeFilename = (filename) => { const sanitizeFilename = (filename) => {
const value = path.basename(String(filename || '').trim()); const value = path.basename(String(filename || '').trim());
return (!value || value === '.' || value === '..') ? null : value; return !value || value === '.' || value === '..' ? null : value;
}; };
const initUploadSession = async (req, res) => { const initUploadSession = async (req, res) => {
@ -472,9 +533,20 @@ const initUploadSession = async (req, res) => {
const size = Number(req.body?.size); const size = Number(req.body?.size);
const contentType = String(req.body?.contentType || '').trim(); const contentType = String(req.body?.contentType || '').trim();
if (!folder || !filename) return res.status(400).send(createErrorResponse('Invalid folder or filename', 'INVALID_INPUT')); if (!folder || !filename)
if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send(createErrorResponse('Invalid totalChunks', 'INVALID_INPUT')); return res
if (!Number.isFinite(size) || size < 0) return res.status(400).send(createErrorResponse('Invalid file size', 'INVALID_INPUT')); .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({ const sessionId = sessionManager.createSession({
userId: req.currentUser.id, userId: req.currentUser.id,
@ -485,7 +557,10 @@ const initUploadSession = async (req, res) => {
contentType, contentType,
}); });
log.info({ sessionId, folder, filename, totalChunks, size }, 'Upload session initialized'); log.info(
{ sessionId, folder, filename, totalChunks, size },
'Upload session initialized',
);
return res.status(200).send({ return res.status(200).send({
sessionId, sessionId,
@ -494,7 +569,14 @@ const initUploadSession = async (req, res) => {
}); });
} catch (error) { } catch (error) {
log.error({ err: error }, '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')); return res
.status(500)
.send(
createErrorResponse(
'Failed to initialize upload session',
'SESSION_INIT_ERROR',
),
);
} }
}; };
@ -506,7 +588,12 @@ const getUploadSession = async (req, res) => {
const sessionManager = getUploadSessionManager(); const sessionManager = getUploadSessionManager();
const session = sessionManager.readMeta(sessionId); const session = sessionManager.readMeta(sessionId);
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); if (!session)
return res
.status(404)
.send(
createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'),
);
if (session.userId !== req.currentUser.id) return res.sendStatus(403); if (session.userId !== req.currentUser.id) return res.sendStatus(403);
return res.status(200).send({ return res.status(200).send({
@ -518,7 +605,14 @@ const getUploadSession = async (req, res) => {
} catch (error) { } catch (error) {
const log = req.log || logger; const log = req.log || logger;
log.error({ err: error }, 'Failed to get upload session'); log.error({ err: error }, 'Failed to get upload session');
return res.status(500).send(createErrorResponse('Failed to get upload session', 'SESSION_GET_ERROR')); return res
.status(500)
.send(
createErrorResponse(
'Failed to get upload session',
'SESSION_GET_ERROR',
),
);
} }
}; };
@ -532,15 +626,27 @@ const uploadChunk = async (req, res) => {
const chunkIndex = Number(req.params.chunkIndex); const chunkIndex = Number(req.params.chunkIndex);
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) { if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
return res.status(400).send(createErrorResponse('Invalid chunk index', 'INVALID_INPUT')); return res
.status(400)
.send(createErrorResponse('Invalid chunk index', 'INVALID_INPUT'));
} }
const sessionManager = getUploadSessionManager(); const sessionManager = getUploadSessionManager();
const session = sessionManager.readMeta(sessionId); const session = sessionManager.readMeta(sessionId);
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); if (!session)
return res
.status(404)
.send(
createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'),
);
if (session.userId !== req.currentUser.id) return res.sendStatus(403); if (session.userId !== req.currentUser.id) return res.sendStatus(403);
if (chunkIndex >= session.totalChunks) return res.status(400).send(createErrorResponse('Chunk index is out of range', 'INVALID_INPUT')); if (chunkIndex >= session.totalChunks)
return res
.status(400)
.send(
createErrorResponse('Chunk index is out of range', 'INVALID_INPUT'),
);
// Collect chunk data // Collect chunk data
const chunks = []; const chunks = [];
@ -558,7 +664,11 @@ const uploadChunk = async (req, res) => {
}); });
} catch (error) { } catch (error) {
log.error({ err: error }, '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')); return res
.status(500)
.send(
createErrorResponse('Failed to upload chunk', 'CHUNK_UPLOAD_ERROR'),
);
} }
}; };
@ -572,18 +682,32 @@ const finalizeUploadSession = async (req, res) => {
const sessionManager = getUploadSessionManager(); const sessionManager = getUploadSessionManager();
const session = sessionManager.readMeta(sessionId); const session = sessionManager.readMeta(sessionId);
if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); if (!session)
return res
.status(404)
.send(
createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'),
);
if (session.userId !== req.currentUser.id) return res.sendStatus(403); if (session.userId !== req.currentUser.id) return res.sendStatus(403);
// Verify all chunks exist // Verify all chunks exist
for (let i = 0; i < session.totalChunks; i++) { for (let i = 0; i < session.totalChunks; i++) {
if (!sessionManager.chunkExists(sessionId, i)) { if (!sessionManager.chunkExists(sessionId, i)) {
return res.status(400).send(createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', { missingChunk: i })); return res
.status(400)
.send(
createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', {
missingChunk: i,
}),
);
} }
} }
// Assemble file to temp location // Assemble file to temp location
const assembledPath = path.join(config.uploadDir, `assembled_${sessionId}_${Date.now()}`); const assembledPath = path.join(
config.uploadDir,
`assembled_${sessionId}_${Date.now()}`,
);
await sessionManager.assembleChunks(sessionId, assembledPath); await sessionManager.assembleChunks(sessionId, assembledPath);
const privateUrl = `${session.folder}/${session.filename}`; const privateUrl = `${session.folder}/${session.filename}`;
@ -594,13 +718,20 @@ const finalizeUploadSession = async (req, res) => {
if (provider === 's3') { if (provider === 's3') {
const s3 = getS3Provider(); const s3 = getS3Provider();
const data = fs.readFileSync(assembledPath); const data = fs.readFileSync(assembledPath);
const result = await s3.upload(privateUrl, data, { contentType: session.contentType }); const result = await s3.upload(privateUrl, data, {
contentType: session.contentType,
});
publicUrl = result.url; publicUrl = result.url;
} else if (provider === 'gcloud') { } else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket(); const { bucket, hash } = getGCloudBucket();
const blob = bucket.file(`${hash}/${privateUrl}`); const blob = bucket.file(`${hash}/${privateUrl}`);
await pipeline(fs.createReadStream(assembledPath), blob.createWriteStream({ resumable: false })); await pipeline(
publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); fs.createReadStream(assembledPath),
blob.createWriteStream({ resumable: false }),
);
publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`,
);
} else { } else {
const local = getLocalProvider(); const local = getLocalProvider();
const data = fs.readFileSync(assembledPath); const data = fs.readFileSync(assembledPath);
@ -624,7 +755,14 @@ const finalizeUploadSession = async (req, res) => {
}); });
} catch (error) { } catch (error) {
log.error({ err: error }, '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')); return res
.status(500)
.send(
createErrorResponse(
'Failed to finalize upload session',
'SESSION_FINALIZE_ERROR',
),
);
} }
}; };
@ -651,7 +789,7 @@ const generatePresignedUrls = async (urls) => {
await Promise.all( await Promise.all(
urls.map(async (url) => { urls.map(async (url) => {
presignedUrls[url] = await s3.getSignedUrl(url, expirySeconds); presignedUrls[url] = await s3.getSignedUrl(url, expirySeconds);
}) }),
); );
return presignedUrls; return presignedUrls;

View File

@ -76,7 +76,10 @@ class UploadSessionManager {
* Get chunk file path * Get chunk file path
*/ */
getChunkPath(sessionId, chunkIndex) { getChunkPath(sessionId, chunkIndex) {
return path.join(this.getChunksDir(sessionId), `${String(chunkIndex)}.part`); return path.join(
this.getChunksDir(sessionId),
`${String(chunkIndex)}.part`,
);
} }
/** /**
@ -238,7 +241,9 @@ class UploadSessionManager {
return; return;
} }
const updatedAt = new Date(meta.updatedAt || meta.createdAt || 0).getTime(); const updatedAt = new Date(
meta.updatedAt || meta.createdAt || 0,
).getTime();
if (!updatedAt || now - updatedAt > this.ttlMs) { if (!updatedAt || now - updatedAt > this.ttlMs) {
this.removeSession(sessionId); this.removeSession(sessionId);
} }

View File

@ -2,11 +2,7 @@
* Tour Pages Service * Tour Pages Service
* *
* Extends the factory service with reversed video generation for back navigation transitions. * Extends the factory service with reversed video generation for back navigation transitions.
* * Reversed videos are always generated for forward navigation elements to support back navigation.
* Supports two back navigation modes:
* 1. target_page: Back button has its own transitionVideoUrl - reversed video generated for it
* 2. history: Back button uses forward element from previous page - reversed video generated
* for forward elements when project has any history-mode back buttons
*/ */
const Tour_pagesDBApi = require('../db/api/tour_pages'); const Tour_pagesDBApi = require('../db/api/tour_pages');
@ -17,8 +13,6 @@ const { downloadToBuffer, uploadBuffer } = require('./file');
const videoProcessing = require('./videoProcessing'); const videoProcessing = require('./videoProcessing');
const { logger } = require('../utils/logger'); const { logger } = require('../utils/logger');
// Cache for project history-mode status (cleared per request cycle)
const projectHistoryModeCache = new Map();
const projectRegenInProgress = new Set(); const projectRegenInProgress = new Set();
const singleReverseGenerationInProgress = new Set(); const singleReverseGenerationInProgress = new Set();
const reverseGenerationPromiseByStorageKey = new Map(); const reverseGenerationPromiseByStorageKey = new Map();
@ -37,10 +31,11 @@ class TourPagesService extends BaseService {
*/ */
static async create(data, currentUser) { static async create(data, currentUser) {
// Process reversed videos and get updated ui_schema_json // Process reversed videos and get updated ui_schema_json
const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( const updatedData =
data, await TourPagesService.processReversedVideosAndUpdateSchema(
currentUser, data,
); currentUser,
);
return super.create(updatedData, currentUser); return super.create(updatedData, currentUser);
} }
@ -51,13 +46,15 @@ class TourPagesService extends BaseService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
// Fetch existing page to get projectId (not included in update request body) // Fetch existing page to get projectId (not included in update request body)
const existingPage = await Tour_pagesDBApi.findBy({ id }); const existingPage = await Tour_pagesDBApi.findBy({ id });
const projectId = existingPage?.projectId || data.projectId || data.project_id; const projectId =
existingPage?.projectId || data.projectId || data.project_id;
// Process reversed videos and get updated ui_schema_json // Process reversed videos and get updated ui_schema_json
const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( const updatedData =
{ ...data, projectId, id }, await TourPagesService.processReversedVideosAndUpdateSchema(
currentUser, { ...data, projectId, id },
); currentUser,
);
return super.update(updatedData, id, currentUser); return super.update(updatedData, id, currentUser);
} }
@ -88,94 +85,16 @@ class TourPagesService extends BaseService {
return isForward && hasTarget && element.transitionVideoUrl; return isForward && hasTarget && element.transitionVideoUrl;
} }
/**
* Check if any page in the project has a history-mode back button.
* Uses per-request caching to avoid repeated queries.
*
* @param {string} projectId - Project ID
* @returns {Promise<boolean>}
*/
static async projectHasHistoryModeBackButton(projectId) {
if (!projectId) return false;
// Check cache first
if (projectHistoryModeCache.has(projectId)) {
return projectHistoryModeCache.get(projectId);
}
const { rows: pages } = await Tour_pagesDBApi.findAll(
{ projectId },
{ attributes: ['id', 'ui_schema_json'] },
);
logger.info(
{ projectId, pageCount: pages.length },
'Checking project for history-mode back buttons',
);
for (const page of pages) {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json || '{}')
: page.ui_schema_json || {};
if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) continue;
for (const element of uiSchema.elements) {
const isBack = TourPagesService.isBackElement(element);
logger.debug(
{
pageId: page.id,
elementType: element.type,
navType: element.navType,
navBackMode: element.navBackMode,
isBack,
},
'Checking element for history mode',
);
if (isBack && element.navBackMode === 'history') {
logger.info(
{ pageId: page.id, elementType: element.type },
'Found history-mode back button',
);
projectHistoryModeCache.set(projectId, true);
return true;
}
}
}
logger.info({ projectId }, 'No history-mode back buttons found in project');
projectHistoryModeCache.set(projectId, false);
return false;
}
/**
* Clear project cache (call at end of request or after project changes)
* @param {string} projectId - Project ID to clear, or undefined to clear all
*/
static clearProjectCache(projectId) {
if (projectId) {
projectHistoryModeCache.delete(projectId);
} else {
projectHistoryModeCache.clear();
}
}
/** /**
* Process reversed videos and update ui_schema_json with reversed URLs. * Process reversed videos and update ui_schema_json with reversed URLs.
* Returns data with updated ui_schema_json. * Returns data with updated ui_schema_json.
* *
* Handles two cases: * Generates reversed videos for all navigation elements with transitions.
* 1. Back navigation elements with transitionVideoUrl - always generate reversed
* 2. Forward navigation elements when project uses history-mode back navigation
* *
* @param {Object} data - Page data with ui_schema_json * @param {Object} data - Page data with ui_schema_json
* @param {Object} currentUser - Current user for permissions * @param {Object} currentUser - Current user for permissions
* @param {Object} options - Processing options * @param {Object} options - Processing options
* @param {boolean} options._forceForwardReversed - Force processing forward elements * @param {boolean} options._skipHistoryModeCheck - Skip initial regeneration check
* @param {boolean} options._skipHistoryModeCheck - Skip initial history mode check
*/ */
static async processReversedVideosAndUpdateSchema( static async processReversedVideosAndUpdateSchema(
data, data,
@ -202,43 +121,9 @@ class TourPagesService extends BaseService {
// Get project ID // Get project ID
const projectId = data.projectId || data.project_id || data.project; const projectId = data.projectId || data.project_id || data.project;
// Check if this page being saved has a history-mode back button
// This is used to trigger regeneration of other pages' forward elements
let thisPageHasHistoryMode = false;
for (const element of uiSchema.elements) {
if (
TourPagesService.isBackElement(element) &&
element.navBackMode === 'history'
) {
thisPageHasHistoryMode = true;
break;
}
}
// Check if project already has history-mode back buttons (from other pages)
// Skip this check if we're in regeneration mode to avoid recursion
let projectHasHistoryMode = options._forceForwardReversed || false;
if (!projectHasHistoryMode && projectId && !options._skipHistoryModeCheck) {
// Invalidate cache since we're about to save changes
TourPagesService.clearProjectCache(projectId);
projectHasHistoryMode =
await TourPagesService.projectHasHistoryModeBackButton(projectId);
}
// Combined check: process forward elements if project uses history mode OR this page enables it
const shouldProcessForward =
projectHasHistoryMode || thisPageHasHistoryMode;
logger.info( logger.info(
{ { projectId },
thisPageHasHistoryMode, 'Processing reversed videos for navigation elements',
projectHasHistoryMode,
shouldProcessForward,
projectId,
},
'History mode detection result',
); );
let wasModified = false; let wasModified = false;
@ -247,8 +132,8 @@ class TourPagesService extends BaseService {
const isBack = TourPagesService.isBackElement(element); const isBack = TourPagesService.isBackElement(element);
const isForward = TourPagesService.isForwardElementWithTarget(element); const isForward = TourPagesService.isForwardElementWithTarget(element);
// Determine if this element needs reversed video // Always generate reversed videos for navigation elements with transitions
const needsReversed = isBack || (shouldProcessForward && isForward); const needsReversed = isBack || isForward;
logger.debug( logger.debug(
{ {
@ -260,7 +145,6 @@ class TourPagesService extends BaseService {
hasTransitionVideo: Boolean(element.transitionVideoUrl), hasTransitionVideo: Boolean(element.transitionVideoUrl),
targetPageSlug: element.targetPageSlug, targetPageSlug: element.targetPageSlug,
targetPageId: element.targetPageId, targetPageId: element.targetPageId,
shouldProcessForward,
}, },
'Evaluating element for reversed video', 'Evaluating element for reversed video',
); );
@ -366,7 +250,12 @@ class TourPagesService extends BaseService {
* @param {Object} task.currentUser * @param {Object} task.currentUser
* @param {string} [task.pageId] * @param {string} [task.pageId]
*/ */
static enqueueSingleReverseGeneration({ projectId, storageKey, currentUser, pageId }) { static enqueueSingleReverseGeneration({
projectId,
storageKey,
currentUser,
pageId,
}) {
if (!projectId || !storageKey) return; if (!projectId || !storageKey) return;
const taskKey = `${projectId}:${storageKey}`; const taskKey = `${projectId}:${storageKey}`;
@ -392,7 +281,9 @@ class TourPagesService extends BaseService {
); );
if (!reversedUrl) { if (!reversedUrl) {
log.warn('Background reversed variant generation finished without result'); log.warn(
'Background reversed variant generation finished without result',
);
return; return;
} }
@ -435,7 +326,10 @@ class TourPagesService extends BaseService {
excludePageId, excludePageId,
); );
} catch (err) { } catch (err) {
logger.error({ err, projectId }, 'Background project regeneration failed'); logger.error(
{ err, projectId },
'Background project regeneration failed',
);
} finally { } finally {
projectRegenInProgress.delete(projectId); projectRegenInProgress.delete(projectId);
} }
@ -650,22 +544,26 @@ class TourPagesService extends BaseService {
for (const element of uiSchema.elements) { for (const element of uiSchema.elements) {
// Process both forward elements AND back elements with their own transition // Process both forward elements AND back elements with their own transition
const isForward = TourPagesService.isForwardElementWithTarget(element); const isForward =
TourPagesService.isForwardElementWithTarget(element);
const isBackWithTransition = const isBackWithTransition =
TourPagesService.isBackElement(element) && TourPagesService.isBackElement(element) &&
element.transitionVideoUrl; element.transitionVideoUrl;
log.debug({ log.debug(
pageId: page.id, {
elementType: element.type, pageId: page.id,
navType: element.navType, elementType: element.type,
isForward, navType: element.navType,
isBackWithTransition, isForward,
hasTransitionVideo: Boolean(element.transitionVideoUrl), isBackWithTransition,
hasReverseVideo: Boolean(element.reverseVideoUrl), hasTransitionVideo: Boolean(element.transitionVideoUrl),
targetPageSlug: element.targetPageSlug, hasReverseVideo: Boolean(element.reverseVideoUrl),
targetPageId: element.targetPageId, targetPageSlug: element.targetPageSlug,
}, 'Checking element in regeneration'); targetPageId: element.targetPageId,
},
'Checking element in regeneration',
);
// Skip if neither forward nor back-with-transition // Skip if neither forward nor back-with-transition
if (!isForward && !isBackWithTransition) { if (!isForward && !isBackWithTransition) {

View File

@ -6,7 +6,9 @@ const config = require('../config');
const AuthService = require('./auth'); const AuthService = require('./auth');
// Generate base service from factory // Generate base service from factory
const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users' }); const BaseUsersService = createEntityService(UsersDBApi, {
entityName: 'Users',
});
/** /**
* Users service with email invitation functionality * Users service with email invitation functionality

View File

@ -33,13 +33,20 @@ async function reverseVideo(inputBuffer, filename) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ffmpeg(inputPath) ffmpeg(inputPath)
.outputOptions([ .outputOptions([
'-vf', 'reverse', '-vf',
'-af', 'areverse', 'reverse',
'-c:v', 'libx264', '-af',
'-preset', 'fast', 'areverse',
'-crf', '23', '-c:v',
'-c:a', 'aac', 'libx264',
'-movflags', '+faststart', '-preset',
'fast',
'-crf',
'23',
'-c:a',
'aac',
'-movflags',
'+faststart',
]) ])
.output(outputPath) .output(outputPath)
.on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command')) .on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command'))

View File

@ -322,7 +322,6 @@ export function ElementEditorPanel({
selectedElement.transitionReverseMode || 'auto_reverse' selectedElement.transitionReverseMode || 'auto_reverse'
} }
reverseVideoUrl={selectedElement.reverseVideoUrl || ''} reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
navBackMode={selectedElement.navBackMode}
allowedNavigationTypes={allowedNavigationTypes} allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={assetOptions.icon} iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo} transitionVideoOptions={assetOptions.transitionVideo}

View File

@ -31,7 +31,6 @@ interface NavigationSettingsSectionCompactProps {
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;
navBackMode?: 'target_page' | 'history';
allowedNavigationTypes: NavigationElementType[]; allowedNavigationTypes: NavigationElementType[];
iconAssetOptions: AssetOption[]; iconAssetOptions: AssetOption[];
transitionVideoOptions: AssetOption[]; transitionVideoOptions: AssetOption[];
@ -68,7 +67,6 @@ const NavigationSettingsSectionCompact: React.FC<
transitionVideoUrl, transitionVideoUrl,
transitionReverseMode, transitionReverseMode,
reverseVideoUrl, reverseVideoUrl,
navBackMode,
allowedNavigationTypes, allowedNavigationTypes,
iconAssetOptions, iconAssetOptions,
transitionVideoOptions, transitionVideoOptions,
@ -186,31 +184,16 @@ const NavigationSettingsSectionCompact: React.FC<
)} )}
</div> </div>
{/* Back Navigation Mode - only shown for back buttons */} {/* Back navigation info text */}
{currentKind === 'back' && ( {currentKind === 'back' && (
<div> <p className='text-[11px] italic text-gray-500'>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> Back button returns to the previous page using the original forward
Back Navigation Mode transition in reverse.
</label> </p>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={navBackMode || 'target_page'}
onChange={(e) => onChange('navBackMode', e.target.value)}
>
<option value='target_page'>Fixed target page</option>
<option value='history'>Previous page (browser-like)</option>
</select>
{navBackMode === 'history' && (
<p className='mt-1 text-[11px] text-gray-500'>
Returns to the page user came from, using the original forward
transition in reverse.
</p>
)}
</div>
)} )}
{/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */} {/* Only show target page and transition settings for forward buttons */}
{(currentKind === 'forward' || navBackMode !== 'history') && ( {currentKind === 'forward' && (
<> <>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>

View File

@ -46,6 +46,7 @@ import { logger } from '../lib/logger';
import { import {
resolveNavigationTarget, resolveNavigationTarget,
isTransitionBlocking, isTransitionBlocking,
isBackNavigation,
} from '../lib/navigationHelpers'; } from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation'; import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
@ -396,7 +397,7 @@ export default function RuntimePresentation({
resolvedTargetPageId: navTarget?.pageId, resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl, transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl), hasTransition: Boolean(element.transitionVideoUrl),
navBackMode: element.navBackMode, isBack: isBackNavigation(element),
previousPageId: navContext.previousPageId, previousPageId: navContext.previousPageId,
}); });

View File

@ -110,7 +110,6 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
navDisabled: false, navDisabled: false,
iconUrl: '', iconUrl: '',
transitionReverseMode: 'auto_reverse', transitionReverseMode: 'auto_reverse',
navBackMode: 'target_page',
}, },
tooltip: { tooltip: {
iconUrl: '', iconUrl: '',
@ -490,7 +489,6 @@ export const buildElementSettings = (
element.transitionReverseMode, element.transitionReverseMode,
); );
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl); addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
addIfNotEmpty(settings, 'navBackMode', element.navBackMode);
} }
// Tooltip type settings // Tooltip type settings

View File

@ -121,7 +121,7 @@ export const resolveHistoryBackTarget = (
/** /**
* Resolve target page from element navigation properties. * Resolve target page from element navigation properties.
* Supports both targetPageSlug (new) and targetPageId (legacy). * Supports both targetPageSlug (new) and targetPageId (legacy).
* Also supports history-based back navigation when navBackMode='history'. * Back navigation always uses history mode - returns to previous page.
* *
* @param element - Element with navigation properties * @param element - Element with navigation properties
* @param pages - Available pages to search * @param pages - Available pages to search
@ -133,8 +133,8 @@ export const resolveNavigationTarget = (
pages: RuntimePage[], pages: RuntimePage[],
context?: NavigationContext, context?: NavigationContext,
): NavigationTarget | null => { ): NavigationTarget | null => {
// Handle history-based back navigation // Back navigation ALWAYS uses history mode
if (isBackNavigation(element) && element.navBackMode === 'history') { if (isBackNavigation(element)) {
return resolveHistoryBackTarget( return resolveHistoryBackTarget(
pages, pages,
context?.currentPageSlug || '', context?.currentPageSlug || '',
@ -142,7 +142,7 @@ export const resolveNavigationTarget = (
); );
} }
// Standard target_page mode logic // Forward navigation: resolve target page
const targetPageSlug = element.targetPageSlug; const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId; const legacyTargetPageId = element.targetPageId;
@ -158,15 +158,13 @@ export const resolveNavigationTarget = (
return null; return null;
} }
const isBack = isBackNavigation(element);
return { return {
page: targetPage, page: targetPage,
pageId: targetPage.id, pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl, transitionVideoUrl: element.transitionVideoUrl,
transitionReverseMode: element.transitionReverseMode, transitionReverseMode: element.transitionReverseMode,
reverseVideoUrl: element.reverseVideoUrl, reverseVideoUrl: element.reverseVideoUrl,
isBack, isBack: false,
}; };
}; };

View File

@ -33,6 +33,7 @@ import {
resolveNavigationTarget, resolveNavigationTarget,
hasPlayableTransition, hasPlayableTransition,
getNavigationDirection, getNavigationDirection,
isBackNavigation,
} from '../lib/navigationHelpers'; } from '../lib/navigationHelpers';
import { import {
mergeElementWithDefaults, mergeElementWithDefaults,
@ -1034,8 +1035,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const navTarget = resolveNavigationTarget(element, pages, navContext); const navTarget = resolveNavigationTarget(element, pages, navContext);
if (!navTarget) { if (!navTarget) {
// History mode back buttons need navigation history // Back buttons always use history, forward buttons use target page
if (element.navBackMode === 'history') { if (isBackNavigation(element)) {
if (!navContext.previousPageId) { if (!navContext.previousPageId) {
setErrorMessage( setErrorMessage(
'No previous page in history. Navigate to another page first.', 'No previous page in history. Navigate to another page first.',
@ -1053,17 +1054,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return; return;
} }
// For history mode, use transition from navTarget (the forward element that brought us here) // For back navigation, use transition from navTarget (the forward element that brought us here)
// For target_page mode, use the element's own transition settings // For forward navigation, use the element's own transition settings
const transitionSource = const transitionSource = isBackNavigation(element)
element.navBackMode === 'history' ? {
? { type: element.type,
type: element.type, transitionVideoUrl: navTarget.transitionVideoUrl,
transitionVideoUrl: navTarget.transitionVideoUrl, transitionReverseMode: navTarget.transitionReverseMode,
transitionReverseMode: navTarget.transitionReverseMode, reverseVideoUrl: navTarget.reverseVideoUrl,
reverseVideoUrl: navTarget.reverseVideoUrl, }
} : element;
: element;
// Check if transition can be played using shared helper // Check if transition can be played using shared helper
if (!hasPlayableTransition(transitionSource, direction)) { if (!hasPlayableTransition(transitionSource, direction)) {

View File

@ -230,8 +230,6 @@ export interface CanvasElement extends BaseCanvasElement {
reverseVideoUrl?: string; reverseVideoUrl?: string;
/** Storage key for the reversed transition video (pre-generated by backend) */ /** Storage key for the reversed transition video (pre-generated by backend) */
transitionReversedStorageKey?: string; transitionReversedStorageKey?: string;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
transitionDurationSec?: number; transitionDurationSec?: number;
// Gallery Carousel Settings // Gallery Carousel Settings
galleryCarouselPrevIconUrl?: string; galleryCarouselPrevIconUrl?: string;

View File

@ -84,8 +84,6 @@ export interface NavigableElement {
reverseVideoUrl?: string; reverseVideoUrl?: string;
navType?: 'forward' | 'back'; navType?: 'forward' | 'back';
navDisabled?: boolean; navDisabled?: boolean;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
} }
/** /**