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 || '',
|
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,
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
||||||
|
'tour_pages',
|
||||||
|
'background_video_start_time',
|
||||||
|
{
|
||||||
type: Sequelize.DECIMAL(10, 1),
|
type: Sequelize.DECIMAL(10, 1),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: null,
|
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',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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(
|
||||||
|
createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
|
||||||
invalidPaths: unsafeUrls,
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(
|
||||||
|
(err) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err, assetId: asset.id },
|
{ err, assetId: asset.id },
|
||||||
'Failed to pre-generate reversed video (non-blocking)',
|
'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',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
provider: 's3',
|
||||||
bucket: config.s3.bucket,
|
bucket: config.s3.bucket,
|
||||||
region: config.s3.region,
|
region: config.s3.region,
|
||||||
connectionTimeout: config.s3.connectionTimeout,
|
connectionTimeout: config.s3.connectionTimeout,
|
||||||
requestTimeout: config.s3.requestTimeout,
|
requestTimeout: config.s3.requestTimeout,
|
||||||
maxAttempts: config.s3.maxAttempts,
|
maxAttempts: config.s3.maxAttempts,
|
||||||
}, 'S3 storage provider initialized');
|
},
|
||||||
|
'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,
|
err: error,
|
||||||
provider,
|
provider,
|
||||||
privateUrl,
|
privateUrl,
|
||||||
statusCode,
|
statusCode,
|
||||||
errorName: error?.name,
|
errorName: error?.name,
|
||||||
errorCode: error?.code,
|
errorCode: error?.code,
|
||||||
}, 'Failed to download file');
|
},
|
||||||
|
'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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,7 +31,8 @@ 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 =
|
||||||
|
await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||||
data,
|
data,
|
||||||
currentUser,
|
currentUser,
|
||||||
);
|
);
|
||||||
@ -51,10 +46,12 @@ 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 =
|
||||||
|
await TourPagesService.processReversedVideosAndUpdateSchema(
|
||||||
{ ...data, projectId, id },
|
{ ...data, projectId, id },
|
||||||
currentUser,
|
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,12 +544,14 @@ 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,
|
pageId: page.id,
|
||||||
elementType: element.type,
|
elementType: element.type,
|
||||||
navType: element.navType,
|
navType: element.navType,
|
||||||
@ -665,7 +561,9 @@ class TourPagesService extends BaseService {
|
|||||||
hasReverseVideo: Boolean(element.reverseVideoUrl),
|
hasReverseVideo: Boolean(element.reverseVideoUrl),
|
||||||
targetPageSlug: element.targetPageSlug,
|
targetPageSlug: element.targetPageSlug,
|
||||||
targetPageId: element.targetPageId,
|
targetPageId: element.targetPageId,
|
||||||
}, 'Checking element in regeneration');
|
},
|
||||||
|
'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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
|
||||||
</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.
|
transition in reverse.
|
||||||
</p>
|
</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'>
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,10 +1054,9 @@ 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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||