added configured fades for projects pages

This commit is contained in:
Dmitri 2026-05-04 12:18:56 +02:00
parent 5ef21543b3
commit e855db03d1
38 changed files with 3471 additions and 295 deletions

View 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;

View 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;

View File

@ -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,
};
}

View File

@ -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');
},
};

View File

@ -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;
}
},
};

View File

@ -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";');
},
};

View 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;
};

View 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;
};

View File

@ -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, {

View File

@ -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);

View 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;

View 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;

View 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',
});

View 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;
}
}
};

View File

@ -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,
};
}
};

View File

@ -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)

View File

@ -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}

View File

@ -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'>

View File

@ -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

View File

@ -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) */}

View File

@ -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 &quot;Save
to Stage&quot; and to Production when you &quot;Publish&quot;.
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'

View 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;

View File

@ -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 {

View File

@ -22,7 +22,7 @@ export type {
} from './usePageNavigation';
export { useBackgroundTransition } from './useBackgroundTransition';
export type {
FadeOutConfig,
FadeInConfig,
UseBackgroundTransitionOptions,
UseBackgroundTransitionResult,
} from './useBackgroundTransition';

View File

@ -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,
};
}

View File

@ -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;

View 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,
};
}

View File

@ -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;

View File

@ -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>

View File

@ -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'>

View File

@ -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;

View File

@ -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;

View File

@ -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,
},
});

View File

@ -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;

View File

@ -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;
}

View File

@ -18,3 +18,4 @@ export * from './ui';
export * from './openai';
export * from './components';
export * from './charts';
export * from './transition';

View File

@ -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;

View 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;
}