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) {
|
||||
// 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 {
|
||||
id: data.id || undefined,
|
||||
name: data.name || null,
|
||||
slug: data.slug || null,
|
||||
description: data.description || null,
|
||||
logo_url: data.logo_url || null,
|
||||
favicon_url: data.favicon_url || null,
|
||||
og_image_url: data.og_image_url || null,
|
||||
design_width: data.design_width !== undefined ? data.design_width : null,
|
||||
design_height:
|
||||
data.design_height !== undefined ? data.design_height : null,
|
||||
name: 'name' in data ? (data.name || null) : undefined,
|
||||
slug: 'slug' in data ? (data.slug || null) : undefined,
|
||||
description: 'description' in data ? (data.description || null) : undefined,
|
||||
logo_url: 'logo_url' in data ? (data.logo_url || null) : undefined,
|
||||
favicon_url: 'favicon_url' in data ? (data.favicon_url || null) : undefined,
|
||||
og_image_url: 'og_image_url' in data ? (data.og_image_url || null) : undefined,
|
||||
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||
design_height: 'design_height' in data ? data.design_height : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
// Note: transition_settings moved to project_transition_settings table
|
||||
// for environment-aware storage (dev, stage, production)
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -172,6 +175,16 @@ module.exports = function (sequelize, DataTypes) {
|
||||
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
|
||||
|
||||
db.projects.belongsTo(db.users, {
|
||||
|
||||
@ -51,6 +51,8 @@ const pwa_cachesRoutes = require('./routes/pwa_caches');
|
||||
const access_logsRoutes = require('./routes/access_logs');
|
||||
const element_type_defaultsRoutes = require('./routes/element_type_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 runtimeContextRoutes = require('./routes/runtime-context');
|
||||
@ -235,6 +237,18 @@ app.use(
|
||||
jwtAuth,
|
||||
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);
|
||||
|
||||
|
||||
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,
|
||||
) {
|
||||
// Get source content
|
||||
const [sourcePages, sourceAudioTracks] = await Promise.all([
|
||||
const [sourcePages, sourceAudioTracks, sourceTransitionSettings] = await Promise.all([
|
||||
db.tour_pages.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
@ -266,6 +266,10 @@ module.exports = class PublishService {
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
db.project_transition_settings.findOne({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Clean up target environment (hard delete - paranoid models need force: true)
|
||||
@ -280,6 +284,11 @@ module.exports = class PublishService {
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
db.project_transition_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
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 {
|
||||
pages_copied: sourcePages.length,
|
||||
audios_copied: sourceAudioTracks.length,
|
||||
transition_settings_copied: sourceTransitionSettings ? 1 : 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -32,7 +32,6 @@ interface CanvasBackgroundProps {
|
||||
previousBgVideoUrl?: string;
|
||||
isSwitching?: boolean;
|
||||
isNewBgReady?: boolean;
|
||||
isFadingIn?: boolean;
|
||||
onBackgroundReady?: () => void;
|
||||
// Video playback settings
|
||||
videoAutoplay?: boolean;
|
||||
@ -52,7 +51,6 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
previousBgVideoUrl,
|
||||
isSwitching = false,
|
||||
isNewBgReady = false,
|
||||
isFadingIn = false,
|
||||
onBackgroundReady,
|
||||
videoAutoplay = true,
|
||||
videoLoop = true,
|
||||
@ -165,15 +163,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous background overlays - show during loading AND crossfade.
|
||||
Uses CSS animation for fade-out effect during crossfade.
|
||||
z-0 keeps them BELOW new backgrounds (z-1). */}
|
||||
{/* Previous background overlay - shows during loading (z-2) above new background (z-1).
|
||||
Black overlay for fade effect is rendered separately at higher z-index (TransitionBlackOverlay). */}
|
||||
<PreviousBackgroundOverlay
|
||||
imageUrl={previousBgImageUrl}
|
||||
videoUrl={previousBgVideoUrl}
|
||||
isSwitching={isSwitching}
|
||||
isNewBgReady={isNewBgReady}
|
||||
isFadingIn={isFadingIn}
|
||||
/>
|
||||
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5)
|
||||
|
||||
@ -324,6 +324,14 @@ export function ElementEditorPanel({
|
||||
selectedElement.transitionReverseMode || 'auto_reverse'
|
||||
}
|
||||
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
||||
transitionType={selectedElement.transitionType || ''}
|
||||
transitionDurationMs={
|
||||
selectedElement.transitionDurationMs ?? ''
|
||||
}
|
||||
transitionEasing={selectedElement.transitionEasing || ''}
|
||||
transitionOverlayColor={
|
||||
selectedElement.transitionOverlayColor || ''
|
||||
}
|
||||
allowedNavigationTypes={allowedNavigationTypes}
|
||||
iconAssetOptions={assetOptions.icon}
|
||||
transitionVideoOptions={assetOptions.transitionVideo}
|
||||
|
||||
@ -12,9 +12,24 @@ import type {
|
||||
NavigationButtonKind,
|
||||
CanvasElementType,
|
||||
} from '../../types/constructor';
|
||||
import type { TransitionType, EasingFunction } from '../../types/transition';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
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<
|
||||
CanvasElementType,
|
||||
'navigation_next' | 'navigation_prev'
|
||||
@ -31,6 +46,11 @@ interface NavigationSettingsSectionCompactProps {
|
||||
transitionVideoUrl: string;
|
||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl: string;
|
||||
// CSS transition settings (used when no video is selected)
|
||||
transitionType?: TransitionType | '';
|
||||
transitionDurationMs?: number | '';
|
||||
transitionEasing?: EasingFunction | '';
|
||||
transitionOverlayColor?: string;
|
||||
allowedNavigationTypes: NavigationElementType[];
|
||||
iconAssetOptions: AssetOption[];
|
||||
transitionVideoOptions: AssetOption[];
|
||||
@ -67,6 +87,10 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
transitionVideoUrl,
|
||||
transitionReverseMode,
|
||||
reverseVideoUrl,
|
||||
transitionType = '',
|
||||
transitionDurationMs = '',
|
||||
transitionEasing = '',
|
||||
transitionOverlayColor = '',
|
||||
allowedNavigationTypes,
|
||||
iconAssetOptions,
|
||||
transitionVideoOptions,
|
||||
@ -298,9 +322,99 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS Transition Settings (when no video selected) */}
|
||||
{!transitionVideoUrl && (
|
||||
<>
|
||||
<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 && (
|
||||
<div className='flex gap-2 pt-1'>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
/**
|
||||
* PreviousBackgroundOverlay Component
|
||||
*
|
||||
* Renders the previous page background during page transitions.
|
||||
* Shows during loading and crossfade, with optional fade-out animation.
|
||||
* Used by both CanvasBackground (constructor) and RuntimePresentation.
|
||||
* Shows the previous page background during page transitions
|
||||
* while the new background is loading.
|
||||
*
|
||||
* Used by CanvasBackground component.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@ -17,8 +18,6 @@ interface PreviousBackgroundOverlayProps {
|
||||
isSwitching?: boolean;
|
||||
/** Whether new background is ready */
|
||||
isNewBgReady?: boolean;
|
||||
/** Whether fade animation is in progress */
|
||||
isFadingIn?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
@ -28,19 +27,19 @@ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
||||
videoUrl,
|
||||
isSwitching = false,
|
||||
isNewBgReady = false,
|
||||
isFadingIn = false,
|
||||
className = '',
|
||||
}) => {
|
||||
// Show during loading (isSwitching && !isNewBgReady) OR during crossfade (isFadingIn)
|
||||
const shouldShow = isFadingIn || (isSwitching && !isNewBgReady);
|
||||
// Show previous background during loading (before new bg is ready)
|
||||
const showPreviousBackground = isSwitching && !isNewBgReady;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
if (!showPreviousBackground) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Previous background image */}
|
||||
{imageUrl && (
|
||||
<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={{
|
||||
backgroundImage: `url("${imageUrl}")`,
|
||||
backgroundSize: 'contain',
|
||||
@ -49,9 +48,10 @@ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Previous background video */}
|
||||
{videoUrl && (
|
||||
<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}
|
||||
autoPlay
|
||||
loop
|
||||
|
||||
@ -14,6 +14,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import CardBox from './CardBox';
|
||||
@ -24,6 +25,7 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||
import { BackdropPortalProvider } from './BackdropPortal';
|
||||
import { RotatePrompt } from './RotatePrompt';
|
||||
import CanvasBackground from './Constructor/CanvasBackground';
|
||||
import TransitionBlackOverlay from './TransitionBlackOverlay';
|
||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
@ -49,8 +51,20 @@ import {
|
||||
isBackNavigation,
|
||||
isNavigationType,
|
||||
} 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 { CanvasElement } from '../types/constructor';
|
||||
import type { ElementTransitionSettings } from '../types/transition';
|
||||
import {
|
||||
entityToProjectSettings,
|
||||
extractElementTransitionSettings,
|
||||
} from '../types/transition';
|
||||
|
||||
interface RuntimePresentationProps {
|
||||
projectSlug: string;
|
||||
@ -61,7 +75,13 @@ export default function RuntimePresentation({
|
||||
projectSlug,
|
||||
environment,
|
||||
}: RuntimePresentationProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const globalTransitionDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.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(
|
||||
{
|
||||
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
|
||||
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
||||
|
||||
@ -125,7 +183,6 @@ export default function RuntimePresentation({
|
||||
// Safari Black Flash Prevention (video transitions only):
|
||||
// Track the last successfully displayed background to use as a "snapshot" layer.
|
||||
// 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 transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||
@ -246,18 +303,17 @@ export default function RuntimePresentation({
|
||||
},
|
||||
});
|
||||
|
||||
// Use shared background transition hook for crossfade effects
|
||||
// NOTE: fadeOut config is NOT used for video transitions.
|
||||
// Use shared background transition hook for fade-from-black effects
|
||||
// Video transitions end instantly (last frame = new page, then overlay removed).
|
||||
// fadeIn is used for non-video navigation (crossfade 500ms).
|
||||
// hasActiveTransition includes pendingTransitionComplete to prevent crossfade
|
||||
// during the video-to-background handoff phase.
|
||||
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
|
||||
// fadeIn controls the black overlay for non-video navigation.
|
||||
// hasActiveTransition prevents fade during video-to-background handoff.
|
||||
const { isFadingIn, resetFadeIn, transitionStyle } = useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeIn: {
|
||||
hasActiveTransition:
|
||||
Boolean(transitionPreview) || pendingTransitionComplete,
|
||||
},
|
||||
transitionSettings,
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
@ -417,10 +473,8 @@ export default function RuntimePresentation({
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Direct navigation with crossfade effect:
|
||||
// useBackgroundTransition detects switching and applies animation classes
|
||||
// - New page gets animate-crossfade-in (0 → 1)
|
||||
// - Previous background gets animate-crossfade-out (1 → 0)
|
||||
// Direct navigation with fade-from-black effect:
|
||||
// Page switches instantly, black overlay fades out to reveal new page
|
||||
setIsBackgroundReady(false);
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
lastInitializedPageIdRef.current = targetPageId;
|
||||
@ -482,6 +536,21 @@ export default function RuntimePresentation({
|
||||
});
|
||||
|
||||
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(
|
||||
navTarget.pageId,
|
||||
navTarget.transitionVideoUrl,
|
||||
@ -497,6 +566,7 @@ export default function RuntimePresentation({
|
||||
isBuffering,
|
||||
getNavigationContext,
|
||||
areNeighborBackgroundsReady,
|
||||
setCurrentElementTransitionSettings,
|
||||
],
|
||||
);
|
||||
|
||||
@ -664,7 +734,6 @@ export default function RuntimePresentation({
|
||||
<BackdropPortalProvider>
|
||||
{/* Safari Black Flash Prevention (video transitions only):
|
||||
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. */}
|
||||
{lastKnownBgUrl &&
|
||||
isSafari() &&
|
||||
@ -681,11 +750,12 @@ export default function RuntimePresentation({
|
||||
)}
|
||||
|
||||
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||
Fades in for non-transition navigation. Uses shared CanvasBackground component
|
||||
for single source of truth with constructor (same transitions, same structure). */}
|
||||
Uses shared CanvasBackground component for single source of truth with constructor.
|
||||
Previous background overlay shows during loading.
|
||||
Black overlay for fade effect is rendered separately at z-[100]. */}
|
||||
<div
|
||||
data-testid='page-background-wrapper'
|
||||
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||
className='absolute inset-0 z-5'
|
||||
>
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageUrl}
|
||||
@ -694,7 +764,6 @@ export default function RuntimePresentation({
|
||||
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
isFadingIn={isFadingIn}
|
||||
onBackgroundReady={() => {
|
||||
setIsBackgroundReady(true);
|
||||
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).
|
||||
UI controls (z-50) remain on top.
|
||||
Fades in together with background. */}
|
||||
No fade animation - elements switch instantly behind the black overlay. */}
|
||||
<div
|
||||
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) => (
|
||||
<RuntimeElement
|
||||
@ -732,6 +801,16 @@ export default function RuntimePresentation({
|
||||
</div>
|
||||
{/* 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 */}
|
||||
{/* 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) */}
|
||||
|
||||
@ -4,7 +4,10 @@ import {
|
||||
mdiFileDocumentPlus,
|
||||
mdiSwapHorizontal,
|
||||
mdiViewDashboard,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
} from '@mdi/js';
|
||||
import Icon from '@mdi/react';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
@ -17,7 +20,7 @@ import SectionMain from './SectionMain';
|
||||
import SectionTitleLineWithButton from './SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||
import { logger } from '../lib/logger';
|
||||
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
||||
import {
|
||||
@ -26,6 +29,20 @@ import {
|
||||
getProjectId,
|
||||
getRows,
|
||||
} 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 = {
|
||||
id: string;
|
||||
@ -61,9 +78,27 @@ type ListEntry = {
|
||||
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 router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const globalDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.data,
|
||||
);
|
||||
|
||||
const routeProjectId = useMemo(() => {
|
||||
const value = router.query.projectId;
|
||||
@ -84,6 +119,36 @@ const TourFlowManager = () => {
|
||||
const [deletingId, setDeletingId] = 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 canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
||||
const canDeletePage = hasPermission(currentUser, 'DELETE_TOUR_PAGES');
|
||||
@ -161,6 +226,38 @@ const TourFlowManager = () => {
|
||||
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(() => {
|
||||
if (!selectedProjectId) return;
|
||||
if (routeProjectId && selectedProjectId === routeProjectId) return;
|
||||
@ -391,6 +488,57 @@ const TourFlowManager = () => {
|
||||
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 (
|
||||
event: React.MouseEvent,
|
||||
id: string,
|
||||
@ -486,6 +634,144 @@ const TourFlowManager = () => {
|
||||
/>
|
||||
</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
|
||||
title='Create page'
|
||||
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 */
|
||||
/* 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 {
|
||||
/* Explicit initial state prevents flash during animation setup */
|
||||
opacity: 0;
|
||||
@ -117,15 +129,15 @@
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-in;
|
||||
animation-name: page-crossfade-in;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards;
|
||||
@ -146,15 +158,15 @@
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-out;
|
||||
animation-name: page-crossfade-out;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
-webkit-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) */
|
||||
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
||||
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 */
|
||||
@-webkit-keyframes element-fade-in {
|
||||
from {
|
||||
|
||||
@ -22,7 +22,7 @@ export type {
|
||||
} from './usePageNavigation';
|
||||
export { useBackgroundTransition } from './useBackgroundTransition';
|
||||
export type {
|
||||
FadeOutConfig,
|
||||
FadeInConfig,
|
||||
UseBackgroundTransitionOptions,
|
||||
UseBackgroundTransitionResult,
|
||||
} from './useBackgroundTransition';
|
||||
|
||||
@ -2,23 +2,13 @@
|
||||
* useBackgroundTransition Hook
|
||||
*
|
||||
* Manages background transition effects when switching between pages.
|
||||
* Handles crossfade animation for non-video navigation and
|
||||
* coordinates with the page switch hook to clear previous backgrounds.
|
||||
* Controls the fade-from-black overlay for smooth page transitions.
|
||||
*
|
||||
* This hook consolidates the background transition logic used by both
|
||||
* RuntimePresentation and constructor.tsx.
|
||||
* When a page switch occurs:
|
||||
* 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.
|
||||
* 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
|
||||
* NOTE: Video transitions do NOT use fades - the video itself IS the transition.
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -28,28 +18,11 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
isSafari,
|
||||
scheduleAfterPaintSafari,
|
||||
getCrossfadeDuration,
|
||||
} from '../lib/browserUtils';
|
||||
import { isSafari, getCrossfadeDuration } from '../lib/browserUtils';
|
||||
import type { ResolvedTransitionSettings } from '../types/transition';
|
||||
|
||||
/**
|
||||
* Fade-out configuration (optional - for RuntimePresentation)
|
||||
*/
|
||||
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)
|
||||
* Fade-in configuration for page content
|
||||
*/
|
||||
export interface FadeInConfig {
|
||||
/** Whether a transition video is currently active (disables fade-in) */
|
||||
@ -65,85 +38,59 @@ export interface UseBackgroundTransitionOptions {
|
||||
previousBgImageUrl: string;
|
||||
previousBgVideoUrl: string;
|
||||
};
|
||||
/** Optional fade-out configuration (for RuntimePresentation) */
|
||||
fadeOut?: FadeOutConfig;
|
||||
/** Optional fade-in configuration for page content */
|
||||
fadeIn?: FadeInConfig;
|
||||
/** Optional resolved transition settings for dynamic duration/easing */
|
||||
transitionSettings?: ResolvedTransitionSettings | null;
|
||||
}
|
||||
|
||||
export interface UseBackgroundTransitionResult {
|
||||
/** Whether the overlay is currently fading out */
|
||||
isOverlayFadingOut: boolean;
|
||||
/** Reset the fade-out state (call before starting a new transition) */
|
||||
resetFadeOut: () => void;
|
||||
/** Whether page content is currently fading (crossfade in progress) */
|
||||
/** Whether page content is currently fading (fade-from-black in progress) */
|
||||
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) */
|
||||
resetFadeIn: () => void;
|
||||
/** Inline style for transition duration and easing */
|
||||
transitionStyle: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing background transition effects.
|
||||
*
|
||||
* @example
|
||||
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
||||
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
||||
* const { isFadingIn, transitionStyle } = useBackgroundTransition({
|
||||
* pageSwitch,
|
||||
* fadeOut: {
|
||||
* pendingTransitionComplete,
|
||||
* isBackgroundReady,
|
||||
* transitionVideoRef,
|
||||
* onTransitionCleanup: () => {
|
||||
* setTransitionPreview(null);
|
||||
* setPendingTransitionComplete(false);
|
||||
* },
|
||||
* },
|
||||
* fadeIn: {
|
||||
* hasActiveTransition: Boolean(transitionPreview),
|
||||
* },
|
||||
* transitionSettings,
|
||||
* });
|
||||
*
|
||||
* // In JSX:
|
||||
* <div
|
||||
* className={isFadingIn ? 'animate-crossfade-in' : ''}
|
||||
* onAnimationEnd={onFadeInAnimationEnd}
|
||||
* >
|
||||
*
|
||||
* @example
|
||||
* // Simple mode - direct navigation only (constructor)
|
||||
* useBackgroundTransition({ pageSwitch });
|
||||
* // Render black overlay that fades out
|
||||
* <TransitionBlackOverlay isFadingIn={isFadingIn} transitionStyle={transitionStyle} />
|
||||
*/
|
||||
export function useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeOut,
|
||||
fadeIn,
|
||||
transitionSettings,
|
||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
||||
const [isFadingIn, setIsFadingIn] = useState(false);
|
||||
|
||||
// Track previous isSwitching state to detect transition start
|
||||
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);
|
||||
// Track if animation was already completed (by event or timer)
|
||||
// Track if animation was already completed
|
||||
const fadeInCompletedRef = useRef(false);
|
||||
|
||||
// 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);
|
||||
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).
|
||||
*/
|
||||
@ -158,14 +105,11 @@ export function useBackgroundTransition({
|
||||
|
||||
/**
|
||||
* Complete fade-in animation.
|
||||
* Called either by onAnimationEnd or by timer fallback.
|
||||
* Uses ref to prevent double-completion.
|
||||
*/
|
||||
const completeFadeIn = useCallback(() => {
|
||||
if (fadeInCompletedRef.current) return;
|
||||
fadeInCompletedRef.current = true;
|
||||
|
||||
// Clear backup timer if it exists
|
||||
if (fadeInTimerRef.current) {
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
@ -174,111 +118,16 @@ export function useBackgroundTransition({
|
||||
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).
|
||||
*
|
||||
* 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(() => {
|
||||
// Read from ref to get current hasActiveTransition value
|
||||
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
|
||||
|
||||
// Skip clearing during video transitions - let RuntimePresentation handle it
|
||||
// Skip clearing during video transitions
|
||||
if (hasActiveTransition) return;
|
||||
|
||||
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();
|
||||
}
|
||||
}, [
|
||||
@ -289,25 +138,13 @@ export function useBackgroundTransition({
|
||||
]);
|
||||
|
||||
/**
|
||||
* Layout effect: Set up crossfade BEFORE browser paints when switching starts.
|
||||
* useLayoutEffect runs synchronously after DOM mutations but before paint,
|
||||
* 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).
|
||||
* Layout effect: Start fade-from-black when switching starts.
|
||||
* useLayoutEffect runs before paint, ensuring overlay appears immediately.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
// Read from ref to get latest value without triggering re-runs
|
||||
const currentFadeIn = fadeInRef.current;
|
||||
|
||||
// Skip crossfade logic if fadeIn config was not provided
|
||||
// Skip if fadeIn config was not provided
|
||||
if (!currentFadeIn) {
|
||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||
return;
|
||||
@ -318,13 +155,10 @@ export function useBackgroundTransition({
|
||||
|
||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||
|
||||
// Only start crossfade for NON-transition navigation
|
||||
// Transitions use video overlay - no fade needed
|
||||
// Only start fade for NON-transition navigation
|
||||
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
|
||||
// Reset completion flag for new animation
|
||||
fadeInCompletedRef.current = false;
|
||||
|
||||
// Clear any existing timer
|
||||
if (fadeInTimerRef.current) {
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
@ -332,12 +166,10 @@ export function useBackgroundTransition({
|
||||
|
||||
setIsFadingIn(true);
|
||||
|
||||
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
|
||||
// can be unreliable or fire on wrong animations.
|
||||
// Timer is slightly longer than CSS duration to let CSS complete first.
|
||||
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
|
||||
const duration = getCrossfadeDuration();
|
||||
// Add 50ms buffer for Safari's animation timing variance
|
||||
// Timer to end fade after animation duration
|
||||
const duration = getCrossfadeDuration(
|
||||
transitionSettingsRef.current?.durationMs,
|
||||
);
|
||||
const bufferMs = isSafari() ? 100 : 50;
|
||||
|
||||
fadeInTimerRef.current = setTimeout(() => {
|
||||
@ -345,8 +177,6 @@ export function useBackgroundTransition({
|
||||
completeFadeIn();
|
||||
}, 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
|
||||
}, [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 {
|
||||
isOverlayFadingOut,
|
||||
resetFadeOut,
|
||||
isFadingIn,
|
||||
onFadeInAnimationEnd,
|
||||
resetFadeIn,
|
||||
transitionStyle,
|
||||
};
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ const EMPTY_PAGES: TourPage[] = [];
|
||||
const EMPTY_ASSETS: Asset[] = [];
|
||||
|
||||
interface UseConstructorDataResult {
|
||||
// Project
|
||||
// Project (note: transition_settings now fetched separately via project_transition_settings store)
|
||||
project: {
|
||||
name: string;
|
||||
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.
|
||||
* Single source of truth: CSS variable --crossfade-duration in main.css.
|
||||
* Get crossfade duration from CSS custom property or override.
|
||||
* 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
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
@ -10,8 +10,10 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CanvasBackground from '../components/Constructor/CanvasBackground';
|
||||
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
|
||||
import ConstructorControlsPanel from '../components/Constructor/ConstructorControlsPanel';
|
||||
import ConstructorMenu from '../components/Constructor/ConstructorMenu';
|
||||
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
|
||||
@ -27,6 +29,18 @@ import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
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 { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { parseJsonObject } from '../lib/parseJson';
|
||||
@ -113,6 +127,10 @@ const labelByType = ELEMENT_TYPE_LABELS;
|
||||
|
||||
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const globalTransitionDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.data,
|
||||
);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const elementEditorRef = 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)}`;
|
||||
}, [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 value = router.query.pageId;
|
||||
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
|
||||
});
|
||||
|
||||
// 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] =
|
||||
useState<EditorMenuItem>('none');
|
||||
// Transition preview state managed by useTransitionPreview hook (below)
|
||||
@ -248,6 +291,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||
useState(false);
|
||||
// Current element transition settings (for CSS transitions when no video)
|
||||
const [
|
||||
currentElementTransitionSettings,
|
||||
setCurrentElementTransitionSettings,
|
||||
] = useState<ElementTransitionSettings | null>(null);
|
||||
|
||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||
@ -335,13 +383,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Last project-level save: most recent updatedAt across all pages
|
||||
const lastProjectSaveAt = useMemo(() => {
|
||||
if (!pages.length) return null;
|
||||
return pages.reduce((latest, page) => {
|
||||
return pages.reduce(
|
||||
(latest, page) => {
|
||||
if (!page.updatedAt) return latest;
|
||||
if (!latest) return page.updatedAt;
|
||||
return new Date(page.updatedAt) > new Date(latest)
|
||||
? page.updatedAt
|
||||
: latest;
|
||||
}, null as string | null);
|
||||
},
|
||||
null as string | null,
|
||||
);
|
||||
}, [pages]);
|
||||
|
||||
// Transition preview state management
|
||||
@ -400,10 +451,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
||||
const pageSwitchToPage = pageSwitch.switchToPage;
|
||||
|
||||
// Use shared background transition hook for direct navigation clearing and crossfade
|
||||
// Crossfade starts automatically when new background is ready
|
||||
const { isFadingIn } = useBackgroundTransition({
|
||||
// Resolve transition settings using cascade: element → project → global
|
||||
const transitionSettings = useTransitionSettings({
|
||||
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,
|
||||
transitionSettings,
|
||||
fadeIn: {
|
||||
hasActiveTransition: Boolean(transitionPreview),
|
||||
},
|
||||
@ -464,7 +523,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
updateBackgroundFromPage(page);
|
||||
|
||||
// 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(
|
||||
page
|
||||
? {
|
||||
@ -1198,6 +1257,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
}
|
||||
: 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
|
||||
if (!hasPlayableTransition(transitionSource, direction)) {
|
||||
closeTransitionPreview();
|
||||
@ -1594,10 +1668,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
}}
|
||||
>
|
||||
<BackdropPortalProvider>
|
||||
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10) */}
|
||||
<div
|
||||
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||
>
|
||||
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||
Previous background overlay shows during loading.
|
||||
Black overlay for fade effect is rendered separately at z-[100]. */}
|
||||
<div className='absolute inset-0 z-5'>
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageSrc}
|
||||
backgroundVideoUrl={backgroundVideoSrc}
|
||||
@ -1606,7 +1680,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
isFadingIn={isFadingIn}
|
||||
onBackgroundReady={() => {
|
||||
pageSwitch.markBackgroundReady();
|
||||
setIsBackgroundReady(true);
|
||||
@ -1623,10 +1696,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
</div>
|
||||
|
||||
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
|
||||
UI controls (z-50) remain on top. */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||
>
|
||||
UI controls (z-50) remain on top.
|
||||
No fade animation - elements switch instantly behind the black overlay. */}
|
||||
<div className='absolute inset-0 z-[46]'>
|
||||
{isLoading ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<p className='text-sm text-gray-500'>
|
||||
@ -1697,6 +1769,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,9 +7,20 @@ import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import { getPageTitle } from '../config';
|
||||
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 {
|
||||
GlobalTransitionDefaults,
|
||||
TransitionType,
|
||||
EasingFunction,
|
||||
} from '../types/transition';
|
||||
|
||||
type ElementTypeDefault = UiElementDefault & {
|
||||
element_type: string;
|
||||
@ -23,11 +34,81 @@ const toHumanLabel = (value: string) =>
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.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 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 [isLoading, setIsLoading] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -78,6 +159,114 @@ const ElementTypeDefaultsPage = () => {
|
||||
{''}
|
||||
</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>
|
||||
{isLoading ? (
|
||||
<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 pwa_cachesSlice from './pwa_caches/pwa_cachesSlice';
|
||||
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({
|
||||
reducer: {
|
||||
@ -40,6 +42,8 @@ export const store = configureStore({
|
||||
publish_events: publish_eventsSlice,
|
||||
pwa_caches: pwa_cachesSlice,
|
||||
access_logs: access_logsSlice,
|
||||
global_transition_defaults: globalTransitionDefaultsSlice,
|
||||
project_transition_settings: projectTransitionSettingsSlice,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import type { ElementStyleProperties } from '../lib/elementStyles';
|
||||
import type { ElementEffectProperties } from '../lib/elementEffects';
|
||||
import type { TransitionType, EasingFunction } from './transition';
|
||||
|
||||
/**
|
||||
* 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) */
|
||||
transitionReversedStorageKey?: string;
|
||||
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
|
||||
galleryCarouselPrevIconUrl?: string;
|
||||
galleryCarouselNextIconUrl?: string;
|
||||
|
||||
@ -2,6 +2,11 @@
|
||||
* Entity Types
|
||||
*/
|
||||
|
||||
import {
|
||||
GlobalTransitionDefaults,
|
||||
ProjectTransitionSettingsEntity,
|
||||
} from './transition';
|
||||
|
||||
// Base entity interface that all entities extend
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
@ -43,6 +48,8 @@ export interface Project extends BaseEntity {
|
||||
og_image_url?: string;
|
||||
design_width?: number;
|
||||
design_height?: number;
|
||||
// Note: transition_settings is now stored in project_transition_settings table
|
||||
// with environment awareness (dev, stage, production)
|
||||
is_deleted?: boolean;
|
||||
deleted_at_time?: string | Date | null;
|
||||
}
|
||||
@ -281,11 +288,13 @@ export type EntityName =
|
||||
| 'asset_variants'
|
||||
| 'tour_pages'
|
||||
| 'project_audio_tracks'
|
||||
| 'project_transition_settings'
|
||||
| 'publish_events'
|
||||
| 'pwa_caches'
|
||||
| 'presigned_url_requests'
|
||||
| 'access_logs'
|
||||
| 'element_type_defaults';
|
||||
| 'element_type_defaults'
|
||||
| 'global_transition_defaults';
|
||||
|
||||
// Entity type map for generic lookups
|
||||
export interface EntityTypeMap {
|
||||
@ -298,9 +307,11 @@ export interface EntityTypeMap {
|
||||
asset_variants: AssetVariant;
|
||||
tour_pages: TourPage;
|
||||
project_audio_tracks: ProjectAudioTrack;
|
||||
project_transition_settings: ProjectTransitionSettingsEntity;
|
||||
publish_events: PublishEvent;
|
||||
pwa_caches: PwaCache;
|
||||
presigned_url_requests: PresignedUrlRequest;
|
||||
access_logs: AccessLog;
|
||||
element_type_defaults: UIElement;
|
||||
global_transition_defaults: GlobalTransitionDefaults;
|
||||
}
|
||||
|
||||
@ -18,3 +18,4 @@ export * from './ui';
|
||||
export * from './openai';
|
||||
export * from './components';
|
||||
export * from './charts';
|
||||
export * from './transition';
|
||||
|
||||
@ -9,6 +9,7 @@ import type { PreloadPage, PreloadPageLink, PreloadElement } from './preload';
|
||||
|
||||
/**
|
||||
* Runtime project data
|
||||
* Note: transition_settings now fetched separately via project_transition_settings store
|
||||
*/
|
||||
export interface RuntimeProject {
|
||||
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