simplified "go back" functionality
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
@ -18,7 +18,8 @@ const config = {
|
||||
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,
|
||||
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,
|
||||
@ -26,7 +27,8 @@ const config = {
|
||||
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,
|
||||
presignExpirySeconds:
|
||||
parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600,
|
||||
},
|
||||
bcrypt: {
|
||||
saltRounds: 12,
|
||||
|
||||
@ -131,7 +131,9 @@ class GenericDBApi {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
mapped[field] = transformer(mapped[field]);
|
||||
}
|
||||
|
||||
@ -35,14 +35,7 @@ class ProjectsDBApi extends GenericDBApi {
|
||||
}
|
||||
|
||||
static get CSV_FIELDS() {
|
||||
return [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'logo_url',
|
||||
'createdAt',
|
||||
];
|
||||
return ['id', 'name', 'slug', 'description', 'logo_url', 'createdAt'];
|
||||
}
|
||||
|
||||
static get AUTOCOMPLETE_FIELD() {
|
||||
@ -63,7 +56,8 @@ class ProjectsDBApi extends GenericDBApi {
|
||||
favicon_url: data.favicon_url || null,
|
||||
og_image_url: data.og_image_url || 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -94,8 +94,7 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
data.background_video_end_time !== undefined
|
||||
? data.background_video_end_time
|
||||
: 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,
|
||||
requires_auth: data.requires_auth || false,
|
||||
|
||||
@ -60,6 +60,8 @@ module.exports = {
|
||||
|
||||
async down(_queryInterface, _Sequelize) {
|
||||
// Cannot restore deleted duplicates
|
||||
console.log('Down migration not applicable - duplicates cannot be restored.');
|
||||
console.log(
|
||||
'Down migration not applicable - duplicates cannot be restored.',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -63,9 +63,7 @@ module.exports = {
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Deleted ${idsToDelete.length} invalid element_type_defaults.`,
|
||||
);
|
||||
console.log(`Deleted ${idsToDelete.length} invalid element_type_defaults.`);
|
||||
console.log(
|
||||
`Deleted ${deletedProjectDefaults.length} invalid project_element_defaults.`,
|
||||
);
|
||||
|
||||
@ -18,11 +18,15 @@ module.exports = {
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_start_time', {
|
||||
type: Sequelize.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
await queryInterface.addColumn(
|
||||
'tour_pages',
|
||||
'background_video_start_time',
|
||||
{
|
||||
type: Sequelize.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
);
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_end_time', {
|
||||
type: Sequelize.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
@ -31,10 +35,19 @@ module.exports = {
|
||||
},
|
||||
|
||||
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_muted');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_start_time');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_end_time');
|
||||
await queryInterface.removeColumn(
|
||||
'tour_pages',
|
||||
'background_video_start_time',
|
||||
);
|
||||
await queryInterface.removeColumn(
|
||||
'tour_pages',
|
||||
'background_video_end_time',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -27,11 +27,17 @@ router.post('/presign', jsonParser, async (req, res) => {
|
||||
const { urls } = req.body || {};
|
||||
|
||||
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) {
|
||||
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
|
||||
@ -39,24 +45,43 @@ router.post('/presign', jsonParser, async (req, res) => {
|
||||
(url) => typeof url !== 'string' || !url.trim(),
|
||||
);
|
||||
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)
|
||||
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,
|
||||
}));
|
||||
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) {
|
||||
log.error({ err: error, urlCount: urls.length }, 'Failed to generate presigned URLs');
|
||||
res.status(500).json(createErrorResponse('Failed to generate presigned URLs', 'PRESIGN_ERROR'));
|
||||
log.error(
|
||||
{ err: error, urlCount: urls.length },
|
||||
'Failed to generate presigned URLs',
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json(
|
||||
createErrorResponse(
|
||||
'Failed to generate presigned URLs',
|
||||
'PRESIGN_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ const router = createEntityRouter('users', UsersService, UsersDBApi, {
|
||||
// Note: This needs to be added BEFORE the router is exported
|
||||
// The factory already registered this route, so we add middleware to sanitize
|
||||
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) {
|
||||
|
||||
@ -93,12 +93,14 @@ class AssetsService extends BaseService {
|
||||
|
||||
// Pre-generate reversed video for video assets (async, doesn't block response)
|
||||
if (assetType === 'video' && data.storage_key) {
|
||||
AssetsService.preGenerateReversedVideo(asset, currentUser).catch((err) => {
|
||||
logger.error(
|
||||
{ err, assetId: asset.id },
|
||||
'Failed to pre-generate reversed video (non-blocking)',
|
||||
);
|
||||
});
|
||||
AssetsService.preGenerateReversedVideo(asset, currentUser).catch(
|
||||
(err) => {
|
||||
logger.error(
|
||||
{ err, assetId: asset.id },
|
||||
'Failed to pre-generate reversed video (non-blocking)',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return asset;
|
||||
@ -131,7 +133,9 @@ class AssetsService extends BaseService {
|
||||
// Check if reversed variant already exists (shouldn't happen on create, but safety check)
|
||||
const existingAsset = await AssetsDBApi.findBy({ id: asset.id });
|
||||
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) {
|
||||
log.debug('Reversed variant already exists');
|
||||
@ -172,7 +176,10 @@ class AssetsService extends BaseService {
|
||||
);
|
||||
|
||||
log.info(
|
||||
{ reversedKey, sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2) },
|
||||
{
|
||||
reversedKey,
|
||||
sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2),
|
||||
},
|
||||
'Pre-generated reversed video successfully',
|
||||
);
|
||||
|
||||
|
||||
@ -33,18 +33,25 @@ let gcloudHash = null;
|
||||
let uploadSessionManager = null;
|
||||
|
||||
const getFileStorageProvider = () => {
|
||||
const provider = (process.env.FILE_STORAGE_PROVIDER || '').trim().toLowerCase();
|
||||
const provider = (process.env.FILE_STORAGE_PROVIDER || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (provider) return provider;
|
||||
|
||||
const hasS3 = Boolean(
|
||||
config.s3.bucket && config.s3.region &&
|
||||
config.s3.accessKeyId && config.s3.secretAccessKey
|
||||
config.s3.bucket &&
|
||||
config.s3.region &&
|
||||
config.s3.accessKeyId &&
|
||||
config.s3.secretAccessKey,
|
||||
);
|
||||
if (hasS3) return 's3';
|
||||
|
||||
const hasGCloud = Boolean(
|
||||
process.env.GC_PROJECT_ID && process.env.GC_CLIENT_EMAIL &&
|
||||
process.env.GC_PRIVATE_KEY && config.gcloud.bucket && config.gcloud.hash
|
||||
process.env.GC_PROJECT_ID &&
|
||||
process.env.GC_CLIENT_EMAIL &&
|
||||
process.env.GC_PRIVATE_KEY &&
|
||||
config.gcloud.bucket &&
|
||||
config.gcloud.hash,
|
||||
);
|
||||
if (hasGCloud) return 'gcloud';
|
||||
|
||||
@ -67,14 +74,17 @@ const getS3Provider = () => {
|
||||
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');
|
||||
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;
|
||||
};
|
||||
@ -144,7 +154,11 @@ const getErrorMessage = (error, operation = 'process') => {
|
||||
const errorName = error?.name || '';
|
||||
const errorCode = error?.code || '';
|
||||
|
||||
if (errorName === 'NoSuchKey' || errorName === 'NotFound' || errorName === 'NoSuchBucket') {
|
||||
if (
|
||||
errorName === 'NoSuchKey' ||
|
||||
errorName === 'NotFound' ||
|
||||
errorName === 'NoSuchBucket'
|
||||
) {
|
||||
return 'File not found';
|
||||
}
|
||||
if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') {
|
||||
@ -203,17 +217,25 @@ const uploadFile = async (folder, req, res) => {
|
||||
const processFile = require('../middlewares/upload');
|
||||
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;
|
||||
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}`;
|
||||
let publicUrl = '';
|
||||
|
||||
if (provider === 's3') {
|
||||
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;
|
||||
} else if (provider === 'gcloud') {
|
||||
const { bucket, hash } = getGCloudBucket();
|
||||
@ -225,7 +247,9 @@ const uploadFile = async (folder, req, res) => {
|
||||
blobStream.on('finish', resolve);
|
||||
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 {
|
||||
const local = getLocalProvider();
|
||||
await local.upload(privateUrl, req.file.buffer);
|
||||
@ -240,7 +264,14 @@ const uploadFile = async (folder, req, res) => {
|
||||
});
|
||||
} catch (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'));
|
||||
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 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
|
||||
if (!isValidPath(privateUrl)) {
|
||||
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');
|
||||
@ -279,7 +320,8 @@ const downloadFile = async (req, res) => {
|
||||
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 (result.contentLength)
|
||||
res.setHeader('Content-Length', result.contentLength);
|
||||
|
||||
if (typeof result.body.pipe === 'function') {
|
||||
result.body.pipe(res);
|
||||
@ -290,7 +332,10 @@ const downloadFile = async (req, res) => {
|
||||
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') {
|
||||
const { bucket, hash } = getGCloudBucket();
|
||||
const file = bucket.file(`${hash}/${privateUrl}`);
|
||||
@ -298,7 +343,9 @@ const downloadFile = async (req, res) => {
|
||||
if (exists) {
|
||||
file.createReadStream().pipe(res);
|
||||
} else {
|
||||
res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND'));
|
||||
res
|
||||
.status(404)
|
||||
.send(createErrorResponse('File not found', 'NOT_FOUND'));
|
||||
}
|
||||
} else {
|
||||
res.download(path.join(config.uploadDir, privateUrl));
|
||||
@ -316,17 +363,24 @@ const downloadFile = async (req, res) => {
|
||||
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');
|
||||
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'));
|
||||
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 }>}
|
||||
*/
|
||||
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 provider = getFileStorageProvider();
|
||||
@ -435,11 +490,15 @@ const uploadBuffer = async (privateUrl, buffer, options = {}) => {
|
||||
blobStream.on('finish', resolve);
|
||||
blobStream.end(buffer);
|
||||
});
|
||||
return { url: `https://storage.googleapis.com/${bucket.name}/${blob.name}` };
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`,
|
||||
};
|
||||
} else {
|
||||
const local = getLocalProvider();
|
||||
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 value = String(folder || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
return (!value || value.includes('..')) ? null : value;
|
||||
const value = String(folder || '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
return !value || value.includes('..') ? null : value;
|
||||
};
|
||||
|
||||
const sanitizeFilename = (filename) => {
|
||||
const value = path.basename(String(filename || '').trim());
|
||||
return (!value || value === '.' || value === '..') ? null : value;
|
||||
return !value || value === '.' || value === '..' ? null : value;
|
||||
};
|
||||
|
||||
const initUploadSession = async (req, res) => {
|
||||
@ -472,9 +533,20 @@ 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(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'));
|
||||
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,
|
||||
@ -485,7 +557,10 @@ const initUploadSession = async (req, res) => {
|
||||
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({
|
||||
sessionId,
|
||||
@ -494,7 +569,14 @@ const initUploadSession = async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
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 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);
|
||||
|
||||
return res.status(200).send({
|
||||
@ -518,7 +605,14 @@ const getUploadSession = async (req, res) => {
|
||||
} catch (error) {
|
||||
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'));
|
||||
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);
|
||||
|
||||
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 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 (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
|
||||
const chunks = [];
|
||||
@ -558,7 +664,11 @@ const uploadChunk = async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
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 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);
|
||||
|
||||
// Verify all chunks exist
|
||||
for (let i = 0; i < session.totalChunks; 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
|
||||
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);
|
||||
|
||||
const privateUrl = `${session.folder}/${session.filename}`;
|
||||
@ -594,13 +718,20 @@ const finalizeUploadSession = async (req, res) => {
|
||||
if (provider === 's3') {
|
||||
const s3 = getS3Provider();
|
||||
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;
|
||||
} else if (provider === 'gcloud') {
|
||||
const { bucket, hash } = getGCloudBucket();
|
||||
const blob = bucket.file(`${hash}/${privateUrl}`);
|
||||
await pipeline(fs.createReadStream(assembledPath), blob.createWriteStream({ resumable: false }));
|
||||
publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
|
||||
await pipeline(
|
||||
fs.createReadStream(assembledPath),
|
||||
blob.createWriteStream({ resumable: false }),
|
||||
);
|
||||
publicUrl = format(
|
||||
`https://storage.googleapis.com/${bucket.name}/${blob.name}`,
|
||||
);
|
||||
} else {
|
||||
const local = getLocalProvider();
|
||||
const data = fs.readFileSync(assembledPath);
|
||||
@ -624,7 +755,14 @@ const finalizeUploadSession = async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
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(
|
||||
urls.map(async (url) => {
|
||||
presignedUrls[url] = await s3.getSignedUrl(url, expirySeconds);
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return presignedUrls;
|
||||
|
||||
@ -76,7 +76,10 @@ class UploadSessionManager {
|
||||
* Get chunk file path
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.removeSession(sessionId);
|
||||
}
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
* Tour Pages Service
|
||||
*
|
||||
* Extends the factory service with reversed video generation for back navigation transitions.
|
||||
*
|
||||
* 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
|
||||
* Reversed videos are always generated for forward navigation elements to support back navigation.
|
||||
*/
|
||||
|
||||
const Tour_pagesDBApi = require('../db/api/tour_pages');
|
||||
@ -17,8 +13,6 @@ const { downloadToBuffer, uploadBuffer } = require('./file');
|
||||
const videoProcessing = require('./videoProcessing');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
// Cache for project history-mode status (cleared per request cycle)
|
||||
const projectHistoryModeCache = new Map();
|
||||
const projectRegenInProgress = new Set();
|
||||
const singleReverseGenerationInProgress = new Set();
|
||||
const reverseGenerationPromiseByStorageKey = new Map();
|
||||
@ -37,10 +31,11 @@ class TourPagesService extends BaseService {
|
||||
*/
|
||||
static async create(data, currentUser) {
|
||||
// Process reversed videos and get updated ui_schema_json
|
||||
const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||
data,
|
||||
currentUser,
|
||||
);
|
||||
const updatedData =
|
||||
await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||
data,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
return super.create(updatedData, currentUser);
|
||||
}
|
||||
@ -51,13 +46,15 @@ class TourPagesService extends BaseService {
|
||||
static async update(data, id, currentUser) {
|
||||
// Fetch existing page to get projectId (not included in update request body)
|
||||
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
|
||||
const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||
{ ...data, projectId, id },
|
||||
currentUser,
|
||||
);
|
||||
const updatedData =
|
||||
await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||
{ ...data, projectId, id },
|
||||
currentUser,
|
||||
);
|
||||
|
||||
return super.update(updatedData, id, currentUser);
|
||||
}
|
||||
@ -88,94 +85,16 @@ class TourPagesService extends BaseService {
|
||||
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.
|
||||
* Returns data with updated ui_schema_json.
|
||||
*
|
||||
* Handles two cases:
|
||||
* 1. Back navigation elements with transitionVideoUrl - always generate reversed
|
||||
* 2. Forward navigation elements when project uses history-mode back navigation
|
||||
* Generates reversed videos for all navigation elements with transitions.
|
||||
*
|
||||
* @param {Object} data - Page data with ui_schema_json
|
||||
* @param {Object} currentUser - Current user for permissions
|
||||
* @param {Object} options - Processing options
|
||||
* @param {boolean} options._forceForwardReversed - Force processing forward elements
|
||||
* @param {boolean} options._skipHistoryModeCheck - Skip initial history mode check
|
||||
* @param {boolean} options._skipHistoryModeCheck - Skip initial regeneration check
|
||||
*/
|
||||
static async processReversedVideosAndUpdateSchema(
|
||||
data,
|
||||
@ -202,43 +121,9 @@ class TourPagesService extends BaseService {
|
||||
// Get project ID
|
||||
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(
|
||||
{
|
||||
thisPageHasHistoryMode,
|
||||
projectHasHistoryMode,
|
||||
shouldProcessForward,
|
||||
projectId,
|
||||
},
|
||||
'History mode detection result',
|
||||
{ projectId },
|
||||
'Processing reversed videos for navigation elements',
|
||||
);
|
||||
|
||||
let wasModified = false;
|
||||
@ -247,8 +132,8 @@ class TourPagesService extends BaseService {
|
||||
const isBack = TourPagesService.isBackElement(element);
|
||||
const isForward = TourPagesService.isForwardElementWithTarget(element);
|
||||
|
||||
// Determine if this element needs reversed video
|
||||
const needsReversed = isBack || (shouldProcessForward && isForward);
|
||||
// Always generate reversed videos for navigation elements with transitions
|
||||
const needsReversed = isBack || isForward;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
@ -260,7 +145,6 @@ class TourPagesService extends BaseService {
|
||||
hasTransitionVideo: Boolean(element.transitionVideoUrl),
|
||||
targetPageSlug: element.targetPageSlug,
|
||||
targetPageId: element.targetPageId,
|
||||
shouldProcessForward,
|
||||
},
|
||||
'Evaluating element for reversed video',
|
||||
);
|
||||
@ -366,7 +250,12 @@ class TourPagesService extends BaseService {
|
||||
* @param {Object} task.currentUser
|
||||
* @param {string} [task.pageId]
|
||||
*/
|
||||
static enqueueSingleReverseGeneration({ projectId, storageKey, currentUser, pageId }) {
|
||||
static enqueueSingleReverseGeneration({
|
||||
projectId,
|
||||
storageKey,
|
||||
currentUser,
|
||||
pageId,
|
||||
}) {
|
||||
if (!projectId || !storageKey) return;
|
||||
|
||||
const taskKey = `${projectId}:${storageKey}`;
|
||||
@ -392,7 +281,9 @@ class TourPagesService extends BaseService {
|
||||
);
|
||||
|
||||
if (!reversedUrl) {
|
||||
log.warn('Background reversed variant generation finished without result');
|
||||
log.warn(
|
||||
'Background reversed variant generation finished without result',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -435,7 +326,10 @@ class TourPagesService extends BaseService {
|
||||
excludePageId,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'Background project regeneration failed');
|
||||
logger.error(
|
||||
{ err, projectId },
|
||||
'Background project regeneration failed',
|
||||
);
|
||||
} finally {
|
||||
projectRegenInProgress.delete(projectId);
|
||||
}
|
||||
@ -650,22 +544,26 @@ class TourPagesService extends BaseService {
|
||||
|
||||
for (const element of uiSchema.elements) {
|
||||
// Process both forward elements AND back elements with their own transition
|
||||
const isForward = TourPagesService.isForwardElementWithTarget(element);
|
||||
const isForward =
|
||||
TourPagesService.isForwardElementWithTarget(element);
|
||||
const isBackWithTransition =
|
||||
TourPagesService.isBackElement(element) &&
|
||||
element.transitionVideoUrl;
|
||||
|
||||
log.debug({
|
||||
pageId: page.id,
|
||||
elementType: element.type,
|
||||
navType: element.navType,
|
||||
isForward,
|
||||
isBackWithTransition,
|
||||
hasTransitionVideo: Boolean(element.transitionVideoUrl),
|
||||
hasReverseVideo: Boolean(element.reverseVideoUrl),
|
||||
targetPageSlug: element.targetPageSlug,
|
||||
targetPageId: element.targetPageId,
|
||||
}, 'Checking element in regeneration');
|
||||
log.debug(
|
||||
{
|
||||
pageId: page.id,
|
||||
elementType: element.type,
|
||||
navType: element.navType,
|
||||
isForward,
|
||||
isBackWithTransition,
|
||||
hasTransitionVideo: Boolean(element.transitionVideoUrl),
|
||||
hasReverseVideo: Boolean(element.reverseVideoUrl),
|
||||
targetPageSlug: element.targetPageSlug,
|
||||
targetPageId: element.targetPageId,
|
||||
},
|
||||
'Checking element in regeneration',
|
||||
);
|
||||
|
||||
// Skip if neither forward nor back-with-transition
|
||||
if (!isForward && !isBackWithTransition) {
|
||||
|
||||
@ -6,7 +6,9 @@ const config = require('../config');
|
||||
const AuthService = require('./auth');
|
||||
|
||||
// Generate base service from factory
|
||||
const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users' });
|
||||
const BaseUsersService = createEntityService(UsersDBApi, {
|
||||
entityName: 'Users',
|
||||
});
|
||||
|
||||
/**
|
||||
* Users service with email invitation functionality
|
||||
|
||||
@ -33,13 +33,20 @@ async function reverseVideo(inputBuffer, filename) {
|
||||
await new Promise((resolve, reject) => {
|
||||
ffmpeg(inputPath)
|
||||
.outputOptions([
|
||||
'-vf', 'reverse',
|
||||
'-af', 'areverse',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-movflags', '+faststart',
|
||||
'-vf',
|
||||
'reverse',
|
||||
'-af',
|
||||
'areverse',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'fast',
|
||||
'-crf',
|
||||
'23',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
])
|
||||
.output(outputPath)
|
||||
.on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command'))
|
||||
|
||||
@ -322,7 +322,6 @@ export function ElementEditorPanel({
|
||||
selectedElement.transitionReverseMode || 'auto_reverse'
|
||||
}
|
||||
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
||||
navBackMode={selectedElement.navBackMode}
|
||||
allowedNavigationTypes={allowedNavigationTypes}
|
||||
iconAssetOptions={assetOptions.icon}
|
||||
transitionVideoOptions={assetOptions.transitionVideo}
|
||||
|
||||
@ -31,7 +31,6 @@ interface NavigationSettingsSectionCompactProps {
|
||||
transitionVideoUrl: string;
|
||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl: string;
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
allowedNavigationTypes: NavigationElementType[];
|
||||
iconAssetOptions: AssetOption[];
|
||||
transitionVideoOptions: AssetOption[];
|
||||
@ -68,7 +67,6 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
transitionVideoUrl,
|
||||
transitionReverseMode,
|
||||
reverseVideoUrl,
|
||||
navBackMode,
|
||||
allowedNavigationTypes,
|
||||
iconAssetOptions,
|
||||
transitionVideoOptions,
|
||||
@ -186,31 +184,16 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back Navigation Mode - only shown for back buttons */}
|
||||
{/* Back navigation info text */}
|
||||
{currentKind === 'back' && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
Back Navigation Mode
|
||||
</label>
|
||||
<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>
|
||||
<p className='text-[11px] italic text-gray-500'>
|
||||
Back button returns to the previous page using the original forward
|
||||
transition in reverse.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */}
|
||||
{(currentKind === 'forward' || navBackMode !== 'history') && (
|
||||
{/* Only show target page and transition settings for forward buttons */}
|
||||
{currentKind === 'forward' && (
|
||||
<>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
|
||||
@ -46,6 +46,7 @@ import { logger } from '../lib/logger';
|
||||
import {
|
||||
resolveNavigationTarget,
|
||||
isTransitionBlocking,
|
||||
isBackNavigation,
|
||||
} from '../lib/navigationHelpers';
|
||||
import type { TransitionPhase } from '../types/presentation';
|
||||
import type { CanvasElement } from '../types/constructor';
|
||||
@ -396,7 +397,7 @@ export default function RuntimePresentation({
|
||||
resolvedTargetPageId: navTarget?.pageId,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
hasTransition: Boolean(element.transitionVideoUrl),
|
||||
navBackMode: element.navBackMode,
|
||||
isBack: isBackNavigation(element),
|
||||
previousPageId: navContext.previousPageId,
|
||||
});
|
||||
|
||||
|
||||
@ -110,7 +110,6 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
|
||||
navDisabled: false,
|
||||
iconUrl: '',
|
||||
transitionReverseMode: 'auto_reverse',
|
||||
navBackMode: 'target_page',
|
||||
},
|
||||
tooltip: {
|
||||
iconUrl: '',
|
||||
@ -490,7 +489,6 @@ export const buildElementSettings = (
|
||||
element.transitionReverseMode,
|
||||
);
|
||||
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
|
||||
addIfNotEmpty(settings, 'navBackMode', element.navBackMode);
|
||||
}
|
||||
|
||||
// Tooltip type settings
|
||||
|
||||
@ -121,7 +121,7 @@ export const resolveHistoryBackTarget = (
|
||||
/**
|
||||
* Resolve target page from element navigation properties.
|
||||
* 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 pages - Available pages to search
|
||||
@ -133,8 +133,8 @@ export const resolveNavigationTarget = (
|
||||
pages: RuntimePage[],
|
||||
context?: NavigationContext,
|
||||
): NavigationTarget | null => {
|
||||
// Handle history-based back navigation
|
||||
if (isBackNavigation(element) && element.navBackMode === 'history') {
|
||||
// Back navigation ALWAYS uses history mode
|
||||
if (isBackNavigation(element)) {
|
||||
return resolveHistoryBackTarget(
|
||||
pages,
|
||||
context?.currentPageSlug || '',
|
||||
@ -142,7 +142,7 @@ export const resolveNavigationTarget = (
|
||||
);
|
||||
}
|
||||
|
||||
// Standard target_page mode logic
|
||||
// Forward navigation: resolve target page
|
||||
const targetPageSlug = element.targetPageSlug;
|
||||
const legacyTargetPageId = element.targetPageId;
|
||||
|
||||
@ -158,15 +158,13 @@ export const resolveNavigationTarget = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBack = isBackNavigation(element);
|
||||
|
||||
return {
|
||||
page: targetPage,
|
||||
pageId: targetPage.id,
|
||||
transitionVideoUrl: element.transitionVideoUrl,
|
||||
transitionReverseMode: element.transitionReverseMode,
|
||||
reverseVideoUrl: element.reverseVideoUrl,
|
||||
isBack,
|
||||
isBack: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
resolveNavigationTarget,
|
||||
hasPlayableTransition,
|
||||
getNavigationDirection,
|
||||
isBackNavigation,
|
||||
} from '../lib/navigationHelpers';
|
||||
import {
|
||||
mergeElementWithDefaults,
|
||||
@ -1034,8 +1035,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const navTarget = resolveNavigationTarget(element, pages, navContext);
|
||||
|
||||
if (!navTarget) {
|
||||
// History mode back buttons need navigation history
|
||||
if (element.navBackMode === 'history') {
|
||||
// Back buttons always use history, forward buttons use target page
|
||||
if (isBackNavigation(element)) {
|
||||
if (!navContext.previousPageId) {
|
||||
setErrorMessage(
|
||||
'No previous page in history. Navigate to another page first.',
|
||||
@ -1053,17 +1054,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// For history mode, use transition from navTarget (the forward element that brought us here)
|
||||
// For target_page mode, use the element's own transition settings
|
||||
const transitionSource =
|
||||
element.navBackMode === 'history'
|
||||
? {
|
||||
type: element.type,
|
||||
transitionVideoUrl: navTarget.transitionVideoUrl,
|
||||
transitionReverseMode: navTarget.transitionReverseMode,
|
||||
reverseVideoUrl: navTarget.reverseVideoUrl,
|
||||
}
|
||||
: element;
|
||||
// For back navigation, use transition from navTarget (the forward element that brought us here)
|
||||
// For forward navigation, use the element's own transition settings
|
||||
const transitionSource = isBackNavigation(element)
|
||||
? {
|
||||
type: element.type,
|
||||
transitionVideoUrl: navTarget.transitionVideoUrl,
|
||||
transitionReverseMode: navTarget.transitionReverseMode,
|
||||
reverseVideoUrl: navTarget.reverseVideoUrl,
|
||||
}
|
||||
: element;
|
||||
|
||||
// Check if transition can be played using shared helper
|
||||
if (!hasPlayableTransition(transitionSource, direction)) {
|
||||
|
||||
@ -230,8 +230,6 @@ export interface CanvasElement extends BaseCanvasElement {
|
||||
reverseVideoUrl?: string;
|
||||
/** Storage key for the reversed transition video (pre-generated by backend) */
|
||||
transitionReversedStorageKey?: string;
|
||||
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
transitionDurationSec?: number;
|
||||
// Gallery Carousel Settings
|
||||
galleryCarouselPrevIconUrl?: string;
|
||||
|
||||
@ -84,8 +84,6 @@ export interface NavigableElement {
|
||||
reverseVideoUrl?: string;
|
||||
navType?: 'forward' | 'back';
|
||||
navDisabled?: boolean;
|
||||
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
|
||||
navBackMode?: 'target_page' | 'history';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||