From e855db03d19bbf3d4b6c83db58dc78136c4c0a0a Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 4 May 2026 12:18:56 +0200 Subject: [PATCH] added configured fades for projects pages --- .../src/db/api/global_transition_defaults.js | 155 ++++++++ .../src/db/api/project_transition_settings.js | 273 ++++++++++++++ backend/src/db/api/projects.js | 20 +- .../20260430000001-add-transition-settings.js | 103 +++++ ...-simplify-transitions-add-overlay-color.js | 76 ++++ ...0002-create-project-transition-settings.js | 217 +++++++++++ .../db/models/global_transition_defaults.js | 73 ++++ .../db/models/project_transition_settings.js | 103 +++++ backend/src/db/models/projects.js | 13 + backend/src/index.js | 14 + .../src/routes/global_transition_defaults.js | 117 ++++++ .../src/routes/project_transition_settings.js | 357 ++++++++++++++++++ .../services/global_transition_defaults.js | 6 + .../services/project_transition_settings.js | 158 ++++++++ backend/src/services/publish.js | 28 +- .../Constructor/CanvasBackground.tsx | 8 +- .../Constructor/ElementEditorPanel.tsx | 8 + .../NavigationSettingsSectionCompact.tsx | 120 +++++- .../components/PreviousBackgroundOverlay.tsx | 22 +- .../src/components/RuntimePresentation.tsx | 115 +++++- frontend/src/components/TourFlowManager.tsx | 288 +++++++++++++- .../src/components/TransitionBlackOverlay.tsx | 53 +++ frontend/src/css/main.css | 77 +++- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useBackgroundTransition.ts | 246 ++---------- frontend/src/hooks/useConstructorData.ts | 2 +- frontend/src/hooks/useTransitionSettings.ts | 175 +++++++++ frontend/src/lib/browserUtils.ts | 15 +- frontend/src/pages/constructor.tsx | 122 +++++- frontend/src/pages/element-type-defaults.tsx | 189 ++++++++++ .../globalTransitionDefaultsSlice.ts | 128 +++++++ .../projectTransitionSettingsSlice.ts | 269 +++++++++++++ frontend/src/stores/store.ts | 4 + frontend/src/types/constructor.ts | 9 + frontend/src/types/entities.ts | 13 +- frontend/src/types/index.ts | 1 + frontend/src/types/runtime.ts | 1 + frontend/src/types/transition.ts | 186 +++++++++ 38 files changed, 3471 insertions(+), 295 deletions(-) create mode 100644 backend/src/db/api/global_transition_defaults.js create mode 100644 backend/src/db/api/project_transition_settings.js create mode 100644 backend/src/db/migrations/20260430000001-add-transition-settings.js create mode 100644 backend/src/db/migrations/20260501000001-simplify-transitions-add-overlay-color.js create mode 100644 backend/src/db/migrations/20260501000002-create-project-transition-settings.js create mode 100644 backend/src/db/models/global_transition_defaults.js create mode 100644 backend/src/db/models/project_transition_settings.js create mode 100644 backend/src/routes/global_transition_defaults.js create mode 100644 backend/src/routes/project_transition_settings.js create mode 100644 backend/src/services/global_transition_defaults.js create mode 100644 backend/src/services/project_transition_settings.js create mode 100644 frontend/src/components/TransitionBlackOverlay.tsx create mode 100644 frontend/src/hooks/useTransitionSettings.ts create mode 100644 frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts create mode 100644 frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts create mode 100644 frontend/src/types/transition.ts diff --git a/backend/src/db/api/global_transition_defaults.js b/backend/src/db/api/global_transition_defaults.js new file mode 100644 index 0000000..f8974a6 --- /dev/null +++ b/backend/src/db/api/global_transition_defaults.js @@ -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; diff --git a/backend/src/db/api/project_transition_settings.js b/backend/src/db/api/project_transition_settings.js new file mode 100644 index 0000000..547ad4c --- /dev/null +++ b/backend/src/db/api/project_transition_settings.js @@ -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; diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index 0d79fc0..3bca6d5 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -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, }; } diff --git a/backend/src/db/migrations/20260430000001-add-transition-settings.js b/backend/src/db/migrations/20260430000001-add-transition-settings.js new file mode 100644 index 0000000..e94da7b --- /dev/null +++ b/backend/src/db/migrations/20260430000001-add-transition-settings.js @@ -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'); + }, +}; diff --git a/backend/src/db/migrations/20260501000001-simplify-transitions-add-overlay-color.js b/backend/src/db/migrations/20260501000001-simplify-transitions-add-overlay-color.js new file mode 100644 index 0000000..773bb59 --- /dev/null +++ b/backend/src/db/migrations/20260501000001-simplify-transitions-add-overlay-color.js @@ -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; + } + }, +}; diff --git a/backend/src/db/migrations/20260501000002-create-project-transition-settings.js b/backend/src/db/migrations/20260501000002-create-project-transition-settings.js new file mode 100644 index 0000000..8de19d3 --- /dev/null +++ b/backend/src/db/migrations/20260501000002-create-project-transition-settings.js @@ -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";'); + }, +}; diff --git a/backend/src/db/models/global_transition_defaults.js b/backend/src/db/models/global_transition_defaults.js new file mode 100644 index 0000000..c6a67e8 --- /dev/null +++ b/backend/src/db/models/global_transition_defaults.js @@ -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; +}; diff --git a/backend/src/db/models/project_transition_settings.js b/backend/src/db/models/project_transition_settings.js new file mode 100644 index 0000000..40bb980 --- /dev/null +++ b/backend/src/db/models/project_transition_settings.js @@ -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; +}; diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js index 557fb7b..d345756 100644 --- a/backend/src/db/models/projects.js +++ b/backend/src/db/models/projects.js @@ -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, { diff --git a/backend/src/index.js b/backend/src/index.js index 93d5a1a..cc2b408 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js new file mode 100644 index 0000000..625f239 --- /dev/null +++ b/backend/src/routes/global_transition_defaults.js @@ -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; diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js new file mode 100644 index 0000000..7eccecb --- /dev/null +++ b/backend/src/routes/project_transition_settings.js @@ -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; diff --git a/backend/src/services/global_transition_defaults.js b/backend/src/services/global_transition_defaults.js new file mode 100644 index 0000000..674e55e --- /dev/null +++ b/backend/src/services/global_transition_defaults.js @@ -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', +}); diff --git a/backend/src/services/project_transition_settings.js b/backend/src/services/project_transition_settings.js new file mode 100644 index 0000000..1d6590b --- /dev/null +++ b/backend/src/services/project_transition_settings.js @@ -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; + } + } +}; diff --git a/backend/src/services/publish.js b/backend/src/services/publish.js index a144598..14027bc 100644 --- a/backend/src/services/publish.js +++ b/backend/src/services/publish.js @@ -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, }; } }; diff --git a/frontend/src/components/Constructor/CanvasBackground.tsx b/frontend/src/components/Constructor/CanvasBackground.tsx index 1a4a773..71c67fc 100644 --- a/frontend/src/components/Constructor/CanvasBackground.tsx +++ b/frontend/src/components/Constructor/CanvasBackground.tsx @@ -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 = ({ previousBgVideoUrl, isSwitching = false, isNewBgReady = false, - isFadingIn = false, onBackgroundReady, videoAutoplay = true, videoLoop = true, @@ -165,15 +163,13 @@ const CanvasBackground: React.FC = ({ )} - {/* 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). */} {/* Background video - z-1 keeps it below backdrop blur layer (z-5) diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 187f955..359b18f 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -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} diff --git a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx index 8cebd56..4a82931 100644 --- a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx @@ -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< )} -

- Transition duration is set automatically from the selected video. -

+ {/* CSS Transition Settings (when no video selected) */} + {!transitionVideoUrl && ( + <> +

+ No transition video selected. Configure CSS transition instead: +

+
+ + +
+
+ + { + const val = event.target.value; + onChange( + 'transitionDurationMs', + val === '' ? '' : Math.max(0, parseInt(val, 10) || 0), + ); + }} + /> +
+
+ + +
+
+ +
+ + onChange('transitionOverlayColor', event.target.value) + } + /> + + onChange('transitionOverlayColor', event.target.value) + } + /> +
+
+ + )} + + {transitionVideoUrl && ( +

+ Transition duration is set automatically from the selected video. +

+ )} {onPreviewTransition && (
diff --git a/frontend/src/components/PreviousBackgroundOverlay.tsx b/frontend/src/components/PreviousBackgroundOverlay.tsx index 55ea536..c0e2ad8 100644 --- a/frontend/src/components/PreviousBackgroundOverlay.tsx +++ b/frontend/src/components/PreviousBackgroundOverlay.tsx @@ -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 = ({ 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 && (
= ({ }} /> )} + {/* Previous background video */} {videoUrl && (