added configured fades for projects pages
This commit is contained in:
parent
5ef21543b3
commit
e855db03d1
155
backend/src/db/api/global_transition_defaults.js
Normal file
155
backend/src/db/api/global_transition_defaults.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
const GenericDBApi = require('./base.api');
|
||||||
|
const db = require('../models');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Transition Defaults API
|
||||||
|
*
|
||||||
|
* Single-row table pattern for platform-wide transition settings.
|
||||||
|
* Auto-seeds default values if the table is empty.
|
||||||
|
*/
|
||||||
|
class Global_transition_defaultsDBApi extends GenericDBApi {
|
||||||
|
static get MODEL() {
|
||||||
|
return db.global_transition_defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get TABLE_NAME() {
|
||||||
|
return 'global_transition_defaults';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get SEARCHABLE_FIELDS() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RANGE_FIELDS() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ENUM_FIELDS() {
|
||||||
|
return ['transition_type', 'easing'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get CSV_FIELDS() {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'transition_type',
|
||||||
|
'duration_ms',
|
||||||
|
'easing',
|
||||||
|
'overlay_color',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get AUTOCOMPLETE_FIELD() {
|
||||||
|
return 'transition_type';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get FIELD_DEFAULTS() {
|
||||||
|
return {
|
||||||
|
transition_type: { default: 'fade' },
|
||||||
|
duration_ms: { default: 700 },
|
||||||
|
easing: { default: 'ease-in-out' },
|
||||||
|
overlay_color: { default: '#000000' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get DEFAULT_ROW() {
|
||||||
|
return {
|
||||||
|
transition_type: 'fade',
|
||||||
|
duration_ms: 700,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
overlay_color: '#000000',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFieldMapping(data) {
|
||||||
|
const mapped = super.getFieldMapping(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: mapped.id || undefined,
|
||||||
|
transition_type: mapped.transition_type,
|
||||||
|
duration_ms: mapped.duration_ms,
|
||||||
|
easing: mapped.easing,
|
||||||
|
overlay_color: mapped.overlay_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the singleton row exists.
|
||||||
|
* Creates the default row if table is empty.
|
||||||
|
*/
|
||||||
|
static async ensureInitialized() {
|
||||||
|
if (!this.initializationPromise) {
|
||||||
|
this.initializationPromise = (async () => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
count = await this.MODEL.count();
|
||||||
|
} catch (error) {
|
||||||
|
// Table doesn't exist yet (happens during initial migration)
|
||||||
|
if (error?.original?.code !== '42P01') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.MODEL.sync();
|
||||||
|
count = await this.MODEL.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.MODEL.create({
|
||||||
|
...this.getFieldMapping(this.DEFAULT_ROW),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
})().catch((error) => {
|
||||||
|
this.initializationPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton row.
|
||||||
|
* Always returns a single object, not an array.
|
||||||
|
*/
|
||||||
|
static async findOne(options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const record = await this.MODEL.findOne({
|
||||||
|
transaction: options.transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
return record.get({ plain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for findOne to maintain semantic clarity.
|
||||||
|
*/
|
||||||
|
static async get(options = {}) {
|
||||||
|
return this.findOne(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.update(id, data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.findBy(where, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAll(filter = {}, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.findAll(filter, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Global_transition_defaultsDBApi.initializationPromise = null;
|
||||||
|
|
||||||
|
module.exports = Global_transition_defaultsDBApi;
|
||||||
273
backend/src/db/api/project_transition_settings.js
Normal file
273
backend/src/db/api/project_transition_settings.js
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
const GenericDBApi = require('./base.api');
|
||||||
|
const db = require('../models');
|
||||||
|
const Utils = require('../utils');
|
||||||
|
const {
|
||||||
|
applyRuntimeEnvironment,
|
||||||
|
applyRuntimeProjectFilter,
|
||||||
|
} = require('./runtime-context');
|
||||||
|
|
||||||
|
const Sequelize = db.Sequelize;
|
||||||
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
|
class Project_transition_settingsDBApi extends GenericDBApi {
|
||||||
|
static get MODEL() {
|
||||||
|
return db.project_transition_settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get TABLE_NAME() {
|
||||||
|
return 'project_transition_settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get SEARCHABLE_FIELDS() {
|
||||||
|
return ['source_key', 'transition_type', 'easing', 'overlay_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RANGE_FIELDS() {
|
||||||
|
return ['duration_ms'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ENUM_FIELDS() {
|
||||||
|
return ['environment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get CSV_FIELDS() {
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
'environment',
|
||||||
|
'source_key',
|
||||||
|
'transition_type',
|
||||||
|
'duration_ms',
|
||||||
|
'easing',
|
||||||
|
'overlay_color',
|
||||||
|
'createdAt',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get AUTOCOMPLETE_FIELD() {
|
||||||
|
return 'transition_type';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ASSOCIATIONS() {
|
||||||
|
return [{ field: 'project', setter: 'setProject', isArray: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFieldMapping(data) {
|
||||||
|
// Note: environment and projectId are NOT included here because they are
|
||||||
|
// set explicitly in upsertForProject and should never be changed via data
|
||||||
|
return {
|
||||||
|
id: data.id || undefined,
|
||||||
|
source_key: data.source_key || null,
|
||||||
|
transition_type: data.transition_type || 'fade',
|
||||||
|
duration_ms: data.duration_ms !== undefined ? data.duration_ms : 700,
|
||||||
|
easing: data.easing || 'ease-in-out',
|
||||||
|
overlay_color: data.overlay_color || '#000000',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find settings by project ID and environment.
|
||||||
|
* This is the primary method for fetching transition settings.
|
||||||
|
*
|
||||||
|
* @param {string} projectId - Project ID
|
||||||
|
* @param {string} environment - Environment (dev, stage, production)
|
||||||
|
* @param {object} options - Query options
|
||||||
|
* @returns {object|null} Settings record or null
|
||||||
|
*/
|
||||||
|
static async findByProjectAndEnvironment(projectId, environment, options = {}) {
|
||||||
|
const transaction = options.transaction;
|
||||||
|
|
||||||
|
const record = await this.MODEL.findOne({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
return record.get({ plain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update settings for a project/environment combination.
|
||||||
|
* Uses upsert semantics - creates if not exists, updates if exists.
|
||||||
|
*
|
||||||
|
* @param {string} projectId - Project ID
|
||||||
|
* @param {string} environment - Environment (dev, stage, production)
|
||||||
|
* @param {object} data - Settings data
|
||||||
|
* @param {object} options - Query options
|
||||||
|
* @returns {object} Created or updated record
|
||||||
|
*/
|
||||||
|
static async upsertForProject(projectId, environment, data, options = {}) {
|
||||||
|
const transaction = options.transaction;
|
||||||
|
const currentUser = options.currentUser;
|
||||||
|
|
||||||
|
// Check if record exists
|
||||||
|
const existing = await this.MODEL.findOne({
|
||||||
|
where: { projectId, environment },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing record
|
||||||
|
await existing.update(
|
||||||
|
{
|
||||||
|
...this.getFieldMapping(data),
|
||||||
|
updatedById: currentUser?.id || null,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
return existing.get({ plain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new record
|
||||||
|
const newRecord = await this.MODEL.create(
|
||||||
|
{
|
||||||
|
...this.getFieldMapping(data),
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
createdById: currentUser?.id || null,
|
||||||
|
updatedById: currentUser?.id || null,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
return newRecord.get({ plain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options = {}) {
|
||||||
|
const transaction = options.transaction;
|
||||||
|
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
|
||||||
|
const projectInclude = applyRuntimeProjectFilter(
|
||||||
|
{ model: db.projects, as: 'project' },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = await this.MODEL.findOne({
|
||||||
|
where: queryWhere,
|
||||||
|
transaction,
|
||||||
|
include: [projectInclude],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
return record.get({ plain: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAll(filter = {}, options = {}) {
|
||||||
|
filter = filter || {};
|
||||||
|
const limit = filter.limit || 0;
|
||||||
|
const currentPage = +filter.page || 0;
|
||||||
|
const offset = currentPage * limit;
|
||||||
|
|
||||||
|
let where = {};
|
||||||
|
|
||||||
|
const terms = filter.project ? filter.project.split('|') : [];
|
||||||
|
const validUuids = Utils.filterValidUuids(terms);
|
||||||
|
|
||||||
|
let include = [
|
||||||
|
{
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
where: filter.project
|
||||||
|
? {
|
||||||
|
[Op.or]: [
|
||||||
|
...(validUuids.length > 0
|
||||||
|
? [{ id: { [Op.in]: validUuids } }]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
include[0] = applyRuntimeProjectFilter(include[0], options);
|
||||||
|
|
||||||
|
if (filter.id) {
|
||||||
|
if (!Utils.isValidUuid(filter.id)) {
|
||||||
|
return { rows: [], count: 0 };
|
||||||
|
}
|
||||||
|
where.id = filter.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of this.SEARCHABLE_FIELDS) {
|
||||||
|
if (filter[field]) {
|
||||||
|
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of this.RANGE_FIELDS) {
|
||||||
|
const rangeKey = `${field}Range`;
|
||||||
|
if (filter[rangeKey]) {
|
||||||
|
const [start, end] = filter[rangeKey];
|
||||||
|
if (start !== undefined && start !== null && start !== '') {
|
||||||
|
where[field] = { ...where[field], [Op.gte]: start };
|
||||||
|
}
|
||||||
|
if (end !== undefined && end !== null && end !== '') {
|
||||||
|
where[field] = { ...where[field], [Op.lte]: end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of this.ENUM_FIELDS) {
|
||||||
|
if (filter[field] !== undefined) {
|
||||||
|
where[field] = filter[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.active !== undefined) {
|
||||||
|
where.active = filter.active === true || filter.active === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.createdAtRange) {
|
||||||
|
const [start, end] = filter.createdAtRange;
|
||||||
|
if (start !== undefined && start !== null && start !== '') {
|
||||||
|
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
||||||
|
}
|
||||||
|
if (end !== undefined && end !== null && end !== '') {
|
||||||
|
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
where = applyRuntimeEnvironment(where, options);
|
||||||
|
|
||||||
|
const queryOptions = {
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
distinct: true,
|
||||||
|
order:
|
||||||
|
filter.field && filter.sort
|
||||||
|
? [[filter.field, filter.sort]]
|
||||||
|
: [['createdAt', 'desc']],
|
||||||
|
transaction: options.transaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options.countOnly) {
|
||||||
|
queryOptions.limit = limit ? Number(limit) : undefined;
|
||||||
|
queryOptions.offset = offset ? Number(offset) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
||||||
|
return {
|
||||||
|
rows: options.countOnly ? [] : rows,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing query:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Project_transition_settingsDBApi;
|
||||||
@ -47,17 +47,19 @@ class ProjectsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
|
// Use undefined for missing fields so they're skipped during update
|
||||||
|
// Only include fields that are explicitly provided in data
|
||||||
|
// Note: transition_settings moved to project_transition_settings table
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
name: data.name || null,
|
name: 'name' in data ? (data.name || null) : undefined,
|
||||||
slug: data.slug || null,
|
slug: 'slug' in data ? (data.slug || null) : undefined,
|
||||||
description: data.description || null,
|
description: 'description' in data ? (data.description || null) : undefined,
|
||||||
logo_url: data.logo_url || null,
|
logo_url: 'logo_url' in data ? (data.logo_url || null) : undefined,
|
||||||
favicon_url: data.favicon_url || null,
|
favicon_url: 'favicon_url' in data ? (data.favicon_url || null) : undefined,
|
||||||
og_image_url: data.og_image_url || null,
|
og_image_url: 'og_image_url' in data ? (data.og_image_url || null) : undefined,
|
||||||
design_width: data.design_width !== undefined ? data.design_width : null,
|
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||||
design_height:
|
design_height: 'design_height' in data ? data.design_height : undefined,
|
||||||
data.design_height !== undefined ? data.design_height : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Add hierarchical transition settings
|
||||||
|
*
|
||||||
|
* Creates global_transition_defaults table for platform-wide transition settings
|
||||||
|
* and adds transition_settings JSONB column to projects table for project-level overrides.
|
||||||
|
*
|
||||||
|
* Cascade: Element → Project → Global (fallback)
|
||||||
|
*
|
||||||
|
* @type {import('sequelize-cli').Migration}
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Create global_transition_defaults table (single-row pattern)
|
||||||
|
await queryInterface.createTable('global_transition_defaults', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
transition_type: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'fade',
|
||||||
|
},
|
||||||
|
duration_ms: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 700,
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'ease-in-out',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the default row
|
||||||
|
const now = new Date();
|
||||||
|
await queryInterface.bulkInsert('global_transition_defaults', [
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
transition_type: 'fade',
|
||||||
|
duration_ms: 700,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add transition_settings JSONB column to projects
|
||||||
|
await queryInterface.addColumn('projects', 'transition_settings', {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, _Sequelize) {
|
||||||
|
// Remove transition_settings from projects
|
||||||
|
await queryInterface.removeColumn('projects', 'transition_settings');
|
||||||
|
|
||||||
|
// Drop global_transition_defaults table
|
||||||
|
await queryInterface.dropTable('global_transition_defaults');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Simplify transitions and add overlay color
|
||||||
|
*
|
||||||
|
* 1. Add overlay_color column to global_transition_defaults
|
||||||
|
* 2. Update global_transition_defaults: change slide-left/slide-right/zoom to 'fade'
|
||||||
|
* 3. Update projects.transition_settings JSONB where transitionType is slide/zoom
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Add overlay_color column to global_transition_defaults
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
'global_transition_defaults',
|
||||||
|
'overlay_color',
|
||||||
|
{
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '#000000',
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Update global_transition_defaults: change slide-left/slide-right/zoom to 'fade'
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`UPDATE global_transition_defaults
|
||||||
|
SET transition_type = 'fade'
|
||||||
|
WHERE transition_type IN ('slide-left', 'slide-right', 'zoom')`,
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update projects.transition_settings JSONB where transitionType is slide/zoom
|
||||||
|
// Convert slide-left, slide-right, zoom to 'fade'
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
`UPDATE projects
|
||||||
|
SET transition_settings = jsonb_set(
|
||||||
|
COALESCE(transition_settings, '{}'::jsonb),
|
||||||
|
'{transitionType}',
|
||||||
|
'"fade"'
|
||||||
|
)
|
||||||
|
WHERE transition_settings IS NOT NULL
|
||||||
|
AND transition_settings->>'transitionType' IN ('slide-left', 'slide-right', 'zoom')`,
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, _Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove overlay_color column
|
||||||
|
await queryInterface.removeColumn(
|
||||||
|
'global_transition_defaults',
|
||||||
|
'overlay_color',
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: We cannot restore the original slide/zoom values as they are lost
|
||||||
|
// The data migration is one-way
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,217 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Create project_transition_settings table
|
||||||
|
*
|
||||||
|
* Creates environment-aware project transition settings following the
|
||||||
|
* project_audio_tracks pattern. This allows transition settings to be
|
||||||
|
* isolated per environment and participate in the publishing workflow.
|
||||||
|
*
|
||||||
|
* Data migration:
|
||||||
|
* - Existing projects.transition_settings values are copied to 'dev' environment records
|
||||||
|
* - The column is dropped after migration to avoid dual storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Step 1: Create the project_transition_settings table
|
||||||
|
await queryInterface.createTable('project_transition_settings', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
projectId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'projects',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
type: Sequelize.ENUM('dev', 'stage', 'production'),
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
source_key: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
transition_type: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'fade',
|
||||||
|
},
|
||||||
|
duration_ms: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 700,
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'ease-in-out',
|
||||||
|
},
|
||||||
|
overlay_color: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '#000000',
|
||||||
|
},
|
||||||
|
createdById: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
updatedById: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
allowNull: false,
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
importHash: {
|
||||||
|
type: Sequelize.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add unique constraint on (projectId, environment)
|
||||||
|
// Use IF NOT EXISTS to avoid errors if index already exists
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS project_transition_settings_project_env_unique
|
||||||
|
ON project_transition_settings ("projectId", environment)
|
||||||
|
WHERE "deletedAt" IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add index on deletedAt for soft delete queries
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS project_transition_settings_deleted_at
|
||||||
|
ON project_transition_settings ("deletedAt")
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Step 2: Migrate existing project.transition_settings data to 'dev' records
|
||||||
|
const [projects] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT id, transition_settings, "createdById", "updatedById"
|
||||||
|
FROM projects
|
||||||
|
WHERE transition_settings IS NOT NULL
|
||||||
|
AND transition_settings != 'null'
|
||||||
|
AND "deletedAt" IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const records = [];
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
let settings = project.transition_settings;
|
||||||
|
|
||||||
|
// Parse JSONB if it's a string
|
||||||
|
if (typeof settings === 'string') {
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(settings);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to parse transition_settings for project ${project.id}:`, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if settings is null, empty object, or has no actual values
|
||||||
|
if (!settings || typeof settings !== 'object' || Object.keys(settings).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
projectId: project.id,
|
||||||
|
environment: 'dev',
|
||||||
|
source_key: null,
|
||||||
|
transition_type: settings.transitionType || 'fade',
|
||||||
|
duration_ms: settings.durationMs || 700,
|
||||||
|
easing: settings.easing || 'ease-in-out',
|
||||||
|
overlay_color: settings.overlayColor || '#000000',
|
||||||
|
createdById: project.createdById || null,
|
||||||
|
updatedById: project.updatedById || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
deletedAt: null,
|
||||||
|
importHash: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('project_transition_settings', records);
|
||||||
|
console.log(`Migrated ${records.length} project transition settings to 'dev' environment`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Drop the transition_settings column from projects table
|
||||||
|
await queryInterface.removeColumn('projects', 'transition_settings');
|
||||||
|
console.log('Dropped transition_settings column from projects table');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// Step 1: Re-add the transition_settings column to projects
|
||||||
|
await queryInterface.addColumn('projects', 'transition_settings', {
|
||||||
|
type: Sequelize.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Migrate 'dev' records back to projects.transition_settings
|
||||||
|
const [settings] = await queryInterface.sequelize.query(`
|
||||||
|
SELECT "projectId", transition_type, duration_ms, easing, overlay_color
|
||||||
|
FROM project_transition_settings
|
||||||
|
WHERE environment = 'dev'
|
||||||
|
AND "deletedAt" IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const setting of settings) {
|
||||||
|
const jsonValue = JSON.stringify({
|
||||||
|
transitionType: setting.transition_type,
|
||||||
|
durationMs: setting.duration_ms,
|
||||||
|
easing: setting.easing,
|
||||||
|
overlayColor: setting.overlay_color,
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE projects
|
||||||
|
SET transition_settings = :settings::jsonb
|
||||||
|
WHERE id = :projectId
|
||||||
|
`, {
|
||||||
|
replacements: {
|
||||||
|
settings: jsonValue,
|
||||||
|
projectId: setting.projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Drop indexes and table
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP INDEX IF EXISTS project_transition_settings_project_env_unique;
|
||||||
|
DROP INDEX IF EXISTS project_transition_settings_deleted_at;
|
||||||
|
`);
|
||||||
|
await queryInterface.dropTable('project_transition_settings');
|
||||||
|
|
||||||
|
// Drop the ENUM type
|
||||||
|
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_project_transition_settings_environment";');
|
||||||
|
},
|
||||||
|
};
|
||||||
73
backend/src/db/models/global_transition_defaults.js
Normal file
73
backend/src/db/models/global_transition_defaults.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const global_transition_defaults = sequelize.define(
|
||||||
|
'global_transition_defaults',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
transition_type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'fade',
|
||||||
|
validate: {
|
||||||
|
notEmpty: { msg: 'Transition type is required' },
|
||||||
|
isIn: {
|
||||||
|
args: [['fade', 'none']],
|
||||||
|
msg: 'Invalid transition type',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlay_color: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '#000000',
|
||||||
|
validate: {
|
||||||
|
notEmpty: { msg: 'Overlay color is required' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration_ms: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 700,
|
||||||
|
validate: {
|
||||||
|
isInt: { msg: 'Duration must be an integer' },
|
||||||
|
min: {
|
||||||
|
args: [0],
|
||||||
|
msg: 'Duration must be at least 0ms',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'ease-in-out',
|
||||||
|
validate: {
|
||||||
|
notEmpty: { msg: 'Easing is required' },
|
||||||
|
isIn: {
|
||||||
|
args: [['ease-in-out', 'ease-in', 'ease-out', 'linear']],
|
||||||
|
msg: 'Invalid easing function',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
global_transition_defaults.associate = (db) => {
|
||||||
|
db.global_transition_defaults.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.global_transition_defaults.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return global_transition_defaults;
|
||||||
|
};
|
||||||
103
backend/src/db/models/project_transition_settings.js
Normal file
103
backend/src/db/models/project_transition_settings.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const project_transition_settings = sequelize.define(
|
||||||
|
'project_transition_settings',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
environment: {
|
||||||
|
type: DataTypes.ENUM,
|
||||||
|
values: ['dev', 'stage', 'production'],
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
source_key: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
transition_type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'fade',
|
||||||
|
validate: {
|
||||||
|
isIn: {
|
||||||
|
args: [['fade', 'none', 'video']],
|
||||||
|
msg: 'Transition type must be fade, none, or video',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
duration_ms: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 700,
|
||||||
|
validate: {
|
||||||
|
min: { args: [0], msg: 'Duration must be at least 0ms' },
|
||||||
|
max: { args: [10000], msg: 'Duration must be at most 10000ms' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
easing: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'ease-in-out',
|
||||||
|
validate: {
|
||||||
|
isIn: {
|
||||||
|
args: [['ease-in-out', 'ease-in', 'ease-out', 'linear']],
|
||||||
|
msg: 'Easing must be ease-in-out, ease-in, ease-out, or linear',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
overlay_color: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: '#000000',
|
||||||
|
},
|
||||||
|
|
||||||
|
importHash: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['projectId', 'environment'],
|
||||||
|
unique: true,
|
||||||
|
where: { deletedAt: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
project_transition_settings.associate = (db) => {
|
||||||
|
db.project_transition_settings.belongsTo(db.projects, {
|
||||||
|
as: 'project',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'projectId',
|
||||||
|
},
|
||||||
|
constraints: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.project_transition_settings.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.project_transition_settings.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return project_transition_settings;
|
||||||
|
};
|
||||||
@ -65,6 +65,9 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
defaultValue: 1080,
|
defaultValue: 1080,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Note: transition_settings moved to project_transition_settings table
|
||||||
|
// for environment-aware storage (dev, stage, production)
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -172,6 +175,16 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.projects.hasMany(db.project_transition_settings, {
|
||||||
|
as: 'project_transition_settings_project',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'projectId',
|
||||||
|
},
|
||||||
|
constraints: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
});
|
||||||
|
|
||||||
//end loop
|
//end loop
|
||||||
|
|
||||||
db.projects.belongsTo(db.users, {
|
db.projects.belongsTo(db.users, {
|
||||||
|
|||||||
@ -51,6 +51,8 @@ const pwa_cachesRoutes = require('./routes/pwa_caches');
|
|||||||
const access_logsRoutes = require('./routes/access_logs');
|
const access_logsRoutes = require('./routes/access_logs');
|
||||||
const element_type_defaultsRoutes = require('./routes/element_type_defaults');
|
const element_type_defaultsRoutes = require('./routes/element_type_defaults');
|
||||||
const project_element_defaultsRoutes = require('./routes/project_element_defaults');
|
const project_element_defaultsRoutes = require('./routes/project_element_defaults');
|
||||||
|
const global_transition_defaultsRoutes = require('./routes/global_transition_defaults');
|
||||||
|
const project_transition_settingsRoutes = require('./routes/project_transition_settings');
|
||||||
|
|
||||||
const publishRoutes = require('./routes/publish');
|
const publishRoutes = require('./routes/publish');
|
||||||
const runtimeContextRoutes = require('./routes/runtime-context');
|
const runtimeContextRoutes = require('./routes/runtime-context');
|
||||||
@ -235,6 +237,18 @@ app.use(
|
|||||||
jwtAuth,
|
jwtAuth,
|
||||||
project_element_defaultsRoutes,
|
project_element_defaultsRoutes,
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
'/api/global-transition-defaults',
|
||||||
|
jwtAuth,
|
||||||
|
global_transition_defaultsRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Environment-aware project transition settings (supports runtime public access)
|
||||||
|
mountRuntimeEntityRoute(
|
||||||
|
'/api/project-transition-settings',
|
||||||
|
'project_transition_settings',
|
||||||
|
project_transition_settingsRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||||
|
|
||||||
|
|||||||
117
backend/src/routes/global_transition_defaults.js
Normal file
117
backend/src/routes/global_transition_defaults.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Global_transition_defaultsService = require('../services/global_transition_defaults');
|
||||||
|
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
|
||||||
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Use page_elements permission (same as element_type_defaults)
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/global-transition-defaults:
|
||||||
|
* get:
|
||||||
|
* summary: Get global transition defaults (singleton)
|
||||||
|
* tags: [GlobalTransitionDefaults]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Global transition defaults settings
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (_req, res) => {
|
||||||
|
const payload = await Global_transition_defaultsDBApi.findOne();
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/global-transition-defaults/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get global transition defaults by ID
|
||||||
|
* tags: [GlobalTransitionDefaults]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Global transition defaults settings
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
if (!isUuidV4(req.params.id)) {
|
||||||
|
return res.status(400).send('Invalid global_transition_defaults id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await Global_transition_defaultsDBApi.findBy({
|
||||||
|
id: req.params.id,
|
||||||
|
});
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/global-transition-defaults/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update global transition defaults
|
||||||
|
* tags: [GlobalTransitionDefaults]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* transition_type:
|
||||||
|
* type: string
|
||||||
|
* enum: [fade, slide-left, slide-right, zoom, none]
|
||||||
|
* duration_ms:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* easing:
|
||||||
|
* type: string
|
||||||
|
* enum: [ease-in-out, ease-in, ease-out, linear]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Update successful
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
await Global_transition_defaultsService.update(
|
||||||
|
req.body.data,
|
||||||
|
req.body.id,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
357
backend/src/routes/project_transition_settings.js
Normal file
357
backend/src/routes/project_transition_settings.js
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const Project_transition_settingsService = require('../services/project_transition_settings');
|
||||||
|
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
||||||
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Use page_elements permission (same as other element/transition settings)
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* tags:
|
||||||
|
* name: Project_transition_settings
|
||||||
|
* description: Environment-aware project transition settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/project/{projectId}/env/{environment}:
|
||||||
|
* get:
|
||||||
|
* summary: Get transition settings for a project in a specific environment
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: projectId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* - in: path
|
||||||
|
* name: environment
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [dev, stage, production]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Transition settings for the project/environment
|
||||||
|
* 404:
|
||||||
|
* description: No settings found (use global defaults)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/project/:projectId/env/:environment',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
|
if (!isUuidV4(projectId)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid project ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['dev', 'stage', 'production'].includes(environment)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid environment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings =
|
||||||
|
await Project_transition_settingsService.findByProjectAndEnvironment(
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return res.status(404).send({ message: 'No project-specific settings found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(settings);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/project/{projectId}/env/{environment}:
|
||||||
|
* put:
|
||||||
|
* summary: Create or update transition settings for a project in a specific environment
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: projectId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* - in: path
|
||||||
|
* name: environment
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [dev, stage, production]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* transition_type:
|
||||||
|
* type: string
|
||||||
|
* enum: [fade, none, video]
|
||||||
|
* duration_ms:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* easing:
|
||||||
|
* type: string
|
||||||
|
* enum: [ease-in-out, ease-in, ease-out, linear]
|
||||||
|
* overlay_color:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Settings created or updated successfully
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/project/:projectId/env/:environment',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
|
if (!isUuidV4(projectId)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid project ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['dev', 'stage', 'production'].includes(environment)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid environment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await Project_transition_settingsService.upsertForProject(
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
req.body.data || {},
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(settings);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/project/{projectId}/env/{environment}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete transition settings for a project in a specific environment (reverts to global defaults)
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: projectId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* - in: path
|
||||||
|
* name: environment
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [dev, stage, production]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Settings deleted successfully
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/project/:projectId/env/:environment',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
|
if (!isUuidV4(projectId)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid project ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['dev', 'stage', 'production'].includes(environment)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid environment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings =
|
||||||
|
await Project_transition_settingsService.findByProjectAndEnvironment(
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
await Project_transition_settingsService.remove(
|
||||||
|
settings.id,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send({ success: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Get all project transition settings
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of project transition settings
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Project_transition_settingsDBApi.findAll(req.query);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings:
|
||||||
|
* post:
|
||||||
|
* summary: Create new project transition settings
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Settings created successfully
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Project_transition_settingsService.create(
|
||||||
|
req.body.data,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get project transition settings by ID
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Project transition settings
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
if (!isUuidV4(req.params.id)) {
|
||||||
|
return res.status(400).send({ message: 'Invalid ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await Project_transition_settingsDBApi.findBy({
|
||||||
|
id: req.params.id,
|
||||||
|
});
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update project transition settings by ID
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Settings updated successfully
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
await Project_transition_settingsService.update(
|
||||||
|
req.body.data,
|
||||||
|
req.body.id,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/project-transition-settings/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete project transition settings by ID
|
||||||
|
* tags: [Project_transition_settings]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Settings deleted successfully
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
await Project_transition_settingsService.remove(
|
||||||
|
req.params.id,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.use('/', commonErrorHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
6
backend/src/services/global_transition_defaults.js
Normal file
6
backend/src/services/global_transition_defaults.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
|
||||||
|
const { createEntityService } = require('../factories/service.factory');
|
||||||
|
|
||||||
|
module.exports = createEntityService(Global_transition_defaultsDBApi, {
|
||||||
|
entityName: 'global_transition_defaults',
|
||||||
|
});
|
||||||
158
backend/src/services/project_transition_settings.js
Normal file
158
backend/src/services/project_transition_settings.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
const db = require('../db/models');
|
||||||
|
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
|
||||||
|
const processFile = require('../middlewares/upload');
|
||||||
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const csv = require('csv-parser');
|
||||||
|
const stream = require('stream');
|
||||||
|
|
||||||
|
module.exports = class Project_transition_settingsService {
|
||||||
|
static async create(data, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const createdRecord = await Project_transition_settingsDBApi.create(
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return createdRecord;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async bulkImport(req, res) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processFile(req, res);
|
||||||
|
const bufferStream = new stream.PassThrough();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
bufferStream
|
||||||
|
.pipe(csv())
|
||||||
|
.on('data', (data) => results.push(data))
|
||||||
|
.on('end', async () => {
|
||||||
|
console.log('CSV results', results);
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.on('error', (error) => reject(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
await Project_transition_settingsDBApi.bulkImport(results, {
|
||||||
|
transaction,
|
||||||
|
ignoreDuplicates: true,
|
||||||
|
validate: true,
|
||||||
|
currentUser: req.currentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(data, id, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
let record = await Project_transition_settingsDBApi.findBy(
|
||||||
|
{ id },
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new ValidationError('project_transition_settingsNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRecord = await Project_transition_settingsDBApi.update(
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return updatedRecord;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Project_transition_settingsDBApi.deleteByIds(ids, {
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Project_transition_settingsDBApi.remove(id, {
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find settings by project ID and environment
|
||||||
|
*/
|
||||||
|
static async findByProjectAndEnvironment(projectId, environment, currentUser) {
|
||||||
|
return Project_transition_settingsDBApi.findByProjectAndEnvironment(
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
{ currentUser },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update settings for a project/environment combination
|
||||||
|
*/
|
||||||
|
static async upsertForProject(projectId, environment, data, currentUser) {
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const result = await Project_transition_settingsDBApi.upsertForProject(
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -257,7 +257,7 @@ module.exports = class PublishService {
|
|||||||
transaction,
|
transaction,
|
||||||
) {
|
) {
|
||||||
// Get source content
|
// Get source content
|
||||||
const [sourcePages, sourceAudioTracks] = await Promise.all([
|
const [sourcePages, sourceAudioTracks, sourceTransitionSettings] = await Promise.all([
|
||||||
db.tour_pages.findAll({
|
db.tour_pages.findAll({
|
||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
transaction,
|
||||||
@ -266,6 +266,10 @@ module.exports = class PublishService {
|
|||||||
where: { projectId, environment: fromEnv },
|
where: { projectId, environment: fromEnv },
|
||||||
transaction,
|
transaction,
|
||||||
}),
|
}),
|
||||||
|
db.project_transition_settings.findOne({
|
||||||
|
where: { projectId, environment: fromEnv },
|
||||||
|
transaction,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clean up target environment (hard delete - paranoid models need force: true)
|
// Clean up target environment (hard delete - paranoid models need force: true)
|
||||||
@ -280,6 +284,11 @@ module.exports = class PublishService {
|
|||||||
transaction,
|
transaction,
|
||||||
force: true,
|
force: true,
|
||||||
}),
|
}),
|
||||||
|
db.project_transition_settings.destroy({
|
||||||
|
where: { projectId, environment: toEnv },
|
||||||
|
transaction,
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const actorId = currentUser?.id || null;
|
const actorId = currentUser?.id || null;
|
||||||
@ -325,9 +334,26 @@ module.exports = class PublishService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create target transition settings (if source exists)
|
||||||
|
if (sourceTransitionSettings) {
|
||||||
|
const settingsData = sanitizeRecordForClone(sourceTransitionSettings);
|
||||||
|
await db.project_transition_settings.create(
|
||||||
|
{
|
||||||
|
...settingsData,
|
||||||
|
projectId,
|
||||||
|
environment: toEnv,
|
||||||
|
source_key: sourceTransitionSettings.id,
|
||||||
|
createdById: actorId,
|
||||||
|
updatedById: actorId,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pages_copied: sourcePages.length,
|
pages_copied: sourcePages.length,
|
||||||
audios_copied: sourceAudioTracks.length,
|
audios_copied: sourceAudioTracks.length,
|
||||||
|
transition_settings_copied: sourceTransitionSettings ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -32,7 +32,6 @@ interface CanvasBackgroundProps {
|
|||||||
previousBgVideoUrl?: string;
|
previousBgVideoUrl?: string;
|
||||||
isSwitching?: boolean;
|
isSwitching?: boolean;
|
||||||
isNewBgReady?: boolean;
|
isNewBgReady?: boolean;
|
||||||
isFadingIn?: boolean;
|
|
||||||
onBackgroundReady?: () => void;
|
onBackgroundReady?: () => void;
|
||||||
// Video playback settings
|
// Video playback settings
|
||||||
videoAutoplay?: boolean;
|
videoAutoplay?: boolean;
|
||||||
@ -52,7 +51,6 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
previousBgVideoUrl,
|
previousBgVideoUrl,
|
||||||
isSwitching = false,
|
isSwitching = false,
|
||||||
isNewBgReady = false,
|
isNewBgReady = false,
|
||||||
isFadingIn = false,
|
|
||||||
onBackgroundReady,
|
onBackgroundReady,
|
||||||
videoAutoplay = true,
|
videoAutoplay = true,
|
||||||
videoLoop = true,
|
videoLoop = true,
|
||||||
@ -165,15 +163,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Previous background overlays - show during loading AND crossfade.
|
{/* Previous background overlay - shows during loading (z-2) above new background (z-1).
|
||||||
Uses CSS animation for fade-out effect during crossfade.
|
Black overlay for fade effect is rendered separately at higher z-index (TransitionBlackOverlay). */}
|
||||||
z-0 keeps them BELOW new backgrounds (z-1). */}
|
|
||||||
<PreviousBackgroundOverlay
|
<PreviousBackgroundOverlay
|
||||||
imageUrl={previousBgImageUrl}
|
imageUrl={previousBgImageUrl}
|
||||||
videoUrl={previousBgVideoUrl}
|
videoUrl={previousBgVideoUrl}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
isNewBgReady={isNewBgReady}
|
isNewBgReady={isNewBgReady}
|
||||||
isFadingIn={isFadingIn}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5)
|
{/* Background video - z-1 keeps it below backdrop blur layer (z-5)
|
||||||
|
|||||||
@ -324,6 +324,14 @@ export function ElementEditorPanel({
|
|||||||
selectedElement.transitionReverseMode || 'auto_reverse'
|
selectedElement.transitionReverseMode || 'auto_reverse'
|
||||||
}
|
}
|
||||||
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
||||||
|
transitionType={selectedElement.transitionType || ''}
|
||||||
|
transitionDurationMs={
|
||||||
|
selectedElement.transitionDurationMs ?? ''
|
||||||
|
}
|
||||||
|
transitionEasing={selectedElement.transitionEasing || ''}
|
||||||
|
transitionOverlayColor={
|
||||||
|
selectedElement.transitionOverlayColor || ''
|
||||||
|
}
|
||||||
allowedNavigationTypes={allowedNavigationTypes}
|
allowedNavigationTypes={allowedNavigationTypes}
|
||||||
iconAssetOptions={assetOptions.icon}
|
iconAssetOptions={assetOptions.icon}
|
||||||
transitionVideoOptions={assetOptions.transitionVideo}
|
transitionVideoOptions={assetOptions.transitionVideo}
|
||||||
|
|||||||
@ -12,9 +12,24 @@ import type {
|
|||||||
NavigationButtonKind,
|
NavigationButtonKind,
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
|
import type { TransitionType, EasingFunction } from '../../types/transition';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||||
|
|
||||||
|
const CSS_TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Use Project Default' },
|
||||||
|
{ value: 'fade', label: 'Fade' },
|
||||||
|
{ value: 'none', label: 'None (instant)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CSS_EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Use Project Default' },
|
||||||
|
{ value: 'ease-in-out', label: 'Ease In-Out' },
|
||||||
|
{ value: 'ease-in', label: 'Ease In' },
|
||||||
|
{ value: 'ease-out', label: 'Ease Out' },
|
||||||
|
{ value: 'linear', label: 'Linear' },
|
||||||
|
];
|
||||||
|
|
||||||
type NavigationElementType = Extract<
|
type NavigationElementType = Extract<
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
'navigation_next' | 'navigation_prev'
|
'navigation_next' | 'navigation_prev'
|
||||||
@ -31,6 +46,11 @@ interface NavigationSettingsSectionCompactProps {
|
|||||||
transitionVideoUrl: string;
|
transitionVideoUrl: string;
|
||||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||||
reverseVideoUrl: string;
|
reverseVideoUrl: string;
|
||||||
|
// CSS transition settings (used when no video is selected)
|
||||||
|
transitionType?: TransitionType | '';
|
||||||
|
transitionDurationMs?: number | '';
|
||||||
|
transitionEasing?: EasingFunction | '';
|
||||||
|
transitionOverlayColor?: string;
|
||||||
allowedNavigationTypes: NavigationElementType[];
|
allowedNavigationTypes: NavigationElementType[];
|
||||||
iconAssetOptions: AssetOption[];
|
iconAssetOptions: AssetOption[];
|
||||||
transitionVideoOptions: AssetOption[];
|
transitionVideoOptions: AssetOption[];
|
||||||
@ -67,6 +87,10 @@ const NavigationSettingsSectionCompact: React.FC<
|
|||||||
transitionVideoUrl,
|
transitionVideoUrl,
|
||||||
transitionReverseMode,
|
transitionReverseMode,
|
||||||
reverseVideoUrl,
|
reverseVideoUrl,
|
||||||
|
transitionType = '',
|
||||||
|
transitionDurationMs = '',
|
||||||
|
transitionEasing = '',
|
||||||
|
transitionOverlayColor = '',
|
||||||
allowedNavigationTypes,
|
allowedNavigationTypes,
|
||||||
iconAssetOptions,
|
iconAssetOptions,
|
||||||
transitionVideoOptions,
|
transitionVideoOptions,
|
||||||
@ -298,9 +322,99 @@ const NavigationSettingsSectionCompact: React.FC<
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className='text-[11px] text-gray-500'>
|
{/* CSS Transition Settings (when no video selected) */}
|
||||||
Transition duration is set automatically from the selected video.
|
{!transitionVideoUrl && (
|
||||||
</p>
|
<>
|
||||||
|
<p className='mt-2 text-[11px] italic text-gray-500'>
|
||||||
|
No transition video selected. Configure CSS transition instead:
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Transition type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={transitionType}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('transitionType', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{CSS_TRANSITION_TYPES.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Duration (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='Use project default'
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={transitionDurationMs}
|
||||||
|
onChange={(event) => {
|
||||||
|
const val = event.target.value;
|
||||||
|
onChange(
|
||||||
|
'transitionDurationMs',
|
||||||
|
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Easing
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={transitionEasing}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('transitionEasing', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{CSS_EASING_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
|
Overlay color
|
||||||
|
</label>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='color'
|
||||||
|
className='h-7 w-10 cursor-pointer rounded border border-gray-300 p-0.5'
|
||||||
|
value={transitionOverlayColor || '#000000'}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('transitionOverlayColor', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Use project default'
|
||||||
|
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
|
value={transitionOverlayColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange('transitionOverlayColor', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transitionVideoUrl && (
|
||||||
|
<p className='text-[11px] text-gray-500'>
|
||||||
|
Transition duration is set automatically from the selected video.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{onPreviewTransition && (
|
{onPreviewTransition && (
|
||||||
<div className='flex gap-2 pt-1'>
|
<div className='flex gap-2 pt-1'>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* PreviousBackgroundOverlay Component
|
* PreviousBackgroundOverlay Component
|
||||||
*
|
*
|
||||||
* Renders the previous page background during page transitions.
|
* Shows the previous page background during page transitions
|
||||||
* Shows during loading and crossfade, with optional fade-out animation.
|
* while the new background is loading.
|
||||||
* Used by both CanvasBackground (constructor) and RuntimePresentation.
|
*
|
||||||
|
* Used by CanvasBackground component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@ -17,8 +18,6 @@ interface PreviousBackgroundOverlayProps {
|
|||||||
isSwitching?: boolean;
|
isSwitching?: boolean;
|
||||||
/** Whether new background is ready */
|
/** Whether new background is ready */
|
||||||
isNewBgReady?: boolean;
|
isNewBgReady?: boolean;
|
||||||
/** Whether fade animation is in progress */
|
|
||||||
isFadingIn?: boolean;
|
|
||||||
/** Additional CSS classes */
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@ -28,19 +27,19 @@ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
|||||||
videoUrl,
|
videoUrl,
|
||||||
isSwitching = false,
|
isSwitching = false,
|
||||||
isNewBgReady = false,
|
isNewBgReady = false,
|
||||||
isFadingIn = false,
|
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
// Show during loading (isSwitching && !isNewBgReady) OR during crossfade (isFadingIn)
|
// Show previous background during loading (before new bg is ready)
|
||||||
const shouldShow = isFadingIn || (isSwitching && !isNewBgReady);
|
const showPreviousBackground = isSwitching && !isNewBgReady;
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
if (!showPreviousBackground) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Previous background image */}
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
|
className={`pointer-events-none absolute inset-0 z-2 ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("${imageUrl}")`,
|
backgroundImage: `url("${imageUrl}")`,
|
||||||
backgroundSize: 'contain',
|
backgroundSize: 'contain',
|
||||||
@ -49,9 +48,10 @@ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Previous background video */}
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
<video
|
<video
|
||||||
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
|
className={`absolute inset-0 z-2 h-full w-full object-contain pointer-events-none ${className}`}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import CardBox from './CardBox';
|
import CardBox from './CardBox';
|
||||||
@ -24,6 +25,7 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
|||||||
import { BackdropPortalProvider } from './BackdropPortal';
|
import { BackdropPortalProvider } from './BackdropPortal';
|
||||||
import { RotatePrompt } from './RotatePrompt';
|
import { RotatePrompt } from './RotatePrompt';
|
||||||
import CanvasBackground from './Constructor/CanvasBackground';
|
import CanvasBackground from './Constructor/CanvasBackground';
|
||||||
|
import TransitionBlackOverlay from './TransitionBlackOverlay';
|
||||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
@ -49,8 +51,20 @@ import {
|
|||||||
isBackNavigation,
|
isBackNavigation,
|
||||||
isNavigationType,
|
isNavigationType,
|
||||||
} from '../lib/navigationHelpers';
|
} from '../lib/navigationHelpers';
|
||||||
|
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
||||||
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||||
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
|
import {
|
||||||
|
fetchByProjectAndEnv as fetchProjectTransitionSettings,
|
||||||
|
selectByProjectAndEnv as selectProjectTransitionSettings,
|
||||||
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||||
import type { TransitionPhase } from '../types/presentation';
|
import type { TransitionPhase } from '../types/presentation';
|
||||||
import type { CanvasElement } from '../types/constructor';
|
import type { CanvasElement } from '../types/constructor';
|
||||||
|
import type { ElementTransitionSettings } from '../types/transition';
|
||||||
|
import {
|
||||||
|
entityToProjectSettings,
|
||||||
|
extractElementTransitionSettings,
|
||||||
|
} from '../types/transition';
|
||||||
|
|
||||||
interface RuntimePresentationProps {
|
interface RuntimePresentationProps {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
@ -61,7 +75,13 @@ export default function RuntimePresentation({
|
|||||||
projectSlug,
|
projectSlug,
|
||||||
environment,
|
environment,
|
||||||
}: RuntimePresentationProps) {
|
}: RuntimePresentationProps) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const globalTransitionDefaults = useAppSelector(
|
||||||
|
(state) => state.global_transition_defaults.data,
|
||||||
|
);
|
||||||
|
|
||||||
// Use shared hook for loading project and pages data
|
// Use shared hook for loading project and pages data
|
||||||
|
// Note: We can't fetch project transition settings until we have the project ID
|
||||||
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
|
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
|
||||||
{
|
{
|
||||||
projectSlug,
|
projectSlug,
|
||||||
@ -73,6 +93,44 @@ export default function RuntimePresentation({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch global transition defaults on mount
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Fetch project transition settings when project is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (project?.id) {
|
||||||
|
dispatch(
|
||||||
|
fetchProjectTransitionSettings({ projectId: project.id, environment }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatch, project?.id, environment]);
|
||||||
|
|
||||||
|
// Select project transition settings from store (environment-aware)
|
||||||
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
||||||
|
project?.id
|
||||||
|
? selectProjectTransitionSettings(state, project.id, environment)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const projectTransitionSettings = useMemo(
|
||||||
|
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
||||||
|
[projectTransitionSettingsEntity],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track current element's transition settings (set when navigation is triggered)
|
||||||
|
const [
|
||||||
|
currentElementTransitionSettings,
|
||||||
|
setCurrentElementTransitionSettings,
|
||||||
|
] = useState<ElementTransitionSettings | null>(null);
|
||||||
|
|
||||||
|
// Resolve transition settings using the cascade: Element → Project → Global
|
||||||
|
const transitionSettings = useTransitionSettings({
|
||||||
|
globalDefaults: globalTransitionDefaults,
|
||||||
|
projectSettings: projectTransitionSettings,
|
||||||
|
elementSettings: currentElementTransitionSettings,
|
||||||
|
});
|
||||||
|
|
||||||
// Resolve project assets (favicon, og_image, logo) to presigned URLs
|
// Resolve project assets (favicon, og_image, logo) to presigned URLs
|
||||||
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
||||||
|
|
||||||
@ -125,7 +183,6 @@ export default function RuntimePresentation({
|
|||||||
// Safari Black Flash Prevention (video transitions only):
|
// Safari Black Flash Prevention (video transitions only):
|
||||||
// Track the last successfully displayed background to use as a "snapshot" layer.
|
// Track the last successfully displayed background to use as a "snapshot" layer.
|
||||||
// Only shown during video transitions to prevent black flashes.
|
// Only shown during video transitions to prevent black flashes.
|
||||||
// NOT shown during crossfade navigation (would interfere with smooth animation).
|
|
||||||
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
|
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
|
||||||
|
|
||||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
@ -246,18 +303,17 @@ export default function RuntimePresentation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use shared background transition hook for crossfade effects
|
// Use shared background transition hook for fade-from-black effects
|
||||||
// NOTE: fadeOut config is NOT used for video transitions.
|
|
||||||
// Video transitions end instantly (last frame = new page, then overlay removed).
|
// Video transitions end instantly (last frame = new page, then overlay removed).
|
||||||
// fadeIn is used for non-video navigation (crossfade 500ms).
|
// fadeIn controls the black overlay for non-video navigation.
|
||||||
// hasActiveTransition includes pendingTransitionComplete to prevent crossfade
|
// hasActiveTransition prevents fade during video-to-background handoff.
|
||||||
// during the video-to-background handoff phase.
|
const { isFadingIn, resetFadeIn, transitionStyle } = useBackgroundTransition({
|
||||||
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
|
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
hasActiveTransition:
|
hasActiveTransition:
|
||||||
Boolean(transitionPreview) || pendingTransitionComplete,
|
Boolean(transitionPreview) || pendingTransitionComplete,
|
||||||
},
|
},
|
||||||
|
transitionSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(async () => {
|
const toggleFullscreen = useCallback(async () => {
|
||||||
@ -417,10 +473,8 @@ export default function RuntimePresentation({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Direct navigation with crossfade effect:
|
// Direct navigation with fade-from-black effect:
|
||||||
// useBackgroundTransition detects switching and applies animation classes
|
// Page switches instantly, black overlay fades out to reveal new page
|
||||||
// - New page gets animate-crossfade-in (0 → 1)
|
|
||||||
// - Previous background gets animate-crossfade-out (1 → 0)
|
|
||||||
setIsBackgroundReady(false);
|
setIsBackgroundReady(false);
|
||||||
// Mark this page as initialized to prevent redundant effect calls
|
// Mark this page as initialized to prevent redundant effect calls
|
||||||
lastInitializedPageIdRef.current = targetPageId;
|
lastInitializedPageIdRef.current = targetPageId;
|
||||||
@ -482,6 +536,21 @@ export default function RuntimePresentation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (navTarget) {
|
if (navTarget) {
|
||||||
|
// Extract element transition settings for CSS-based transitions
|
||||||
|
// For back navigation, use navTarget's settings (the forward element that brought us here)
|
||||||
|
// For forward navigation, use the clicked element's settings
|
||||||
|
const elementTransitionSource = isBackNavigation(element)
|
||||||
|
? navTarget
|
||||||
|
: element;
|
||||||
|
const elementSettings = extractElementTransitionSettings(
|
||||||
|
elementTransitionSource,
|
||||||
|
);
|
||||||
|
// Use flushSync to ensure state is updated synchronously before transition starts
|
||||||
|
// Without this, React's async state batching causes the transition to use OLD settings
|
||||||
|
flushSync(() => {
|
||||||
|
setCurrentElementTransitionSettings(elementSettings);
|
||||||
|
});
|
||||||
|
|
||||||
navigateToPage(
|
navigateToPage(
|
||||||
navTarget.pageId,
|
navTarget.pageId,
|
||||||
navTarget.transitionVideoUrl,
|
navTarget.transitionVideoUrl,
|
||||||
@ -497,6 +566,7 @@ export default function RuntimePresentation({
|
|||||||
isBuffering,
|
isBuffering,
|
||||||
getNavigationContext,
|
getNavigationContext,
|
||||||
areNeighborBackgroundsReady,
|
areNeighborBackgroundsReady,
|
||||||
|
setCurrentElementTransitionSettings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -664,7 +734,6 @@ export default function RuntimePresentation({
|
|||||||
<BackdropPortalProvider>
|
<BackdropPortalProvider>
|
||||||
{/* Safari Black Flash Prevention (video transitions only):
|
{/* Safari Black Flash Prevention (video transitions only):
|
||||||
Persistent snapshot layer shown ONLY during video transitions.
|
Persistent snapshot layer shown ONLY during video transitions.
|
||||||
NOT shown during crossfade navigation (would interfere with animation).
|
|
||||||
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
|
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
|
||||||
{lastKnownBgUrl &&
|
{lastKnownBgUrl &&
|
||||||
isSafari() &&
|
isSafari() &&
|
||||||
@ -681,11 +750,12 @@ export default function RuntimePresentation({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||||
Fades in for non-transition navigation. Uses shared CanvasBackground component
|
Uses shared CanvasBackground component for single source of truth with constructor.
|
||||||
for single source of truth with constructor (same transitions, same structure). */}
|
Previous background overlay shows during loading.
|
||||||
|
Black overlay for fade effect is rendered separately at z-[100]. */}
|
||||||
<div
|
<div
|
||||||
data-testid='page-background-wrapper'
|
data-testid='page-background-wrapper'
|
||||||
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
className='absolute inset-0 z-5'
|
||||||
>
|
>
|
||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundImageUrl={backgroundImageUrl}
|
backgroundImageUrl={backgroundImageUrl}
|
||||||
@ -694,7 +764,6 @@ export default function RuntimePresentation({
|
|||||||
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||||
isSwitching={pageSwitch.isSwitching}
|
isSwitching={pageSwitch.isSwitching}
|
||||||
isNewBgReady={pageSwitch.isNewBgReady}
|
isNewBgReady={pageSwitch.isNewBgReady}
|
||||||
isFadingIn={isFadingIn}
|
|
||||||
onBackgroundReady={() => {
|
onBackgroundReady={() => {
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
pageSwitch.markBackgroundReady();
|
pageSwitch.markBackgroundReady();
|
||||||
@ -711,10 +780,10 @@ export default function RuntimePresentation({
|
|||||||
|
|
||||||
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
||||||
UI controls (z-50) remain on top.
|
UI controls (z-50) remain on top.
|
||||||
Fades in together with background. */}
|
No fade animation - elements switch instantly behind the black overlay. */}
|
||||||
<div
|
<div
|
||||||
data-testid='page-elements-wrapper'
|
data-testid='page-elements-wrapper'
|
||||||
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
className='absolute inset-0 z-[46]'
|
||||||
>
|
>
|
||||||
{pageElements.map((element: CanvasElement) => (
|
{pageElements.map((element: CanvasElement) => (
|
||||||
<RuntimeElement
|
<RuntimeElement
|
||||||
@ -732,6 +801,16 @@ export default function RuntimePresentation({
|
|||||||
</div>
|
</div>
|
||||||
{/* End page elements wrapper */}
|
{/* End page elements wrapper */}
|
||||||
|
|
||||||
|
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
|
||||||
|
This covers the elements during page transition to hide the instant switch.
|
||||||
|
Only rendered for 'fade' type. */}
|
||||||
|
<TransitionBlackOverlay
|
||||||
|
isFadingIn={isFadingIn}
|
||||||
|
transitionType={transitionSettings.type}
|
||||||
|
transitionStyle={transitionStyle}
|
||||||
|
overlayColor={transitionSettings.overlayColor}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
||||||
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
||||||
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
|
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import {
|
|||||||
mdiFileDocumentPlus,
|
mdiFileDocumentPlus,
|
||||||
mdiSwapHorizontal,
|
mdiSwapHorizontal,
|
||||||
mdiViewDashboard,
|
mdiViewDashboard,
|
||||||
|
mdiChevronDown,
|
||||||
|
mdiChevronUp,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
import Icon from '@mdi/react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@ -17,7 +20,7 @@ import SectionMain from './SectionMain';
|
|||||||
import SectionTitleLineWithButton from './SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from './SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { hasPermission } from '../helpers/userPermissions';
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
||||||
import {
|
import {
|
||||||
@ -26,6 +29,20 @@ import {
|
|||||||
getProjectId,
|
getProjectId,
|
||||||
getRows,
|
getRows,
|
||||||
} from '../lib/tourFlowHelpers';
|
} from '../lib/tourFlowHelpers';
|
||||||
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
|
import {
|
||||||
|
fetchByProjectAndEnv,
|
||||||
|
upsertByProjectAndEnv,
|
||||||
|
deleteByProjectAndEnv,
|
||||||
|
selectByProjectAndEnv,
|
||||||
|
selectIsLoading as selectTransitionSettingsLoading,
|
||||||
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||||
|
import type {
|
||||||
|
ProjectTransitionSettings,
|
||||||
|
TransitionType,
|
||||||
|
EasingFunction,
|
||||||
|
} from '../types/transition';
|
||||||
|
import { entityToProjectSettings } from '../types/transition';
|
||||||
|
|
||||||
type TourPage = {
|
type TourPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -61,9 +78,27 @@ type ListEntry = {
|
|||||||
parentPageId: string;
|
parentPageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Use Global Default' },
|
||||||
|
{ value: 'fade', label: 'Fade' },
|
||||||
|
{ value: 'none', label: 'None (instant)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Use Global Default' },
|
||||||
|
{ value: 'ease-in-out', label: 'Ease In-Out' },
|
||||||
|
{ value: 'ease-in', label: 'Ease In' },
|
||||||
|
{ value: 'ease-out', label: 'Ease Out' },
|
||||||
|
{ value: 'linear', label: 'Linear' },
|
||||||
|
];
|
||||||
|
|
||||||
const TourFlowManager = () => {
|
const TourFlowManager = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const globalDefaults = useAppSelector(
|
||||||
|
(state) => state.global_transition_defaults.data,
|
||||||
|
);
|
||||||
|
|
||||||
const routeProjectId = useMemo(() => {
|
const routeProjectId = useMemo(() => {
|
||||||
const value = router.query.projectId;
|
const value = router.query.projectId;
|
||||||
@ -84,6 +119,36 @@ const TourFlowManager = () => {
|
|||||||
const [deletingId, setDeletingId] = useState('');
|
const [deletingId, setDeletingId] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
// Use selector for current project's dev transition settings
|
||||||
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
||||||
|
selectedProjectId
|
||||||
|
? selectByProjectAndEnv(state, selectedProjectId, 'dev')
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const isTransitionSettingsLoading = useAppSelector((state) =>
|
||||||
|
selectedProjectId
|
||||||
|
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project transition settings state
|
||||||
|
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
|
||||||
|
useState(false);
|
||||||
|
// Convert entity to camelCase for local form state
|
||||||
|
const projectTransitionSettings = useMemo(
|
||||||
|
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
||||||
|
[projectTransitionSettingsEntity],
|
||||||
|
);
|
||||||
|
const [localTransitionType, setLocalTransitionType] = useState<
|
||||||
|
TransitionType | ''
|
||||||
|
>('');
|
||||||
|
const [localDurationMs, setLocalDurationMs] = useState<number | ''>('');
|
||||||
|
const [localEasing, setLocalEasing] = useState<EasingFunction | ''>('');
|
||||||
|
const [localOverlayColor, setLocalOverlayColor] = useState<string>('');
|
||||||
|
const [isSavingTransitionSettings, setIsSavingTransitionSettings] =
|
||||||
|
useState(false);
|
||||||
|
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
|
||||||
|
|
||||||
const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
|
const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
|
||||||
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
||||||
const canDeletePage = hasPermission(currentUser, 'DELETE_TOUR_PAGES');
|
const canDeletePage = hasPermission(currentUser, 'DELETE_TOUR_PAGES');
|
||||||
@ -161,6 +226,38 @@ const TourFlowManager = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Fetch global transition defaults
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Load project transition settings when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
setLocalTransitionType('');
|
||||||
|
setLocalDurationMs('');
|
||||||
|
setLocalEasing('');
|
||||||
|
setLocalOverlayColor('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch fetch for dev environment settings
|
||||||
|
dispatch(
|
||||||
|
fetchByProjectAndEnv({
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
environment: 'dev',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [selectedProjectId, dispatch]);
|
||||||
|
|
||||||
|
// Sync local form state when store data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalTransitionType(projectTransitionSettings?.transitionType ?? '');
|
||||||
|
setLocalDurationMs(projectTransitionSettings?.durationMs ?? '');
|
||||||
|
setLocalEasing(projectTransitionSettings?.easing ?? '');
|
||||||
|
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
|
||||||
|
}, [projectTransitionSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
if (routeProjectId && selectedProjectId === routeProjectId) return;
|
if (routeProjectId && selectedProjectId === routeProjectId) return;
|
||||||
@ -391,6 +488,57 @@ const TourFlowManager = () => {
|
|||||||
toast.info('Transitions are configured directly on navigation elements.');
|
toast.info('Transitions are configured directly on navigation elements.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveTransitionSettings = async () => {
|
||||||
|
if (!selectedProjectId) return;
|
||||||
|
|
||||||
|
setIsSavingTransitionSettings(true);
|
||||||
|
setTransitionSaveSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if all values are empty (should delete to use global defaults)
|
||||||
|
const hasValues =
|
||||||
|
localTransitionType ||
|
||||||
|
localDurationMs !== '' ||
|
||||||
|
localEasing ||
|
||||||
|
localOverlayColor;
|
||||||
|
|
||||||
|
if (!hasValues) {
|
||||||
|
// Delete the settings record to revert to global defaults
|
||||||
|
await dispatch(
|
||||||
|
deleteByProjectAndEnv({
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
environment: 'dev',
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
} else {
|
||||||
|
// Build the settings object with snake_case keys for the backend
|
||||||
|
const settingsToSave = {
|
||||||
|
transition_type: localTransitionType || 'fade',
|
||||||
|
duration_ms:
|
||||||
|
localDurationMs !== '' ? (localDurationMs as number) : 700,
|
||||||
|
easing: localEasing || 'ease-in-out',
|
||||||
|
overlay_color: localOverlayColor || '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatch(
|
||||||
|
upsertByProjectAndEnv({
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
environment: 'dev',
|
||||||
|
data: settingsToSave,
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransitionSaveSuccess(true);
|
||||||
|
setTimeout(() => setTransitionSaveSuccess(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save project transition settings:', error);
|
||||||
|
toast.error('Failed to save transition settings');
|
||||||
|
} finally {
|
||||||
|
setIsSavingTransitionSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (
|
const handleDelete = async (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
id: string,
|
id: string,
|
||||||
@ -486,6 +634,144 @@ const TourFlowManager = () => {
|
|||||||
/>
|
/>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
|
{/* Project Transition Settings */}
|
||||||
|
{selectedProjectId && (
|
||||||
|
<CardBox className='mb-6'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex w-full items-center justify-between text-left'
|
||||||
|
onClick={() =>
|
||||||
|
setIsTransitionSettingsExpanded(!isTransitionSettingsExpanded)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className='text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
||||||
|
Project Transition Settings
|
||||||
|
</h3>
|
||||||
|
<Icon
|
||||||
|
path={
|
||||||
|
isTransitionSettingsExpanded ? mdiChevronUp : mdiChevronDown
|
||||||
|
}
|
||||||
|
size={0.8}
|
||||||
|
className='text-gray-500'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isTransitionSettingsExpanded && (
|
||||||
|
<div className='mt-4'>
|
||||||
|
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
Override global transition defaults for this project (dev
|
||||||
|
environment). Changes are copied to Stage when you "Save
|
||||||
|
to Stage" and to Production when you "Publish".
|
||||||
|
Leave empty to use global defaults.
|
||||||
|
{globalDefaults && (
|
||||||
|
<span className='ml-1'>
|
||||||
|
(Global: {globalDefaults.transition_type},{' '}
|
||||||
|
{globalDefaults.duration_ms}ms, {globalDefaults.easing},{' '}
|
||||||
|
{globalDefaults.overlay_color ?? '#000000'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Transition Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localTransitionType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalTransitionType(
|
||||||
|
e.target.value as TransitionType | '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TRANSITION_TYPES.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Duration (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
placeholder='Use global default'
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localDurationMs}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setLocalDurationMs(
|
||||||
|
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Easing
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localEasing}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalEasing(e.target.value as EasingFunction | '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{EASING_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Overlay Color
|
||||||
|
</label>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='color'
|
||||||
|
className='h-9 w-12 cursor-pointer rounded border border-gray-300 p-0.5 dark:border-dark-600'
|
||||||
|
value={localOverlayColor || '#000000'}
|
||||||
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Use global'
|
||||||
|
className='flex-1 rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localOverlayColor}
|
||||||
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 flex items-center gap-3'>
|
||||||
|
<BaseButton
|
||||||
|
label={
|
||||||
|
isSavingTransitionSettings ? 'Saving...' : 'Save Settings'
|
||||||
|
}
|
||||||
|
color='info'
|
||||||
|
small
|
||||||
|
onClick={handleSaveTransitionSettings}
|
||||||
|
disabled={isSavingTransitionSettings}
|
||||||
|
/>
|
||||||
|
{transitionSaveSuccess && (
|
||||||
|
<span className='text-xs text-green-600'>
|
||||||
|
Saved successfully!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBox>
|
||||||
|
)}
|
||||||
|
|
||||||
<CardBoxModal
|
<CardBoxModal
|
||||||
title='Create page'
|
title='Create page'
|
||||||
buttonColor='info'
|
buttonColor='info'
|
||||||
|
|||||||
53
frontend/src/components/TransitionBlackOverlay.tsx
Normal file
53
frontend/src/components/TransitionBlackOverlay.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* TransitionBlackOverlay Component
|
||||||
|
*
|
||||||
|
* Overlay for fade-through-color page transitions.
|
||||||
|
* Must be rendered at a z-index ABOVE the elements layer to properly cover them.
|
||||||
|
*
|
||||||
|
* The fade-from-color effect:
|
||||||
|
* 1. Page switches instantly (hidden by opaque overlay)
|
||||||
|
* 2. Overlay fades out (1 → 0) revealing new page
|
||||||
|
*
|
||||||
|
* Used by both constructor.tsx and RuntimePresentation.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { TransitionType } from '../types/transition';
|
||||||
|
|
||||||
|
interface TransitionBlackOverlayProps {
|
||||||
|
/** Whether fade animation is in progress */
|
||||||
|
isFadingIn: boolean;
|
||||||
|
/** Transition type - only renders overlay for 'fade' type */
|
||||||
|
transitionType?: TransitionType;
|
||||||
|
/** Inline styles for transition duration/easing (from useBackgroundTransition) */
|
||||||
|
transitionStyle?: React.CSSProperties;
|
||||||
|
/** Overlay color (default: #000000) */
|
||||||
|
overlayColor?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransitionBlackOverlay: React.FC<TransitionBlackOverlayProps> = ({
|
||||||
|
isFadingIn,
|
||||||
|
transitionType = 'fade',
|
||||||
|
transitionStyle,
|
||||||
|
overlayColor = '#000000',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
// Only render overlay for fade transitions
|
||||||
|
// 'none' means instant switch (no animation)
|
||||||
|
// 'video' is handled by TransitionPreviewOverlay
|
||||||
|
if (!isFadingIn || transitionType !== 'fade') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-0 z-[100] animate-fade-from-black ${className}`}
|
||||||
|
style={{
|
||||||
|
...transitionStyle,
|
||||||
|
backgroundColor: overlayColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransitionBlackOverlay;
|
||||||
@ -107,8 +107,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade from black animation - black overlay fades out 1 → 0 */
|
||||||
|
/* Page has already switched, black covers it then reveals new content */
|
||||||
|
@keyframes fade-from-black {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Crossfade animation classes - GPU accelerated for all browsers */
|
/* Crossfade animation classes - GPU accelerated for all browsers */
|
||||||
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */
|
/* Duration controlled by --transition-duration (from JS) or --crossfade-duration (CSS variable) */
|
||||||
|
/* This allows the hierarchical transition settings to override the default CSS duration */
|
||||||
.animate-crossfade-in {
|
.animate-crossfade-in {
|
||||||
/* Explicit initial state prevents flash during animation setup */
|
/* Explicit initial state prevents flash during animation setup */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -117,15 +129,15 @@
|
|||||||
/* Full animation property for maximum browser compatibility */
|
/* Full animation property for maximum browser compatibility */
|
||||||
-webkit-animation-name: page-crossfade-in;
|
-webkit-animation-name: page-crossfade-in;
|
||||||
animation-name: page-crossfade-in;
|
animation-name: page-crossfade-in;
|
||||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
animation-duration: var(--crossfade-duration, 700ms);
|
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
-webkit-animation-timing-function: var(
|
-webkit-animation-timing-function: var(
|
||||||
--crossfade-easing,
|
--transition-easing,
|
||||||
cubic-bezier(0.4, 0, 0.2, 1)
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
);
|
);
|
||||||
animation-timing-function: var(
|
animation-timing-function: var(
|
||||||
--crossfade-easing,
|
--transition-easing,
|
||||||
cubic-bezier(0.4, 0, 0.2, 1)
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
);
|
);
|
||||||
-webkit-animation-fill-mode: forwards;
|
-webkit-animation-fill-mode: forwards;
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: forwards;
|
||||||
@ -146,15 +158,15 @@
|
|||||||
/* Full animation property for maximum browser compatibility */
|
/* Full animation property for maximum browser compatibility */
|
||||||
-webkit-animation-name: page-crossfade-out;
|
-webkit-animation-name: page-crossfade-out;
|
||||||
animation-name: page-crossfade-out;
|
animation-name: page-crossfade-out;
|
||||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
animation-duration: var(--crossfade-duration, 700ms);
|
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
-webkit-animation-timing-function: var(
|
-webkit-animation-timing-function: var(
|
||||||
--crossfade-easing,
|
--transition-easing,
|
||||||
cubic-bezier(0.4, 0, 0.2, 1)
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
);
|
);
|
||||||
animation-timing-function: var(
|
animation-timing-function: var(
|
||||||
--crossfade-easing,
|
--transition-easing,
|
||||||
cubic-bezier(0.4, 0, 0.2, 1)
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
);
|
);
|
||||||
-webkit-animation-fill-mode: forwards;
|
-webkit-animation-fill-mode: forwards;
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: forwards;
|
||||||
@ -190,6 +202,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade from black animation class - for smooth page transitions via black overlay */
|
||||||
|
/* Black starts opaque (hiding page switch), then fades out to reveal new page */
|
||||||
|
.animate-fade-from-black {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
-webkit-animation-name: fade-from-black;
|
||||||
|
animation-name: fade-from-black;
|
||||||
|
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
|
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||||
|
-webkit-animation-timing-function: var(
|
||||||
|
--transition-easing,
|
||||||
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
|
);
|
||||||
|
animation-timing-function: var(
|
||||||
|
--transition-easing,
|
||||||
|
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||||
|
);
|
||||||
|
-webkit-animation-fill-mode: forwards;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: opacity;
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
|
||||||
/* Transition-based crossfade (Safari-optimized alternative to animations) */
|
/* Transition-based crossfade (Safari-optimized alternative to animations) */
|
||||||
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
||||||
when state changes because they interpolate between current and target values */
|
when state changes because they interpolate between current and target values */
|
||||||
@ -213,6 +251,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
Page Transition Animations (Slide, Zoom)
|
||||||
|
|
||||||
|
These animations support the hierarchical transition settings:
|
||||||
|
Global → Project → Element cascade with configurable:
|
||||||
|
- Duration: --transition-duration CSS variable
|
||||||
|
- Easing: --transition-easing CSS variable
|
||||||
|
- Overlay Color: --overlay-color CSS variable (used by TransitionBlackOverlay)
|
||||||
|
|
||||||
|
Animation classes use these variables when present, falling back
|
||||||
|
to --crossfade-duration/--crossfade-easing for compatibility.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
/* Element appear animation keyframes - Safari optimized */
|
/* Element appear animation keyframes - Safari optimized */
|
||||||
@-webkit-keyframes element-fade-in {
|
@-webkit-keyframes element-fade-in {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export type {
|
|||||||
} from './usePageNavigation';
|
} from './usePageNavigation';
|
||||||
export { useBackgroundTransition } from './useBackgroundTransition';
|
export { useBackgroundTransition } from './useBackgroundTransition';
|
||||||
export type {
|
export type {
|
||||||
FadeOutConfig,
|
FadeInConfig,
|
||||||
UseBackgroundTransitionOptions,
|
UseBackgroundTransitionOptions,
|
||||||
UseBackgroundTransitionResult,
|
UseBackgroundTransitionResult,
|
||||||
} from './useBackgroundTransition';
|
} from './useBackgroundTransition';
|
||||||
|
|||||||
@ -2,23 +2,13 @@
|
|||||||
* useBackgroundTransition Hook
|
* useBackgroundTransition Hook
|
||||||
*
|
*
|
||||||
* Manages background transition effects when switching between pages.
|
* Manages background transition effects when switching between pages.
|
||||||
* Handles crossfade animation for non-video navigation and
|
* Controls the fade-from-black overlay for smooth page transitions.
|
||||||
* coordinates with the page switch hook to clear previous backgrounds.
|
|
||||||
*
|
*
|
||||||
* This hook consolidates the background transition logic used by both
|
* When a page switch occurs:
|
||||||
* RuntimePresentation and constructor.tsx.
|
* 1. Page content switches instantly (hidden by black overlay)
|
||||||
|
* 2. Black overlay fades out (1 → 0) revealing new page
|
||||||
*
|
*
|
||||||
* NOTE: Video transitions do NOT use any fades - the video itself IS the transition.
|
* NOTE: Video transitions do NOT use fades - the video itself IS the transition.
|
||||||
* Video last frame = new page background, then overlay is removed instantly.
|
|
||||||
*
|
|
||||||
* Two modes:
|
|
||||||
* 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
|
|
||||||
* 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
|
|
||||||
*
|
|
||||||
* Cross-browser notes:
|
|
||||||
* - Chrome: Uses CSS animations with onAnimationEnd event
|
|
||||||
* - Safari: Uses JS timer fallback (Safari's onAnimationEnd can be unreliable)
|
|
||||||
* - Firefox: Uses CSS animations with onAnimationEnd event
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -28,28 +18,11 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import { isSafari, getCrossfadeDuration } from '../lib/browserUtils';
|
||||||
isSafari,
|
import type { ResolvedTransitionSettings } from '../types/transition';
|
||||||
scheduleAfterPaintSafari,
|
|
||||||
getCrossfadeDuration,
|
|
||||||
} from '../lib/browserUtils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fade-out configuration (optional - for RuntimePresentation)
|
* Fade-in configuration for page content
|
||||||
*/
|
|
||||||
export interface FadeOutConfig {
|
|
||||||
/** Whether a transition video has finished playing and is waiting for bg ready */
|
|
||||||
pendingTransitionComplete: boolean;
|
|
||||||
/** Whether the new background image is ready to display */
|
|
||||||
isBackgroundReady: boolean;
|
|
||||||
/** Ref to the transition video element for cleanup */
|
|
||||||
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
|
|
||||||
/** Callback to clear transition state after overlay is removed */
|
|
||||||
onTransitionCleanup: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade-in configuration (optional - for page content fade-in)
|
|
||||||
*/
|
*/
|
||||||
export interface FadeInConfig {
|
export interface FadeInConfig {
|
||||||
/** Whether a transition video is currently active (disables fade-in) */
|
/** Whether a transition video is currently active (disables fade-in) */
|
||||||
@ -65,85 +38,59 @@ export interface UseBackgroundTransitionOptions {
|
|||||||
previousBgImageUrl: string;
|
previousBgImageUrl: string;
|
||||||
previousBgVideoUrl: string;
|
previousBgVideoUrl: string;
|
||||||
};
|
};
|
||||||
/** Optional fade-out configuration (for RuntimePresentation) */
|
|
||||||
fadeOut?: FadeOutConfig;
|
|
||||||
/** Optional fade-in configuration for page content */
|
/** Optional fade-in configuration for page content */
|
||||||
fadeIn?: FadeInConfig;
|
fadeIn?: FadeInConfig;
|
||||||
|
/** Optional resolved transition settings for dynamic duration/easing */
|
||||||
|
transitionSettings?: ResolvedTransitionSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseBackgroundTransitionResult {
|
export interface UseBackgroundTransitionResult {
|
||||||
/** Whether the overlay is currently fading out */
|
/** Whether page content is currently fading (fade-from-black in progress) */
|
||||||
isOverlayFadingOut: boolean;
|
|
||||||
/** Reset the fade-out state (call before starting a new transition) */
|
|
||||||
resetFadeOut: () => void;
|
|
||||||
/** Whether page content is currently fading (crossfade in progress) */
|
|
||||||
isFadingIn: boolean;
|
isFadingIn: boolean;
|
||||||
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
|
|
||||||
onFadeInAnimationEnd: (e?: React.AnimationEvent) => void;
|
|
||||||
/** Reset fade-in state (for cleanup or cancellation) */
|
/** Reset fade-in state (for cleanup or cancellation) */
|
||||||
resetFadeIn: () => void;
|
resetFadeIn: () => void;
|
||||||
|
/** Inline style for transition duration and easing */
|
||||||
|
transitionStyle: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing background transition effects.
|
* Hook for managing background transition effects.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
* const { isFadingIn, transitionStyle } = useBackgroundTransition({
|
||||||
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
|
||||||
* pageSwitch,
|
* pageSwitch,
|
||||||
* fadeOut: {
|
|
||||||
* pendingTransitionComplete,
|
|
||||||
* isBackgroundReady,
|
|
||||||
* transitionVideoRef,
|
|
||||||
* onTransitionCleanup: () => {
|
|
||||||
* setTransitionPreview(null);
|
|
||||||
* setPendingTransitionComplete(false);
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* fadeIn: {
|
* fadeIn: {
|
||||||
* hasActiveTransition: Boolean(transitionPreview),
|
* hasActiveTransition: Boolean(transitionPreview),
|
||||||
* },
|
* },
|
||||||
|
* transitionSettings,
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // In JSX:
|
* // Render black overlay that fades out
|
||||||
* <div
|
* <TransitionBlackOverlay isFadingIn={isFadingIn} transitionStyle={transitionStyle} />
|
||||||
* className={isFadingIn ? 'animate-crossfade-in' : ''}
|
|
||||||
* onAnimationEnd={onFadeInAnimationEnd}
|
|
||||||
* >
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Simple mode - direct navigation only (constructor)
|
|
||||||
* useBackgroundTransition({ pageSwitch });
|
|
||||||
*/
|
*/
|
||||||
export function useBackgroundTransition({
|
export function useBackgroundTransition({
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
fadeOut,
|
|
||||||
fadeIn,
|
fadeIn,
|
||||||
|
transitionSettings,
|
||||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
|
||||||
const [isFadingIn, setIsFadingIn] = useState(false);
|
const [isFadingIn, setIsFadingIn] = useState(false);
|
||||||
|
|
||||||
// Track previous isSwitching state to detect transition start
|
// Track previous isSwitching state to detect transition start
|
||||||
const wasSwitchingRef = useRef(false);
|
const wasSwitchingRef = useRef(false);
|
||||||
|
|
||||||
// Safari fallback timer ref - Safari's onAnimationEnd can be unreliable
|
// Store transitionSettings in ref to avoid stale closures
|
||||||
|
const transitionSettingsRef = useRef(transitionSettings);
|
||||||
|
transitionSettingsRef.current = transitionSettings;
|
||||||
|
|
||||||
|
// Timer ref for fade completion
|
||||||
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Track if animation was already completed (by event or timer)
|
// Track if animation was already completed
|
||||||
const fadeInCompletedRef = useRef(false);
|
const fadeInCompletedRef = useRef(false);
|
||||||
|
|
||||||
// Track fadeIn config in ref to avoid stale closure issues
|
// Track fadeIn config in ref to avoid stale closure issues
|
||||||
// This allows us to read current values without adding fadeIn to useLayoutEffect deps
|
|
||||||
const fadeInRef = useRef(fadeIn);
|
const fadeInRef = useRef(fadeIn);
|
||||||
fadeInRef.current = fadeIn;
|
fadeInRef.current = fadeIn;
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset fade-out state before starting a new transition.
|
|
||||||
* This prevents the fade-out effect from re-triggering when state resets.
|
|
||||||
*/
|
|
||||||
const resetFadeOut = useCallback(() => {
|
|
||||||
setIsOverlayFadingOut(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset fade-in state (for cleanup or cancellation).
|
* Reset fade-in state (for cleanup or cancellation).
|
||||||
*/
|
*/
|
||||||
@ -158,14 +105,11 @@ export function useBackgroundTransition({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete fade-in animation.
|
* Complete fade-in animation.
|
||||||
* Called either by onAnimationEnd or by timer fallback.
|
|
||||||
* Uses ref to prevent double-completion.
|
|
||||||
*/
|
*/
|
||||||
const completeFadeIn = useCallback(() => {
|
const completeFadeIn = useCallback(() => {
|
||||||
if (fadeInCompletedRef.current) return;
|
if (fadeInCompletedRef.current) return;
|
||||||
fadeInCompletedRef.current = true;
|
fadeInCompletedRef.current = true;
|
||||||
|
|
||||||
// Clear backup timer if it exists
|
|
||||||
if (fadeInTimerRef.current) {
|
if (fadeInTimerRef.current) {
|
||||||
clearTimeout(fadeInTimerRef.current);
|
clearTimeout(fadeInTimerRef.current);
|
||||||
fadeInTimerRef.current = null;
|
fadeInTimerRef.current = null;
|
||||||
@ -174,111 +118,16 @@ export function useBackgroundTransition({
|
|||||||
setIsFadingIn(false);
|
setIsFadingIn(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler for onAnimationEnd event.
|
|
||||||
* Called when CSS animation completes.
|
|
||||||
* Uses completeFadeIn to handle deduplication with timer fallback.
|
|
||||||
*/
|
|
||||||
const onFadeInAnimationEnd = useCallback(
|
|
||||||
(e?: React.AnimationEvent) => {
|
|
||||||
// Only handle the crossfade animation, not child animations
|
|
||||||
if (e && e.animationName !== 'page-crossfade-in') return;
|
|
||||||
completeFadeIn();
|
|
||||||
},
|
|
||||||
[completeFadeIn],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effect: Fade out and remove transition overlay when background is ready.
|
|
||||||
* Only runs when fadeOut config is provided.
|
|
||||||
*
|
|
||||||
* Sequence:
|
|
||||||
* 1. Transition video finishes playing (pendingTransitionComplete = true)
|
|
||||||
* 2. New background image loads (isBackgroundReady = true)
|
|
||||||
* 3. Safari: Wait extra frame to ensure background is painted
|
|
||||||
* 4. Start fade-out animation (isOverlayFadingOut = true)
|
|
||||||
* 5. After fade completes, clean up video and clear transition state
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fadeOut) return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
pendingTransitionComplete,
|
|
||||||
isBackgroundReady,
|
|
||||||
transitionVideoRef,
|
|
||||||
onTransitionCleanup,
|
|
||||||
} = fadeOut;
|
|
||||||
|
|
||||||
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
|
|
||||||
// Safari Black Flash Prevention:
|
|
||||||
// Wait an extra frame in Safari to ensure the new background is truly painted
|
|
||||||
// before starting the fade-out animation. This prevents showing black between
|
|
||||||
// the transition video and the new page content.
|
|
||||||
const startFadeOut = () => {
|
|
||||||
// Start fade-out animation
|
|
||||||
setIsOverlayFadingOut(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Safari: verify paint completion with extra frame wait
|
|
||||||
if (isSafari()) {
|
|
||||||
scheduleAfterPaintSafari(startFadeOut);
|
|
||||||
} else {
|
|
||||||
startFadeOut();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [fadeOut, isOverlayFadingOut]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Effect: Complete fade-out and cleanup after animation duration.
|
|
||||||
* Separated from the start effect to ensure proper cleanup timing.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fadeOut || !isOverlayFadingOut) return;
|
|
||||||
|
|
||||||
const { transitionVideoRef, onTransitionCleanup } = fadeOut;
|
|
||||||
|
|
||||||
// After fade completes, remove the overlay
|
|
||||||
// Duration is read from CSS variable for consistency
|
|
||||||
const fadeTimer = setTimeout(() => {
|
|
||||||
const video = transitionVideoRef.current;
|
|
||||||
if (video) {
|
|
||||||
video.removeAttribute('src');
|
|
||||||
video.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous background from shared hook
|
|
||||||
pageSwitch.clearPreviousBackground();
|
|
||||||
|
|
||||||
// Notify caller to clear transition state
|
|
||||||
onTransitionCleanup();
|
|
||||||
|
|
||||||
// Reset fade-out state
|
|
||||||
setIsOverlayFadingOut(false);
|
|
||||||
}, getCrossfadeDuration());
|
|
||||||
|
|
||||||
return () => clearTimeout(fadeTimer);
|
|
||||||
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Clear previous background overlay after fade completes (direct navigation).
|
* Effect: Clear previous background overlay after fade completes (direct navigation).
|
||||||
*
|
|
||||||
* The previous background stays visible during the entire fade animation,
|
|
||||||
* providing a smooth crossfade effect. Only cleared after fade ends.
|
|
||||||
*
|
|
||||||
* IMPORTANT: Skip this for video transitions - the transition overlay handles
|
|
||||||
* the visual transition, and we'll clear the previous background manually
|
|
||||||
* after the overlay is removed.
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Read from ref to get current hasActiveTransition value
|
|
||||||
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
|
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
|
||||||
|
|
||||||
// Skip clearing during video transitions - let RuntimePresentation handle it
|
// Skip clearing during video transitions
|
||||||
if (hasActiveTransition) return;
|
if (hasActiveTransition) return;
|
||||||
|
|
||||||
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
|
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
|
||||||
// Fade is complete - clear the previous background overlay
|
|
||||||
// This also resets isSwitching state so next navigation triggers fade-in
|
|
||||||
pageSwitch.clearPreviousBackground();
|
pageSwitch.clearPreviousBackground();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -289,25 +138,13 @@ export function useBackgroundTransition({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout effect: Set up crossfade BEFORE browser paints when switching starts.
|
* Layout effect: Start fade-from-black when switching starts.
|
||||||
* useLayoutEffect runs synchronously after DOM mutations but before paint,
|
* useLayoutEffect runs before paint, ensuring overlay appears immediately.
|
||||||
* preventing any flash of new content at full opacity.
|
|
||||||
*
|
|
||||||
* IMPORTANT: Skip this for transitions - transition video IS the effect.
|
|
||||||
*
|
|
||||||
* Cross-browser handling:
|
|
||||||
* - Sets up JS timer fallback for Safari (unreliable onAnimationEnd)
|
|
||||||
* - Chrome/Firefox rely on CSS onAnimationEnd event
|
|
||||||
*
|
|
||||||
* NOTE: We use fadeInRef to read current fadeIn config without adding it to deps.
|
|
||||||
* This prevents the effect from re-running on every render when the caller
|
|
||||||
* creates fadeIn config inline (which would reset wasSwitchingRef incorrectly).
|
|
||||||
*/
|
*/
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// Read from ref to get latest value without triggering re-runs
|
|
||||||
const currentFadeIn = fadeInRef.current;
|
const currentFadeIn = fadeInRef.current;
|
||||||
|
|
||||||
// Skip crossfade logic if fadeIn config was not provided
|
// Skip if fadeIn config was not provided
|
||||||
if (!currentFadeIn) {
|
if (!currentFadeIn) {
|
||||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||||
return;
|
return;
|
||||||
@ -318,13 +155,10 @@ export function useBackgroundTransition({
|
|||||||
|
|
||||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||||
|
|
||||||
// Only start crossfade for NON-transition navigation
|
// Only start fade for NON-transition navigation
|
||||||
// Transitions use video overlay - no fade needed
|
|
||||||
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
|
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
|
||||||
// Reset completion flag for new animation
|
|
||||||
fadeInCompletedRef.current = false;
|
fadeInCompletedRef.current = false;
|
||||||
|
|
||||||
// Clear any existing timer
|
|
||||||
if (fadeInTimerRef.current) {
|
if (fadeInTimerRef.current) {
|
||||||
clearTimeout(fadeInTimerRef.current);
|
clearTimeout(fadeInTimerRef.current);
|
||||||
fadeInTimerRef.current = null;
|
fadeInTimerRef.current = null;
|
||||||
@ -332,12 +166,10 @@ export function useBackgroundTransition({
|
|||||||
|
|
||||||
setIsFadingIn(true);
|
setIsFadingIn(true);
|
||||||
|
|
||||||
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
|
// Timer to end fade after animation duration
|
||||||
// can be unreliable or fire on wrong animations.
|
const duration = getCrossfadeDuration(
|
||||||
// Timer is slightly longer than CSS duration to let CSS complete first.
|
transitionSettingsRef.current?.durationMs,
|
||||||
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
|
);
|
||||||
const duration = getCrossfadeDuration();
|
|
||||||
// Add 50ms buffer for Safari's animation timing variance
|
|
||||||
const bufferMs = isSafari() ? 100 : 50;
|
const bufferMs = isSafari() ? 100 : 50;
|
||||||
|
|
||||||
fadeInTimerRef.current = setTimeout(() => {
|
fadeInTimerRef.current = setTimeout(() => {
|
||||||
@ -345,8 +177,6 @@ export function useBackgroundTransition({
|
|||||||
completeFadeIn();
|
completeFadeIn();
|
||||||
}, duration + bufferMs);
|
}, duration + bufferMs);
|
||||||
}
|
}
|
||||||
// NOTE: fadeIn intentionally NOT in deps - we read from fadeInRef instead
|
|
||||||
// to avoid re-running when inline fadeIn object is recreated
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [pageSwitch.isSwitching, completeFadeIn]);
|
}, [pageSwitch.isSwitching, completeFadeIn]);
|
||||||
|
|
||||||
@ -360,11 +190,15 @@ export function useBackgroundTransition({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const transitionStyle: React.CSSProperties = {
|
||||||
|
'--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`,
|
||||||
|
'--transition-easing': transitionSettings?.easing ?? 'ease-in-out',
|
||||||
|
'--overlay-color': transitionSettings?.overlayColor ?? '#000000',
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOverlayFadingOut,
|
|
||||||
resetFadeOut,
|
|
||||||
isFadingIn,
|
isFadingIn,
|
||||||
onFadeInAnimationEnd,
|
|
||||||
resetFadeIn,
|
resetFadeIn,
|
||||||
|
transitionStyle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const EMPTY_PAGES: TourPage[] = [];
|
|||||||
const EMPTY_ASSETS: Asset[] = [];
|
const EMPTY_ASSETS: Asset[] = [];
|
||||||
|
|
||||||
interface UseConstructorDataResult {
|
interface UseConstructorDataResult {
|
||||||
// Project
|
// Project (note: transition_settings now fetched separately via project_transition_settings store)
|
||||||
project: {
|
project: {
|
||||||
name: string;
|
name: string;
|
||||||
design_width?: number;
|
design_width?: number;
|
||||||
|
|||||||
175
frontend/src/hooks/useTransitionSettings.ts
Normal file
175
frontend/src/hooks/useTransitionSettings.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* useTransitionSettings Hook
|
||||||
|
*
|
||||||
|
* Resolves transition settings by cascading through three levels:
|
||||||
|
* Element → Project → Global (fallback)
|
||||||
|
*
|
||||||
|
* Video transitions always take precedence over CSS-based transitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
GlobalTransitionDefaults,
|
||||||
|
ProjectTransitionSettings,
|
||||||
|
ElementTransitionSettings,
|
||||||
|
ResolvedTransitionSettings,
|
||||||
|
TransitionType,
|
||||||
|
EasingFunction,
|
||||||
|
DEFAULT_TRANSITION_SETTINGS,
|
||||||
|
} from '../types/transition';
|
||||||
|
|
||||||
|
export interface UseTransitionSettingsParams {
|
||||||
|
/** Global defaults (from global_transition_defaults table) */
|
||||||
|
globalDefaults: GlobalTransitionDefaults | null;
|
||||||
|
/** Project-level settings (from project_transition_settings table, environment-aware) */
|
||||||
|
projectSettings?: ProjectTransitionSettings | null;
|
||||||
|
/** Element-level settings (from ui_schema_json) */
|
||||||
|
elementSettings?: ElementTransitionSettings | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default values used when no settings are available at any level
|
||||||
|
*/
|
||||||
|
const FALLBACK_DEFAULTS = {
|
||||||
|
type: 'fade' as TransitionType,
|
||||||
|
durationMs: 700,
|
||||||
|
easing: 'ease-in-out' as EasingFunction,
|
||||||
|
overlayColor: '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to resolve transition settings with cascade:
|
||||||
|
* Element → Project → Global → Fallback defaults
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Project settings are fetched from project_transition_settings store
|
||||||
|
* // and converted using entityToProjectSettings() helper
|
||||||
|
* const transitionSettings = useTransitionSettings({
|
||||||
|
* globalDefaults,
|
||||||
|
* projectSettings, // from projectTransitionSettingsSlice (environment-aware)
|
||||||
|
* elementSettings: element?.transitionSettings,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Use resolved settings
|
||||||
|
* if (transitionSettings.type === 'video') {
|
||||||
|
* // Play video transition
|
||||||
|
* } else {
|
||||||
|
* // Apply CSS transition with transitionSettings.durationMs and transitionSettings.easing
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTransitionSettings({
|
||||||
|
globalDefaults,
|
||||||
|
projectSettings,
|
||||||
|
elementSettings,
|
||||||
|
}: UseTransitionSettingsParams): ResolvedTransitionSettings {
|
||||||
|
return useMemo(() => {
|
||||||
|
// Video transitions always take precedence
|
||||||
|
if (elementSettings?.transitionVideoUrl) {
|
||||||
|
return {
|
||||||
|
type: 'video' as TransitionType,
|
||||||
|
durationMs:
|
||||||
|
elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs,
|
||||||
|
easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing,
|
||||||
|
overlayColor:
|
||||||
|
elementSettings.transitionOverlayColor ??
|
||||||
|
projectSettings?.overlayColor ??
|
||||||
|
globalDefaults?.overlay_color ??
|
||||||
|
FALLBACK_DEFAULTS.overlayColor,
|
||||||
|
videoUrl: elementSettings.transitionVideoUrl,
|
||||||
|
reverseVideoUrl: elementSettings.reverseVideoUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade: Element → Project → Global → Fallback
|
||||||
|
const type: TransitionType =
|
||||||
|
elementSettings?.transitionType ??
|
||||||
|
projectSettings?.transitionType ??
|
||||||
|
globalDefaults?.transition_type ??
|
||||||
|
FALLBACK_DEFAULTS.type;
|
||||||
|
|
||||||
|
const durationMs: number =
|
||||||
|
elementSettings?.transitionDurationMs ??
|
||||||
|
projectSettings?.durationMs ??
|
||||||
|
globalDefaults?.duration_ms ??
|
||||||
|
FALLBACK_DEFAULTS.durationMs;
|
||||||
|
|
||||||
|
const easing: EasingFunction =
|
||||||
|
elementSettings?.transitionEasing ??
|
||||||
|
projectSettings?.easing ??
|
||||||
|
globalDefaults?.easing ??
|
||||||
|
FALLBACK_DEFAULTS.easing;
|
||||||
|
|
||||||
|
const overlayColor: string =
|
||||||
|
elementSettings?.transitionOverlayColor ??
|
||||||
|
projectSettings?.overlayColor ??
|
||||||
|
globalDefaults?.overlay_color ??
|
||||||
|
FALLBACK_DEFAULTS.overlayColor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
durationMs,
|
||||||
|
easing,
|
||||||
|
overlayColor,
|
||||||
|
};
|
||||||
|
}, [globalDefaults, projectSettings, elementSettings]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve transition settings without React hook (for non-component contexts)
|
||||||
|
*/
|
||||||
|
export function resolveTransitionSettings({
|
||||||
|
globalDefaults,
|
||||||
|
projectSettings,
|
||||||
|
elementSettings,
|
||||||
|
}: UseTransitionSettingsParams): ResolvedTransitionSettings {
|
||||||
|
// Video transitions always take precedence
|
||||||
|
if (elementSettings?.transitionVideoUrl) {
|
||||||
|
return {
|
||||||
|
type: 'video' as TransitionType,
|
||||||
|
durationMs:
|
||||||
|
elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs,
|
||||||
|
easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing,
|
||||||
|
overlayColor:
|
||||||
|
elementSettings.transitionOverlayColor ??
|
||||||
|
projectSettings?.overlayColor ??
|
||||||
|
globalDefaults?.overlay_color ??
|
||||||
|
FALLBACK_DEFAULTS.overlayColor,
|
||||||
|
videoUrl: elementSettings.transitionVideoUrl,
|
||||||
|
reverseVideoUrl: elementSettings.reverseVideoUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade: Element → Project → Global → Fallback
|
||||||
|
const type: TransitionType =
|
||||||
|
elementSettings?.transitionType ??
|
||||||
|
projectSettings?.transitionType ??
|
||||||
|
globalDefaults?.transition_type ??
|
||||||
|
FALLBACK_DEFAULTS.type;
|
||||||
|
|
||||||
|
const durationMs: number =
|
||||||
|
elementSettings?.transitionDurationMs ??
|
||||||
|
projectSettings?.durationMs ??
|
||||||
|
globalDefaults?.duration_ms ??
|
||||||
|
FALLBACK_DEFAULTS.durationMs;
|
||||||
|
|
||||||
|
const easing: EasingFunction =
|
||||||
|
elementSettings?.transitionEasing ??
|
||||||
|
projectSettings?.easing ??
|
||||||
|
globalDefaults?.easing ??
|
||||||
|
FALLBACK_DEFAULTS.easing;
|
||||||
|
|
||||||
|
const overlayColor: string =
|
||||||
|
elementSettings?.transitionOverlayColor ??
|
||||||
|
projectSettings?.overlayColor ??
|
||||||
|
globalDefaults?.overlay_color ??
|
||||||
|
FALLBACK_DEFAULTS.overlayColor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
durationMs,
|
||||||
|
easing,
|
||||||
|
overlayColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -167,12 +167,19 @@ export const scheduleAfterPaint = (callback: () => void): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get crossfade duration from CSS custom property.
|
* Get crossfade duration from CSS custom property or override.
|
||||||
* Single source of truth: CSS variable --crossfade-duration in main.css.
|
* Single source of truth: CSS variable --crossfade-duration in main.css,
|
||||||
|
* unless an explicit override is provided.
|
||||||
*
|
*
|
||||||
* @returns Duration in milliseconds (default: 500ms)
|
* @param overrideMs - Optional explicit duration in milliseconds (for hierarchical transition settings)
|
||||||
|
* @returns Duration in milliseconds (default: 700ms)
|
||||||
*/
|
*/
|
||||||
export const getCrossfadeDuration = (): number => {
|
export const getCrossfadeDuration = (overrideMs?: number): number => {
|
||||||
|
// If explicit override provided, use it
|
||||||
|
if (overrideMs !== undefined && overrideMs >= 0) {
|
||||||
|
return overrideMs;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') return 700; // SSR fallback
|
if (typeof window === 'undefined') return 700; // SSR fallback
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
||||||
|
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
|
||||||
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
|
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
|
||||||
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
||||||
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
||||||
@ -27,6 +29,18 @@ import { usePageNavigation } from '../hooks/usePageNavigation';
|
|||||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||||
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
|
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
|
||||||
|
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
|
import {
|
||||||
|
fetchByProjectAndEnv as fetchProjectTransitionSettings,
|
||||||
|
selectByProjectAndEnv as selectProjectTransitionSettings,
|
||||||
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
||||||
|
import type { ElementTransitionSettings } from '../types/transition';
|
||||||
|
import {
|
||||||
|
entityToProjectSettings,
|
||||||
|
extractElementTransitionSettings,
|
||||||
|
} from '../types/transition';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { parseJsonObject } from '../lib/parseJson';
|
import { parseJsonObject } from '../lib/parseJson';
|
||||||
@ -113,6 +127,10 @@ const labelByType = ELEMENT_TYPE_LABELS;
|
|||||||
|
|
||||||
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const globalTransitionDefaults = useAppSelector(
|
||||||
|
(state) => state.global_transition_defaults.data,
|
||||||
|
);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@ -130,6 +148,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Project transition settings from environment-aware store (constructor always uses 'dev')
|
||||||
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
||||||
|
projectId
|
||||||
|
? selectProjectTransitionSettings(state, projectId, 'dev')
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const projectTransitionSettings = useMemo(
|
||||||
|
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
||||||
|
[projectTransitionSettingsEntity],
|
||||||
|
);
|
||||||
|
|
||||||
const pageIdFromRoute = useMemo(() => {
|
const pageIdFromRoute = useMemo(() => {
|
||||||
const value = router.query.pageId;
|
const value = router.query.pageId;
|
||||||
if (Array.isArray(value)) return value[0] || '';
|
if (Array.isArray(value)) return value[0] || '';
|
||||||
@ -205,6 +234,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch global transition defaults on mount
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Fetch project transition settings for dev environment
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(
|
||||||
|
fetchProjectTransitionSettings({ projectId, environment: 'dev' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatch, projectId]);
|
||||||
|
|
||||||
const [selectedMenuItem, setSelectedMenuItem] =
|
const [selectedMenuItem, setSelectedMenuItem] =
|
||||||
useState<EditorMenuItem>('none');
|
useState<EditorMenuItem>('none');
|
||||||
// Transition preview state managed by useTransitionPreview hook (below)
|
// Transition preview state managed by useTransitionPreview hook (below)
|
||||||
@ -248,6 +291,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
// Current element transition settings (for CSS transitions when no video)
|
||||||
|
const [
|
||||||
|
currentElementTransitionSettings,
|
||||||
|
setCurrentElementTransitionSettings,
|
||||||
|
] = useState<ElementTransitionSettings | null>(null);
|
||||||
|
|
||||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
@ -335,13 +383,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Last project-level save: most recent updatedAt across all pages
|
// Last project-level save: most recent updatedAt across all pages
|
||||||
const lastProjectSaveAt = useMemo(() => {
|
const lastProjectSaveAt = useMemo(() => {
|
||||||
if (!pages.length) return null;
|
if (!pages.length) return null;
|
||||||
return pages.reduce((latest, page) => {
|
return pages.reduce(
|
||||||
if (!page.updatedAt) return latest;
|
(latest, page) => {
|
||||||
if (!latest) return page.updatedAt;
|
if (!page.updatedAt) return latest;
|
||||||
return new Date(page.updatedAt) > new Date(latest)
|
if (!latest) return page.updatedAt;
|
||||||
? page.updatedAt
|
return new Date(page.updatedAt) > new Date(latest)
|
||||||
: latest;
|
? page.updatedAt
|
||||||
}, null as string | null);
|
: latest;
|
||||||
|
},
|
||||||
|
null as string | null,
|
||||||
|
);
|
||||||
}, [pages]);
|
}, [pages]);
|
||||||
|
|
||||||
// Transition preview state management
|
// Transition preview state management
|
||||||
@ -400,10 +451,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
||||||
const pageSwitchToPage = pageSwitch.switchToPage;
|
const pageSwitchToPage = pageSwitch.switchToPage;
|
||||||
|
|
||||||
// Use shared background transition hook for direct navigation clearing and crossfade
|
// Resolve transition settings using cascade: element → project → global
|
||||||
// Crossfade starts automatically when new background is ready
|
const transitionSettings = useTransitionSettings({
|
||||||
const { isFadingIn } = useBackgroundTransition({
|
globalDefaults: globalTransitionDefaults,
|
||||||
|
projectSettings: projectTransitionSettings ?? null,
|
||||||
|
elementSettings: currentElementTransitionSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use shared background transition hook for fade-from-black effect
|
||||||
|
// Black overlay fades out when page switches
|
||||||
|
const { isFadingIn, transitionStyle } = useBackgroundTransition({
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
|
transitionSettings,
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
hasActiveTransition: Boolean(transitionPreview),
|
hasActiveTransition: Boolean(transitionPreview),
|
||||||
},
|
},
|
||||||
@ -464,7 +523,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateBackgroundFromPage(page);
|
updateBackgroundFromPage(page);
|
||||||
|
|
||||||
// Use hook to resolve and set blob URLs for display
|
// Use hook to resolve and set blob URLs for display
|
||||||
// Fade starts automatically when new background is ready (crossfade effect)
|
// Fade-from-black starts automatically when page switches
|
||||||
await pageSwitchToPage(
|
await pageSwitchToPage(
|
||||||
page
|
page
|
||||||
? {
|
? {
|
||||||
@ -1198,6 +1257,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}
|
}
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
|
// Extract element transition settings for CSS-based transitions
|
||||||
|
// For back navigation, use navTarget's settings (the forward element that brought us here)
|
||||||
|
// For forward navigation, use the clicked element's settings
|
||||||
|
const elementTransitionSource = isBackNavigation(element)
|
||||||
|
? navTarget
|
||||||
|
: element;
|
||||||
|
const elementSettings = extractElementTransitionSettings(
|
||||||
|
elementTransitionSource,
|
||||||
|
);
|
||||||
|
// Use flushSync to ensure state is updated synchronously before transition starts
|
||||||
|
// Without this, React's async state batching causes the transition to use OLD settings
|
||||||
|
flushSync(() => {
|
||||||
|
setCurrentElementTransitionSettings(elementSettings);
|
||||||
|
});
|
||||||
|
|
||||||
// Check if transition can be played using shared helper
|
// Check if transition can be played using shared helper
|
||||||
if (!hasPlayableTransition(transitionSource, direction)) {
|
if (!hasPlayableTransition(transitionSource, direction)) {
|
||||||
closeTransitionPreview();
|
closeTransitionPreview();
|
||||||
@ -1594,10 +1668,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BackdropPortalProvider>
|
<BackdropPortalProvider>
|
||||||
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10) */}
|
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||||
<div
|
Previous background overlay shows during loading.
|
||||||
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
Black overlay for fade effect is rendered separately at z-[100]. */}
|
||||||
>
|
<div className='absolute inset-0 z-5'>
|
||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundImageUrl={backgroundImageSrc}
|
backgroundImageUrl={backgroundImageSrc}
|
||||||
backgroundVideoUrl={backgroundVideoSrc}
|
backgroundVideoUrl={backgroundVideoSrc}
|
||||||
@ -1606,7 +1680,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||||
isSwitching={pageSwitch.isSwitching}
|
isSwitching={pageSwitch.isSwitching}
|
||||||
isNewBgReady={pageSwitch.isNewBgReady}
|
isNewBgReady={pageSwitch.isNewBgReady}
|
||||||
isFadingIn={isFadingIn}
|
|
||||||
onBackgroundReady={() => {
|
onBackgroundReady={() => {
|
||||||
pageSwitch.markBackgroundReady();
|
pageSwitch.markBackgroundReady();
|
||||||
setIsBackgroundReady(true);
|
setIsBackgroundReady(true);
|
||||||
@ -1623,10 +1696,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
||||||
UI controls (z-50) remain on top. */}
|
UI controls (z-50) remain on top.
|
||||||
<div
|
No fade animation - elements switch instantly behind the black overlay. */}
|
||||||
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
<div className='absolute inset-0 z-[46]'>
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
<p className='text-sm text-gray-500'>
|
<p className='text-sm text-gray-500'>
|
||||||
@ -1697,6 +1769,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
|
||||||
|
This covers the elements during page transition to hide the instant switch.
|
||||||
|
Only rendered for 'fade' type. */}
|
||||||
|
<TransitionBlackOverlay
|
||||||
|
isFadingIn={isFadingIn}
|
||||||
|
transitionType={transitionSettings.type}
|
||||||
|
transitionStyle={transitionStyle}
|
||||||
|
overlayColor={transitionSettings.overlayColor}
|
||||||
|
/>
|
||||||
</BackdropPortalProvider>
|
</BackdropPortalProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,20 @@ import CardBox from '../components/CardBox';
|
|||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
import SectionMain from '../components/SectionMain';
|
import SectionMain from '../components/SectionMain';
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
import {
|
||||||
|
fetch as fetchGlobalTransitionDefaults,
|
||||||
|
update as updateGlobalTransitionDefaults,
|
||||||
|
} from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
import type { UiElementDefault } from '../types/constructor';
|
import type { UiElementDefault } from '../types/constructor';
|
||||||
|
import type {
|
||||||
|
GlobalTransitionDefaults,
|
||||||
|
TransitionType,
|
||||||
|
EasingFunction,
|
||||||
|
} from '../types/transition';
|
||||||
|
|
||||||
type ElementTypeDefault = UiElementDefault & {
|
type ElementTypeDefault = UiElementDefault & {
|
||||||
element_type: string;
|
element_type: string;
|
||||||
@ -23,11 +34,81 @@ const toHumanLabel = (value: string) =>
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const TRANSITION_TYPES: { value: TransitionType; label: string }[] = [
|
||||||
|
{ value: 'fade', label: 'Fade' },
|
||||||
|
{ value: 'none', label: 'None (instant)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
|
||||||
|
{ value: 'ease-in-out', label: 'Ease In-Out' },
|
||||||
|
{ value: 'ease-in', label: 'Ease In' },
|
||||||
|
{ value: 'ease-out', label: 'Ease Out' },
|
||||||
|
{ value: 'linear', label: 'Linear' },
|
||||||
|
];
|
||||||
|
|
||||||
const ElementTypeDefaultsPage = () => {
|
const ElementTypeDefaultsPage = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const globalDefaults = useAppSelector(
|
||||||
|
(state) => state.global_transition_defaults.data,
|
||||||
|
);
|
||||||
|
const globalLoading = useAppSelector(
|
||||||
|
(state) => state.global_transition_defaults.loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local state for global transition defaults editing
|
||||||
|
const [localTransitionType, setLocalTransitionType] =
|
||||||
|
useState<TransitionType>('fade');
|
||||||
|
const [localDurationMs, setLocalDurationMs] = useState<number>(700);
|
||||||
|
const [localEasing, setLocalEasing] = useState<EasingFunction>('ease-in-out');
|
||||||
|
const [localOverlayColor, setLocalOverlayColor] = useState<string>('#000000');
|
||||||
|
const [isSavingGlobal, setIsSavingGlobal] = useState(false);
|
||||||
|
const [globalSaveSuccess, setGlobalSaveSuccess] = useState(false);
|
||||||
|
|
||||||
const [rows, setRows] = useState<ElementTypeDefault[]>([]);
|
const [rows, setRows] = useState<ElementTypeDefault[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
// Load global transition defaults
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Sync local state when global defaults are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalDefaults) {
|
||||||
|
setLocalTransitionType(globalDefaults.transition_type);
|
||||||
|
setLocalDurationMs(globalDefaults.duration_ms);
|
||||||
|
setLocalEasing(globalDefaults.easing);
|
||||||
|
setLocalOverlayColor(globalDefaults.overlay_color ?? '#000000');
|
||||||
|
}
|
||||||
|
}, [globalDefaults]);
|
||||||
|
|
||||||
|
const handleSaveGlobalDefaults = async () => {
|
||||||
|
if (!globalDefaults?.id) return;
|
||||||
|
|
||||||
|
setIsSavingGlobal(true);
|
||||||
|
setGlobalSaveSuccess(false);
|
||||||
|
try {
|
||||||
|
await dispatch(
|
||||||
|
updateGlobalTransitionDefaults({
|
||||||
|
id: globalDefaults.id,
|
||||||
|
data: {
|
||||||
|
transition_type: localTransitionType,
|
||||||
|
duration_ms: localDurationMs,
|
||||||
|
easing: localEasing,
|
||||||
|
overlay_color: localOverlayColor,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
setGlobalSaveSuccess(true);
|
||||||
|
setTimeout(() => setGlobalSaveSuccess(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save global transition defaults:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSavingGlobal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadRows = useCallback(async () => {
|
const loadRows = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -78,6 +159,114 @@ const ElementTypeDefaultsPage = () => {
|
|||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
{/* Global Transition Defaults Section */}
|
||||||
|
<CardBox className='mb-6'>
|
||||||
|
<h3 className='mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
||||||
|
Global Transition Defaults
|
||||||
|
</h3>
|
||||||
|
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
These settings apply to all page transitions unless overridden at
|
||||||
|
the project or element level.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{globalLoading && !globalDefaults ? (
|
||||||
|
<p className='text-sm text-gray-500'>
|
||||||
|
Loading global transition defaults...
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Transition Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localTransitionType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalTransitionType(e.target.value as TransitionType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TRANSITION_TYPES.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Duration (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localDurationMs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalDurationMs(
|
||||||
|
Math.max(0, parseInt(e.target.value, 10) || 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Easing
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localEasing}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalEasing(e.target.value as EasingFunction)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{EASING_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
||||||
|
Overlay Color
|
||||||
|
</label>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='color'
|
||||||
|
className='h-9 w-12 cursor-pointer rounded border border-gray-300 p-0.5 dark:border-dark-600'
|
||||||
|
value={localOverlayColor}
|
||||||
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='flex-1 rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
||||||
|
value={localOverlayColor}
|
||||||
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
||||||
|
placeholder='#000000'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='mt-4 flex items-center gap-3'>
|
||||||
|
<BaseButton
|
||||||
|
label={isSavingGlobal ? 'Saving...' : 'Save Global Defaults'}
|
||||||
|
color='info'
|
||||||
|
small
|
||||||
|
onClick={handleSaveGlobalDefaults}
|
||||||
|
disabled={isSavingGlobal || !globalDefaults}
|
||||||
|
/>
|
||||||
|
{globalSaveSuccess && (
|
||||||
|
<span className='text-xs text-green-600'>
|
||||||
|
Saved successfully!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
{/* Element Types List */}
|
||||||
<CardBox>
|
<CardBox>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className='text-sm text-gray-500'>
|
<p className='text-sm text-gray-500'>
|
||||||
|
|||||||
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Global Transition Defaults Redux Slice
|
||||||
|
*
|
||||||
|
* Custom slice for singleton entity - always returns a single object, not an array.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import {
|
||||||
|
fulfilledNotify,
|
||||||
|
rejectNotify,
|
||||||
|
resetNotify,
|
||||||
|
} from '../../helpers/notifyStateHandler';
|
||||||
|
import type { ApiError } from '../../types/api';
|
||||||
|
import type { GlobalTransitionDefaults } from '../../types/transition';
|
||||||
|
|
||||||
|
interface NotifyState {
|
||||||
|
showNotification: boolean;
|
||||||
|
textNotification: string;
|
||||||
|
typeNotification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalTransitionDefaultsState {
|
||||||
|
data: GlobalTransitionDefaults | null;
|
||||||
|
loading: boolean;
|
||||||
|
notify: NotifyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: GlobalTransitionDefaultsState = {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
notify: {
|
||||||
|
showNotification: false,
|
||||||
|
textNotification: '',
|
||||||
|
typeNotification: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type guard for axios error
|
||||||
|
function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
||||||
|
return axios.isAxiosError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch singleton thunk
|
||||||
|
export const fetch = createAsyncThunk<
|
||||||
|
GlobalTransitionDefaults,
|
||||||
|
void,
|
||||||
|
{ rejectValue: ApiError }
|
||||||
|
>('global_transition_defaults/fetch', async () => {
|
||||||
|
const result = await axios.get<GlobalTransitionDefaults>(
|
||||||
|
'global-transition-defaults',
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update thunk
|
||||||
|
export const update = createAsyncThunk<
|
||||||
|
GlobalTransitionDefaults,
|
||||||
|
{ id: string; data: Partial<GlobalTransitionDefaults> },
|
||||||
|
{ rejectValue: ApiError }
|
||||||
|
>('global_transition_defaults/update', async (payload, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await axios.put(`global-transition-defaults/${payload.id}`, {
|
||||||
|
id: payload.id,
|
||||||
|
data: payload.data,
|
||||||
|
});
|
||||||
|
// Refetch to get the updated data
|
||||||
|
const result = await axios.get<GlobalTransitionDefaults>(
|
||||||
|
'global-transition-defaults',
|
||||||
|
);
|
||||||
|
return result.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
return rejectWithValue(error.response.data as ApiError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalTransitionDefaultsSlice = createSlice({
|
||||||
|
name: 'global_transition_defaults',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearState: (state) => {
|
||||||
|
state.data = null;
|
||||||
|
},
|
||||||
|
setData: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<GlobalTransitionDefaults | null>,
|
||||||
|
) => {
|
||||||
|
state.data = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// Fetch handlers
|
||||||
|
builder.addCase(fetch.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
resetNotify(state);
|
||||||
|
});
|
||||||
|
builder.addCase(fetch.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
rejectNotify(state, action);
|
||||||
|
});
|
||||||
|
builder.addCase(fetch.fulfilled, (state, action) => {
|
||||||
|
state.data = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update handlers
|
||||||
|
builder.addCase(update.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
resetNotify(state);
|
||||||
|
});
|
||||||
|
builder.addCase(update.fulfilled, (state, action) => {
|
||||||
|
state.data = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
fulfilledNotify(state, 'Global transition defaults have been updated');
|
||||||
|
});
|
||||||
|
builder.addCase(update.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
rejectNotify(state, action);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearState, setData } = globalTransitionDefaultsSlice.actions;
|
||||||
|
|
||||||
|
export default globalTransitionDefaultsSlice.reducer;
|
||||||
@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Project Transition Settings Redux Slice
|
||||||
|
*
|
||||||
|
* Custom slice for environment-aware project transition settings.
|
||||||
|
* Provides actions for fetching/upserting by project + environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import {
|
||||||
|
fulfilledNotify,
|
||||||
|
rejectNotify,
|
||||||
|
resetNotify,
|
||||||
|
} from '../../helpers/notifyStateHandler';
|
||||||
|
import type { ApiError } from '../../types/api';
|
||||||
|
import type { ProjectTransitionSettingsEntity } from '../../types/transition';
|
||||||
|
|
||||||
|
interface NotifyState {
|
||||||
|
showNotification: boolean;
|
||||||
|
textNotification: string;
|
||||||
|
typeNotification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectTransitionSettingsState {
|
||||||
|
// Keyed by `${projectId}:${environment}`
|
||||||
|
byProjectEnv: Record<string, ProjectTransitionSettingsEntity | null>;
|
||||||
|
loading: boolean;
|
||||||
|
loadingKeys: Record<string, boolean>;
|
||||||
|
notify: NotifyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ProjectTransitionSettingsState = {
|
||||||
|
byProjectEnv: {},
|
||||||
|
loading: false,
|
||||||
|
loadingKeys: {},
|
||||||
|
notify: {
|
||||||
|
showNotification: false,
|
||||||
|
textNotification: '',
|
||||||
|
typeNotification: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type guard for axios error
|
||||||
|
function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
||||||
|
return axios.isAxiosError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a cache key from project ID and environment
|
||||||
|
*/
|
||||||
|
function buildKey(projectId: string, environment: string): string {
|
||||||
|
return `${projectId}:${environment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch settings for a specific project and environment
|
||||||
|
*/
|
||||||
|
export const fetchByProjectAndEnv = createAsyncThunk<
|
||||||
|
{ key: string; data: ProjectTransitionSettingsEntity | null },
|
||||||
|
{ projectId: string; environment: 'dev' | 'stage' | 'production' },
|
||||||
|
{ rejectValue: ApiError }
|
||||||
|
>(
|
||||||
|
'project_transition_settings/fetchByProjectAndEnv',
|
||||||
|
async ({ projectId, environment }, { rejectWithValue }) => {
|
||||||
|
const key = buildKey(projectId, environment);
|
||||||
|
try {
|
||||||
|
const result = await axios.get<ProjectTransitionSettingsEntity>(
|
||||||
|
`project-transition-settings/project/${projectId}/env/${environment}`,
|
||||||
|
);
|
||||||
|
return { key, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
// No settings found - not an error, just means use global defaults
|
||||||
|
return { key, data: null };
|
||||||
|
}
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
return rejectWithValue(error.response.data as ApiError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update settings for a specific project and environment
|
||||||
|
*/
|
||||||
|
export const upsertByProjectAndEnv = createAsyncThunk<
|
||||||
|
{ key: string; data: ProjectTransitionSettingsEntity },
|
||||||
|
{
|
||||||
|
projectId: string;
|
||||||
|
environment: 'dev' | 'stage' | 'production';
|
||||||
|
data: Partial<
|
||||||
|
Omit<ProjectTransitionSettingsEntity, 'id' | 'projectId' | 'environment'>
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
{ rejectValue: ApiError }
|
||||||
|
>(
|
||||||
|
'project_transition_settings/upsertByProjectAndEnv',
|
||||||
|
async ({ projectId, environment, data }, { rejectWithValue }) => {
|
||||||
|
const key = buildKey(projectId, environment);
|
||||||
|
try {
|
||||||
|
const result = await axios.put<ProjectTransitionSettingsEntity>(
|
||||||
|
`project-transition-settings/project/${projectId}/env/${environment}`,
|
||||||
|
{ data },
|
||||||
|
);
|
||||||
|
return { key, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
return rejectWithValue(error.response.data as ApiError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete settings for a specific project and environment (reverts to global defaults)
|
||||||
|
*/
|
||||||
|
export const deleteByProjectAndEnv = createAsyncThunk<
|
||||||
|
{ key: string },
|
||||||
|
{ projectId: string; environment: 'dev' | 'stage' | 'production' },
|
||||||
|
{ rejectValue: ApiError }
|
||||||
|
>(
|
||||||
|
'project_transition_settings/deleteByProjectAndEnv',
|
||||||
|
async ({ projectId, environment }, { rejectWithValue }) => {
|
||||||
|
const key = buildKey(projectId, environment);
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`project-transition-settings/project/${projectId}/env/${environment}`,
|
||||||
|
);
|
||||||
|
return { key };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response) {
|
||||||
|
return rejectWithValue(error.response.data as ApiError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectTransitionSettingsSlice = createSlice({
|
||||||
|
name: 'project_transition_settings',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
clearState: (state) => {
|
||||||
|
state.byProjectEnv = {};
|
||||||
|
state.loadingKeys = {};
|
||||||
|
},
|
||||||
|
clearForProject: (state, action: PayloadAction<string>) => {
|
||||||
|
const projectId = action.payload;
|
||||||
|
// Remove all entries for this project
|
||||||
|
Object.keys(state.byProjectEnv).forEach((key) => {
|
||||||
|
if (key.startsWith(`${projectId}:`)) {
|
||||||
|
delete state.byProjectEnv[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// Fetch by project and environment
|
||||||
|
builder.addCase(fetchByProjectAndEnv.pending, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
state.loadingKeys[key] = true;
|
||||||
|
state.loading = true;
|
||||||
|
resetNotify(state);
|
||||||
|
});
|
||||||
|
builder.addCase(fetchByProjectAndEnv.fulfilled, (state, action) => {
|
||||||
|
const { key, data } = action.payload;
|
||||||
|
state.byProjectEnv[key] = data;
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
});
|
||||||
|
builder.addCase(fetchByProjectAndEnv.rejected, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
rejectNotify(state, action);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert by project and environment
|
||||||
|
builder.addCase(upsertByProjectAndEnv.pending, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
state.loadingKeys[key] = true;
|
||||||
|
state.loading = true;
|
||||||
|
resetNotify(state);
|
||||||
|
});
|
||||||
|
builder.addCase(upsertByProjectAndEnv.fulfilled, (state, action) => {
|
||||||
|
const { key, data } = action.payload;
|
||||||
|
state.byProjectEnv[key] = data;
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
fulfilledNotify(state, 'Project transition settings saved');
|
||||||
|
});
|
||||||
|
builder.addCase(upsertByProjectAndEnv.rejected, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
rejectNotify(state, action);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete by project and environment
|
||||||
|
builder.addCase(deleteByProjectAndEnv.pending, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
state.loadingKeys[key] = true;
|
||||||
|
state.loading = true;
|
||||||
|
resetNotify(state);
|
||||||
|
});
|
||||||
|
builder.addCase(deleteByProjectAndEnv.fulfilled, (state, action) => {
|
||||||
|
const { key } = action.payload;
|
||||||
|
state.byProjectEnv[key] = null;
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
fulfilledNotify(state, 'Project transition settings cleared');
|
||||||
|
});
|
||||||
|
builder.addCase(deleteByProjectAndEnv.rejected, (state, action) => {
|
||||||
|
const key = buildKey(
|
||||||
|
action.meta.arg.projectId,
|
||||||
|
action.meta.arg.environment,
|
||||||
|
);
|
||||||
|
delete state.loadingKeys[key];
|
||||||
|
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||||
|
rejectNotify(state, action);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearState, clearForProject } =
|
||||||
|
projectTransitionSettingsSlice.actions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector to get settings for a specific project and environment
|
||||||
|
*/
|
||||||
|
export const selectByProjectAndEnv = (
|
||||||
|
state: { project_transition_settings: ProjectTransitionSettingsState },
|
||||||
|
projectId: string,
|
||||||
|
environment: 'dev' | 'stage' | 'production',
|
||||||
|
): ProjectTransitionSettingsEntity | null | undefined => {
|
||||||
|
const key = buildKey(projectId, environment);
|
||||||
|
return state.project_transition_settings.byProjectEnv[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector to check if loading for a specific project and environment
|
||||||
|
*/
|
||||||
|
export const selectIsLoading = (
|
||||||
|
state: { project_transition_settings: ProjectTransitionSettingsState },
|
||||||
|
projectId: string,
|
||||||
|
environment: 'dev' | 'stage' | 'production',
|
||||||
|
): boolean => {
|
||||||
|
const key = buildKey(projectId, environment);
|
||||||
|
return state.project_transition_settings.loadingKeys[key] ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default projectTransitionSettingsSlice.reducer;
|
||||||
@ -18,6 +18,8 @@ import project_audio_tracksSlice from './project_audio_tracks/project_audio_trac
|
|||||||
import publish_eventsSlice from './publish_events/publish_eventsSlice';
|
import publish_eventsSlice from './publish_events/publish_eventsSlice';
|
||||||
import pwa_cachesSlice from './pwa_caches/pwa_cachesSlice';
|
import pwa_cachesSlice from './pwa_caches/pwa_cachesSlice';
|
||||||
import access_logsSlice from './access_logs/access_logsSlice';
|
import access_logsSlice from './access_logs/access_logsSlice';
|
||||||
|
import globalTransitionDefaultsSlice from './global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
|
import projectTransitionSettingsSlice from './project_transition_settings/projectTransitionSettingsSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@ -40,6 +42,8 @@ export const store = configureStore({
|
|||||||
publish_events: publish_eventsSlice,
|
publish_events: publish_eventsSlice,
|
||||||
pwa_caches: pwa_cachesSlice,
|
pwa_caches: pwa_cachesSlice,
|
||||||
access_logs: access_logsSlice,
|
access_logs: access_logsSlice,
|
||||||
|
global_transition_defaults: globalTransitionDefaultsSlice,
|
||||||
|
project_transition_settings: projectTransitionSettingsSlice,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import type { ElementStyleProperties } from '../lib/elementStyles';
|
import type { ElementStyleProperties } from '../lib/elementStyles';
|
||||||
import type { ElementEffectProperties } from '../lib/elementEffects';
|
import type { ElementEffectProperties } from '../lib/elementEffects';
|
||||||
|
import type { TransitionType, EasingFunction } from './transition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Element types available in the constructor canvas.
|
* Element types available in the constructor canvas.
|
||||||
@ -231,6 +232,14 @@ export interface CanvasElement extends BaseCanvasElement {
|
|||||||
/** 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;
|
||||||
transitionDurationSec?: number;
|
transitionDurationSec?: number;
|
||||||
|
/** CSS transition type when no video transition is configured */
|
||||||
|
transitionType?: TransitionType | '';
|
||||||
|
/** CSS transition duration in milliseconds */
|
||||||
|
transitionDurationMs?: number | '';
|
||||||
|
/** CSS transition easing function */
|
||||||
|
transitionEasing?: EasingFunction | '';
|
||||||
|
/** CSS transition overlay color (hex) */
|
||||||
|
transitionOverlayColor?: string;
|
||||||
// Gallery Carousel Settings
|
// Gallery Carousel Settings
|
||||||
galleryCarouselPrevIconUrl?: string;
|
galleryCarouselPrevIconUrl?: string;
|
||||||
galleryCarouselNextIconUrl?: string;
|
galleryCarouselNextIconUrl?: string;
|
||||||
|
|||||||
@ -2,6 +2,11 @@
|
|||||||
* Entity Types
|
* Entity Types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
GlobalTransitionDefaults,
|
||||||
|
ProjectTransitionSettingsEntity,
|
||||||
|
} from './transition';
|
||||||
|
|
||||||
// Base entity interface that all entities extend
|
// Base entity interface that all entities extend
|
||||||
export interface BaseEntity {
|
export interface BaseEntity {
|
||||||
id: string;
|
id: string;
|
||||||
@ -43,6 +48,8 @@ export interface Project extends BaseEntity {
|
|||||||
og_image_url?: string;
|
og_image_url?: string;
|
||||||
design_width?: number;
|
design_width?: number;
|
||||||
design_height?: number;
|
design_height?: number;
|
||||||
|
// Note: transition_settings is now stored in project_transition_settings table
|
||||||
|
// with environment awareness (dev, stage, production)
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
deleted_at_time?: string | Date | null;
|
deleted_at_time?: string | Date | null;
|
||||||
}
|
}
|
||||||
@ -281,11 +288,13 @@ export type EntityName =
|
|||||||
| 'asset_variants'
|
| 'asset_variants'
|
||||||
| 'tour_pages'
|
| 'tour_pages'
|
||||||
| 'project_audio_tracks'
|
| 'project_audio_tracks'
|
||||||
|
| 'project_transition_settings'
|
||||||
| 'publish_events'
|
| 'publish_events'
|
||||||
| 'pwa_caches'
|
| 'pwa_caches'
|
||||||
| 'presigned_url_requests'
|
| 'presigned_url_requests'
|
||||||
| 'access_logs'
|
| 'access_logs'
|
||||||
| 'element_type_defaults';
|
| 'element_type_defaults'
|
||||||
|
| 'global_transition_defaults';
|
||||||
|
|
||||||
// Entity type map for generic lookups
|
// Entity type map for generic lookups
|
||||||
export interface EntityTypeMap {
|
export interface EntityTypeMap {
|
||||||
@ -298,9 +307,11 @@ export interface EntityTypeMap {
|
|||||||
asset_variants: AssetVariant;
|
asset_variants: AssetVariant;
|
||||||
tour_pages: TourPage;
|
tour_pages: TourPage;
|
||||||
project_audio_tracks: ProjectAudioTrack;
|
project_audio_tracks: ProjectAudioTrack;
|
||||||
|
project_transition_settings: ProjectTransitionSettingsEntity;
|
||||||
publish_events: PublishEvent;
|
publish_events: PublishEvent;
|
||||||
pwa_caches: PwaCache;
|
pwa_caches: PwaCache;
|
||||||
presigned_url_requests: PresignedUrlRequest;
|
presigned_url_requests: PresignedUrlRequest;
|
||||||
access_logs: AccessLog;
|
access_logs: AccessLog;
|
||||||
element_type_defaults: UIElement;
|
element_type_defaults: UIElement;
|
||||||
|
global_transition_defaults: GlobalTransitionDefaults;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,3 +18,4 @@ export * from './ui';
|
|||||||
export * from './openai';
|
export * from './openai';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './charts';
|
export * from './charts';
|
||||||
|
export * from './transition';
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { PreloadPage, PreloadPageLink, PreloadElement } from './preload';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime project data
|
* Runtime project data
|
||||||
|
* Note: transition_settings now fetched separately via project_transition_settings store
|
||||||
*/
|
*/
|
||||||
export interface RuntimeProject {
|
export interface RuntimeProject {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
186
frontend/src/types/transition.ts
Normal file
186
frontend/src/types/transition.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Transition Types
|
||||||
|
*
|
||||||
|
* Hierarchical transition settings with cascade:
|
||||||
|
* Element → Project → Global (fallback)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseEntity } from './entities';
|
||||||
|
|
||||||
|
// Transition type options for CSS-based transitions
|
||||||
|
// Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain
|
||||||
|
export type TransitionType = 'fade' | 'none' | 'video';
|
||||||
|
|
||||||
|
// Easing function options
|
||||||
|
export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global transition defaults entity (singleton table)
|
||||||
|
*/
|
||||||
|
export interface GlobalTransitionDefaults extends BaseEntity {
|
||||||
|
transition_type: TransitionType;
|
||||||
|
duration_ms: number;
|
||||||
|
easing: EasingFunction;
|
||||||
|
overlay_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-level transition settings (camelCase for component props and local state)
|
||||||
|
* All fields optional - missing values inherit from global defaults
|
||||||
|
*/
|
||||||
|
export interface ProjectTransitionSettings {
|
||||||
|
transitionType?: TransitionType;
|
||||||
|
durationMs?: number;
|
||||||
|
easing?: EasingFunction;
|
||||||
|
overlayColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment-aware project transition settings entity (from database)
|
||||||
|
* Snake_case field names match the database schema
|
||||||
|
*/
|
||||||
|
export interface ProjectTransitionSettingsEntity extends BaseEntity {
|
||||||
|
projectId: string;
|
||||||
|
environment: 'dev' | 'stage' | 'production';
|
||||||
|
source_key?: string;
|
||||||
|
transition_type: TransitionType;
|
||||||
|
duration_ms: number;
|
||||||
|
easing: EasingFunction;
|
||||||
|
overlay_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element-level transition settings (stored in ui_schema_json)
|
||||||
|
* All fields optional - missing values inherit from project settings
|
||||||
|
*/
|
||||||
|
export interface ElementTransitionSettings {
|
||||||
|
transitionType?: TransitionType;
|
||||||
|
transitionDurationMs?: number;
|
||||||
|
transitionEasing?: EasingFunction;
|
||||||
|
transitionOverlayColor?: string;
|
||||||
|
transitionVideoUrl?: string;
|
||||||
|
reverseVideoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved transition settings after cascade resolution
|
||||||
|
* All required fields - represents the final computed values
|
||||||
|
*/
|
||||||
|
export interface ResolvedTransitionSettings {
|
||||||
|
type: TransitionType;
|
||||||
|
durationMs: number;
|
||||||
|
easing: EasingFunction;
|
||||||
|
overlayColor: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
reverseVideoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default values for global transition settings
|
||||||
|
*/
|
||||||
|
export const DEFAULT_TRANSITION_SETTINGS: Omit<GlobalTransitionDefaults, 'id'> =
|
||||||
|
{
|
||||||
|
transition_type: 'fade',
|
||||||
|
duration_ms: 700,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
overlay_color: '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for checking if a value is a valid transition type
|
||||||
|
*/
|
||||||
|
export function isValidTransitionType(value: unknown): value is TransitionType {
|
||||||
|
return typeof value === 'string' && ['fade', 'none', 'video'].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for checking if a value is a valid easing function
|
||||||
|
*/
|
||||||
|
export function isValidEasingFunction(value: unknown): value is EasingFunction {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
['ease-in-out', 'ease-in', 'ease-out', 'linear'].includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ProjectTransitionSettingsEntity (snake_case) to ProjectTransitionSettings (camelCase)
|
||||||
|
* for use in components and hooks that expect the camelCase format
|
||||||
|
*/
|
||||||
|
export function entityToProjectSettings(
|
||||||
|
entity: ProjectTransitionSettingsEntity | null | undefined,
|
||||||
|
): ProjectTransitionSettings | null {
|
||||||
|
if (!entity) return null;
|
||||||
|
return {
|
||||||
|
transitionType: entity.transition_type,
|
||||||
|
durationMs: entity.duration_ms,
|
||||||
|
easing: entity.easing,
|
||||||
|
overlayColor: entity.overlay_color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element shape that contains transition settings fields.
|
||||||
|
* Used by extractElementTransitionSettings to extract settings from a CanvasElement.
|
||||||
|
*/
|
||||||
|
interface ElementWithTransitionSettings {
|
||||||
|
transitionVideoUrl?: string;
|
||||||
|
reverseVideoUrl?: string;
|
||||||
|
transitionType?: TransitionType | '';
|
||||||
|
transitionDurationMs?: number | '';
|
||||||
|
transitionEasing?: EasingFunction | '';
|
||||||
|
transitionOverlayColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ElementTransitionSettings from an element.
|
||||||
|
* Converts element fields to the ElementTransitionSettings format for cascade resolution.
|
||||||
|
*
|
||||||
|
* @param element - Element with transition fields (e.g., CanvasElement)
|
||||||
|
* @returns ElementTransitionSettings object or null if element is falsy
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const elementSettings = extractElementTransitionSettings(clickedElement);
|
||||||
|
* setCurrentElementTransitionSettings(elementSettings);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function extractElementTransitionSettings(
|
||||||
|
element: ElementWithTransitionSettings | null | undefined,
|
||||||
|
): ElementTransitionSettings | null {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
// Only include fields that have actual values (not empty strings)
|
||||||
|
// This allows cascade to fall through to project/global defaults when element uses "Use Project Default"
|
||||||
|
const settings: ElementTransitionSettings = {};
|
||||||
|
|
||||||
|
if (element.transitionVideoUrl) {
|
||||||
|
settings.transitionVideoUrl = element.transitionVideoUrl;
|
||||||
|
}
|
||||||
|
if (element.reverseVideoUrl) {
|
||||||
|
settings.reverseVideoUrl = element.reverseVideoUrl;
|
||||||
|
}
|
||||||
|
// Only set transitionType if it's a valid non-empty value
|
||||||
|
// Truthiness check handles both undefined and empty string
|
||||||
|
if (element.transitionType) {
|
||||||
|
settings.transitionType = element.transitionType as TransitionType;
|
||||||
|
}
|
||||||
|
// Only set durationMs if it's a valid number (not empty string or undefined)
|
||||||
|
if (
|
||||||
|
typeof element.transitionDurationMs === 'number' &&
|
||||||
|
element.transitionDurationMs > 0
|
||||||
|
) {
|
||||||
|
settings.transitionDurationMs = element.transitionDurationMs;
|
||||||
|
}
|
||||||
|
// Only set easing if it's a valid non-empty value
|
||||||
|
// Truthiness check handles both undefined and empty string
|
||||||
|
if (element.transitionEasing) {
|
||||||
|
settings.transitionEasing = element.transitionEasing as EasingFunction;
|
||||||
|
}
|
||||||
|
// Only set overlayColor if it's a non-empty string
|
||||||
|
if (element.transitionOverlayColor) {
|
||||||
|
settings.transitionOverlayColor = element.transitionOverlayColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user