From 63909ef66a10f99040f99859e3f231f70d5c0ac6 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sun, 28 Jun 2026 15:04:07 +0200 Subject: [PATCH] Added ability to customize global actions buttons (fullscreen, offline, mute) --- .../src/db/api/global_ui_control_defaults.js | 164 ++++++ .../src/db/api/project_ui_control_settings.js | 183 +++++++ backend/src/db/api/projects.js | 1 + backend/src/db/api/tour_pages.js | 4 + backend/src/db/api/users.js | 32 +- ...-private-production-presentation-access.js | 14 +- ...260628000001-create-ui-control-settings.js | 225 ++++++++ ...5-snapshot-existing-project-ui-controls.js | 138 +++++ .../db/models/global_ui_control_defaults.js | 34 ++ .../db/models/project_ui_control_settings.js | 62 +++ backend/src/db/models/projects.js | 10 + backend/src/db/models/tour_pages.js | 4 + backend/src/index.js | 8 + backend/src/middlewares/check-permissions.js | 2 + backend/src/middlewares/runtime-public.js | 1 + .../src/routes/global_ui_control_defaults.js | 59 ++ .../src/routes/project_transition_settings.js | 40 +- .../src/routes/project_ui_control_settings.js | 185 +++++++ backend/src/routes/runtime-access.js | 25 +- backend/src/routes/sql.js | 6 +- backend/src/services/file.js | 14 +- .../services/global_ui_control_defaults.js | 6 + .../services/project_ui_control_settings.js | 61 +++ backend/src/services/projects.js | 44 ++ backend/src/services/publish.js | 59 +- backend/src/services/tour_pages.js | 34 +- backend/src/services/videoProcessing.js | 11 +- backend/watcher.js | 64 +-- .../Constructor/ConstructorToolbar.tsx | 8 +- .../Constructor/ElementEditorPanel.tsx | 375 +++++++++++-- .../components/Runtime/RuntimeControls.tsx | 511 +++++++++++++----- .../src/components/RuntimePresentation.tsx | 41 ++ frontend/src/components/SelectFieldMany.tsx | 4 +- frontend/src/components/TourFlowManager.tsx | 117 ++++ frontend/src/context/ConstructorContext.tsx | 22 + frontend/src/hooks/useCanvasElementDrag.ts | 32 +- frontend/src/hooks/useConstructorElements.ts | 7 +- .../src/hooks/useConstructorPageActions.ts | 8 + frontend/src/pages/constructor.tsx | 241 +++++++-- frontend/src/pages/dashboard.tsx | 3 +- frontend/src/pages/element-type-defaults.tsx | 72 +++ frontend/src/pages/login.tsx | 4 +- .../src/pages/tour_pages/tour_pages-edit.tsx | 12 + frontend/src/pages/users/users-new.tsx | 170 +++--- .../globalUiControlDefaultsSlice.ts | 112 ++++ .../projectUiControlSettingsSlice.ts | 232 ++++++++ frontend/src/stores/store.ts | 4 + frontend/src/types/entities.ts | 13 +- frontend/src/types/runtime.ts | 3 + frontend/src/types/uiControls.ts | 256 +++++++++ 50 files changed, 3301 insertions(+), 436 deletions(-) create mode 100644 backend/src/db/api/global_ui_control_defaults.js create mode 100644 backend/src/db/api/project_ui_control_settings.js create mode 100644 backend/src/db/migrations/20260628000001-create-ui-control-settings.js create mode 100644 backend/src/db/migrations/20260628000005-snapshot-existing-project-ui-controls.js create mode 100644 backend/src/db/models/global_ui_control_defaults.js create mode 100644 backend/src/db/models/project_ui_control_settings.js create mode 100644 backend/src/routes/global_ui_control_defaults.js create mode 100644 backend/src/routes/project_ui_control_settings.js create mode 100644 backend/src/services/global_ui_control_defaults.js create mode 100644 backend/src/services/project_ui_control_settings.js create mode 100644 frontend/src/stores/global_ui_control_defaults/globalUiControlDefaultsSlice.ts create mode 100644 frontend/src/stores/project_ui_control_settings/projectUiControlSettingsSlice.ts create mode 100644 frontend/src/types/uiControls.ts diff --git a/backend/src/db/api/global_ui_control_defaults.js b/backend/src/db/api/global_ui_control_defaults.js new file mode 100644 index 0000000..7430b21 --- /dev/null +++ b/backend/src/db/api/global_ui_control_defaults.js @@ -0,0 +1,164 @@ +const GenericDBApi = require('./base.api'); +const db = require('../models'); + +const DEFAULT_SETTINGS = { + fullscreen: { + enabled: true, + hidden: false, + xPercent: 92.75, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#2563EB', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#2563EB', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 2, + }, + sound: { + enabled: true, + hidden: false, + xPercent: 96, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#2563EB', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#2563EB', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 3, + }, + offline: { + enabled: true, + hidden: false, + xPercent: 89.5, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#059669', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#059669', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 1, + }, +}; + +class Global_ui_control_defaultsDBApi extends GenericDBApi { + static get MODEL() { + return db.global_ui_control_defaults; + } + + static get TABLE_NAME() { + return 'global_ui_control_defaults'; + } + + static get SEARCHABLE_FIELDS() { + return []; + } + + static get RANGE_FIELDS() { + return []; + } + + static get ENUM_FIELDS() { + return []; + } + + static get DEFAULT_SETTINGS() { + return DEFAULT_SETTINGS; + } + + static getFieldMapping(data) { + const mapped = super.getFieldMapping(data); + return { + id: mapped.id || undefined, + settings_json: + mapped.settings_json || mapped.settings || DEFAULT_SETTINGS, + }; + } + + static async ensureInitialized() { + if (!this.initializationPromise) { + this.initializationPromise = (async () => { + let count = 0; + + try { + count = await this.MODEL.count(); + } catch (error) { + 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({ + settings_json: DEFAULT_SETTINGS, + createdAt: now, + updatedAt: now, + }); + })().catch((error) => { + this.initializationPromise = null; + throw error; + }); + } + + await this.initializationPromise; + } + + 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 }); + } + + 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); + } +} + +Global_ui_control_defaultsDBApi.initializationPromise = null; + +module.exports = Global_ui_control_defaultsDBApi; diff --git a/backend/src/db/api/project_ui_control_settings.js b/backend/src/db/api/project_ui_control_settings.js new file mode 100644 index 0000000..0e7a333 --- /dev/null +++ b/backend/src/db/api/project_ui_control_settings.js @@ -0,0 +1,183 @@ +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_ui_control_settingsDBApi extends GenericDBApi { + static get MODEL() { + return db.project_ui_control_settings; + } + + static get TABLE_NAME() { + return 'project_ui_control_settings'; + } + + static get SEARCHABLE_FIELDS() { + return ['source_key']; + } + + static get RANGE_FIELDS() { + return []; + } + + static get ENUM_FIELDS() { + return ['environment']; + } + + static get ASSOCIATIONS() { + return [{ field: 'project', setter: 'setProject', isArray: false }]; + } + + static getFieldMapping(data) { + return { + id: data.id || undefined, + source_key: data.source_key || null, + settings_json: data.settings_json || data.settings || {}, + }; + } + + static async findByProjectAndEnvironment( + projectId, + environment, + options = {}, + ) { + const record = await this.MODEL.findOne({ + where: { projectId, environment }, + transaction: options.transaction, + include: [{ model: db.projects, as: 'project' }], + }); + + if (!record) return null; + return record.get({ plain: true }); + } + + static async upsertForProject(projectId, environment, data, options = {}) { + const transaction = options.transaction; + const currentUser = options.currentUser; + const existing = await this.MODEL.findOne({ + where: { projectId, environment }, + transaction, + }); + + if (existing) { + await existing.update( + { + ...this.getFieldMapping(data), + updatedById: currentUser?.id || null, + }, + { transaction }, + ); + return existing.get({ plain: true }); + } + + 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 queryWhere = applyRuntimeEnvironment({ ...where }, options); + const projectInclude = applyRuntimeProjectFilter( + { model: db.projects, as: 'project' }, + options, + ); + + const record = await this.MODEL.findOne({ + where: queryWhere, + transaction: options.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 = Number(filter.page) || 0; + const offset = Math.max(currentPage - 1, 0) * 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.ENUM_FIELDS) { + if (filter[field] !== undefined) { + where[field] = filter[field]; + } + } + + where = applyRuntimeEnvironment(where, options); + + const { rows, count } = await this.MODEL.findAndCountAll({ + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options.transaction, + limit: !options.countOnly && limit ? Number(limit) : undefined, + offset: !options.countOnly && offset ? Number(offset) : undefined, + }); + + return { + rows: options.countOnly ? [] : rows, + count, + }; + } +} + +module.exports = Project_ui_control_settingsDBApi; diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index d066d57..e948a39 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -84,6 +84,7 @@ class ProjectsDBApi extends GenericDBApi { { association: 'publish_events_project' }, { association: 'pwa_caches_project' }, { association: 'access_logs_project' }, + { association: 'project_ui_control_settings_project' }, ]; } diff --git a/backend/src/db/api/tour_pages.js b/backend/src/db/api/tour_pages.js index 6f07766..f735beb 100644 --- a/backend/src/db/api/tour_pages.js +++ b/backend/src/db/api/tour_pages.js @@ -117,6 +117,10 @@ class Tour_pagesDBApi extends GenericDBApi { data.design_height !== undefined ? data.design_height : null, requires_auth: data.requires_auth || false, ui_schema_json: data.ui_schema_json || null, + global_ui_controls_settings_json: + data.global_ui_controls_settings_json !== undefined + ? data.global_ui_controls_settings_json + : null, }; } diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index d401559..f9f8036 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -54,7 +54,8 @@ module.exports = class UsersDBApi { static async create(data, options) { const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - const password = data.data.password || crypto.randomBytes(20).toString('hex'); + const password = + data.data.password || crypto.randomBytes(20).toString('hex'); const users = await db.users.create( { @@ -357,22 +358,21 @@ module.exports = class UsersDBApi { transaction, }); - output.allowed_private_production_project_ids = - productionPresentationAccess - .map((row) => { - const plain = - typeof row.get === 'function' ? row.get({ plain: true }) : row; - const project = plain.project; - if (!project?.id) return null; + output.allowed_private_production_project_ids = productionPresentationAccess + .map((row) => { + const plain = + typeof row.get === 'function' ? row.get({ plain: true }) : row; + const project = plain.project; + if (!project?.id) return null; - return { - id: project.id, - label: `${project.name} (${project.slug})`, - name: project.name, - slug: project.slug, - }; - }) - .filter(Boolean); + return { + id: project.id, + label: `${project.name} (${project.slug})`, + name: project.name, + slug: project.slug, + }; + }) + .filter(Boolean); return output; } diff --git a/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js b/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js index 55f5b05..68db832 100644 --- a/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js +++ b/backend/src/db/migrations/20260626000001-add-private-production-presentation-access.js @@ -2,11 +2,15 @@ module.exports = { async up(queryInterface, Sequelize) { - await queryInterface.addColumn('projects', 'production_presentation_visibility', { - type: Sequelize.ENUM('public', 'private'), - allowNull: false, - defaultValue: 'public', - }); + await queryInterface.addColumn( + 'projects', + 'production_presentation_visibility', + { + type: Sequelize.ENUM('public', 'private'), + allowNull: false, + defaultValue: 'public', + }, + ); await queryInterface.createTable('production_presentation_access', { id: { diff --git a/backend/src/db/migrations/20260628000001-create-ui-control-settings.js b/backend/src/db/migrations/20260628000001-create-ui-control-settings.js new file mode 100644 index 0000000..1bac721 --- /dev/null +++ b/backend/src/db/migrations/20260628000001-create-ui-control-settings.js @@ -0,0 +1,225 @@ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +const DEFAULT_SETTINGS = { + fullscreen: { + enabled: true, + hidden: false, + xPercent: 92.75, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#2563EB', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#2563EB', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 2, + }, + sound: { + enabled: true, + hidden: false, + xPercent: 96, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#2563EB', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#2563EB', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 3, + }, + offline: { + enabled: true, + hidden: false, + xPercent: 89.5, + yPercent: 6, + anchor: 'center', + buttonSizePercent: 2.6, + iconSizePercent: 1.35, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#059669', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#059669', + borderRadiusPercent: 0.42, + opacity: 1, + boxShadow: '', + zIndex: 900, + order: 1, + }, +}; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.createTable( + 'global_ui_control_defaults', + { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + settings_json: { + type: Sequelize.JSON, + allowNull: false, + defaultValue: DEFAULT_SETTINGS, + }, + 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 }, + }, + { transaction }, + ); + + await queryInterface.createTable( + 'project_ui_control_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 }, + settings_json: { + type: Sequelize.JSON, + allowNull: false, + defaultValue: {}, + }, + 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, + }, + }, + { transaction }, + ); + + await queryInterface.addColumn( + 'tour_pages', + 'global_ui_controls_settings_json', + { + type: Sequelize.JSON, + allowNull: true, + defaultValue: null, + }, + { transaction }, + ); + + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS project_ui_control_settings_project_env_unique + ON project_ui_control_settings ("projectId", environment) + WHERE "deletedAt" IS NULL`, + { transaction }, + ); + + await queryInterface.sequelize.query( + `CREATE INDEX IF NOT EXISTS project_ui_control_settings_deleted_at + ON project_ui_control_settings ("deletedAt")`, + { transaction }, + ); + + await queryInterface.bulkInsert( + 'global_ui_control_defaults', + [ + { + id: uuidv4(), + settings_json: DEFAULT_SETTINGS, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.removeColumn( + 'tour_pages', + 'global_ui_controls_settings_json', + { transaction }, + ); + await queryInterface.dropTable('project_ui_control_settings', { + transaction, + }); + await queryInterface.dropTable('global_ui_control_defaults', { + transaction, + }); + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_project_ui_control_settings_environment";', + { transaction }, + ); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260628000005-snapshot-existing-project-ui-controls.js b/backend/src/db/migrations/20260628000005-snapshot-existing-project-ui-controls.js new file mode 100644 index 0000000..5d817e7 --- /dev/null +++ b/backend/src/db/migrations/20260628000005-snapshot-existing-project-ui-controls.js @@ -0,0 +1,138 @@ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +const ENVIRONMENTS = ['dev', 'stage', 'production']; +const CONTROL_SIZE_PERCENT = 2.6; +const ICON_SIZE_PERCENT = 1.35; +const RADIUS_PERCENT = 0.42; +const GAP_PX = 8; +const TOP_OFFSET_PX = 16; +const RIGHT_OFFSET_PX = 64; + +const pxToWidthPercent = (value, width) => (value / width) * 100; +const pxToHeightPercent = (value, height) => (value / height) * 100; + +const buildExistingProjectSettings = (project) => { + const width = project.design_width || 1920; + const height = project.design_height || 1080; + const buttonSizePercent = CONTROL_SIZE_PERCENT; + const iconSizePercent = ICON_SIZE_PERCENT; + const borderRadiusPercent = RADIUS_PERCENT; + const centerGapPercent = buttonSizePercent + pxToWidthPercent(GAP_PX, width); + const buttonSizePx = width * (buttonSizePercent / 100); + const soundXPercent = + 100 - pxToWidthPercent(RIGHT_OFFSET_PX, width) - buttonSizePercent / 2; + const yPercent = pxToHeightPercent(TOP_OFFSET_PX + buttonSizePx / 2, height); + + const base = { + enabled: true, + hidden: false, + yPercent, + anchor: 'center', + buttonSizePercent, + iconSizePercent, + defaultIconUrl: '', + activeIconUrl: '', + defaultBackgroundColor: '#2563EB', + activeBackgroundColor: '#2563EB', + hoverBackgroundColor: '#1D4ED8', + color: '#FFFFFF', + defaultBorderColor: '#2563EB', + activeBorderColor: '#2563EB', + borderRadiusPercent, + opacity: 1, + boxShadow: '', + zIndex: 9999, + }; + + return { + offline: { + ...base, + xPercent: soundXPercent - centerGapPercent * 2, + activeBackgroundColor: '#059669', + activeBorderColor: '#059669', + order: 1, + }, + fullscreen: { + ...base, + xPercent: soundXPercent - centerGapPercent, + order: 2, + }, + sound: { + ...base, + xPercent: soundXPercent, + order: 3, + }, + }; +}; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const projects = await queryInterface.sequelize.query( + `SELECT id, design_width, design_height + FROM projects + WHERE "deletedAt" IS NULL`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + const existingSettings = await queryInterface.sequelize.query( + `SELECT "projectId", environment + FROM project_ui_control_settings + WHERE "deletedAt" IS NULL`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + const existingKeys = new Set( + existingSettings.map((item) => `${item.projectId}:${item.environment}`), + ); + const now = new Date(); + const rows = []; + + projects.forEach((project) => { + const settings = buildExistingProjectSettings(project); + + ENVIRONMENTS.forEach((environment) => { + const key = `${project.id}:${environment}`; + if (existingKeys.has(key)) return; + + rows.push({ + id: uuidv4(), + projectId: project.id, + environment, + source_key: 'existing-project-runtime-defaults', + settings_json: JSON.stringify(settings), + createdAt: now, + updatedAt: now, + }); + }); + }); + + if (rows.length > 0) { + await queryInterface.bulkInsert('project_ui_control_settings', rows, { + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + await queryInterface.bulkDelete('project_ui_control_settings', { + source_key: 'existing-project-runtime-defaults', + }); + }, +}; diff --git a/backend/src/db/models/global_ui_control_defaults.js b/backend/src/db/models/global_ui_control_defaults.js new file mode 100644 index 0000000..3144170 --- /dev/null +++ b/backend/src/db/models/global_ui_control_defaults.js @@ -0,0 +1,34 @@ +module.exports = function (sequelize, DataTypes) { + const global_ui_control_defaults = sequelize.define( + 'global_ui_control_defaults', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + settings_json: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: {}, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + global_ui_control_defaults.associate = (db) => { + db.global_ui_control_defaults.belongsTo(db.users, { + as: 'createdBy', + }); + + db.global_ui_control_defaults.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return global_ui_control_defaults; +}; diff --git a/backend/src/db/models/project_ui_control_settings.js b/backend/src/db/models/project_ui_control_settings.js new file mode 100644 index 0000000..915b6aa --- /dev/null +++ b/backend/src/db/models/project_ui_control_settings.js @@ -0,0 +1,62 @@ +module.exports = function (sequelize, DataTypes) { + const project_ui_control_settings = sequelize.define( + 'project_ui_control_settings', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + environment: { + type: DataTypes.ENUM, + allowNull: false, + values: ['dev', 'stage', 'production'], + }, + source_key: { + type: DataTypes.TEXT, + }, + settings_json: { + type: DataTypes.JSON, + allowNull: false, + defaultValue: {}, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + indexes: [ + { fields: ['projectId'] }, + { fields: ['projectId', 'environment'], unique: true }, + { fields: ['deletedAt'] }, + ], + }, + ); + + project_ui_control_settings.associate = (db) => { + db.project_ui_control_settings.belongsTo(db.projects, { + as: 'project', + foreignKey: { + name: 'projectId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + + db.project_ui_control_settings.belongsTo(db.users, { + as: 'createdBy', + }); + + db.project_ui_control_settings.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return project_ui_control_settings; +}; diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js index 6c800a9..cbf95cc 100644 --- a/backend/src/db/models/projects.js +++ b/backend/src/db/models/projects.js @@ -201,6 +201,16 @@ module.exports = function (sequelize, DataTypes) { onUpdate: 'CASCADE', }); + db.projects.hasMany(db.project_ui_control_settings, { + as: 'project_ui_control_settings_project', + foreignKey: { + name: 'projectId', + }, + constraints: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }); + //end loop db.projects.belongsTo(db.users, { diff --git a/backend/src/db/models/tour_pages.js b/backend/src/db/models/tour_pages.js index 46ecf31..3924b0b 100644 --- a/backend/src/db/models/tour_pages.js +++ b/backend/src/db/models/tour_pages.js @@ -154,6 +154,10 @@ module.exports = function (sequelize, DataTypes) { type: DataTypes.JSON, }, + global_ui_controls_settings_json: { + type: DataTypes.JSON, + }, + importHash: { type: DataTypes.STRING(255), allowNull: true, diff --git a/backend/src/index.js b/backend/src/index.js index e57488c..72e4f0b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -53,6 +53,8 @@ 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 global_ui_control_defaultsRoutes = require('./routes/global_ui_control_defaults'); +const project_ui_control_settingsRoutes = require('./routes/project_ui_control_settings'); const publishRoutes = require('./routes/publish'); const runtimeContextRoutes = require('./routes/runtime-context'); @@ -298,6 +300,12 @@ app.use('/api/global-transition-defaults', global_transition_defaultsRoutes); // Project transition settings - routes handle their own auth (production GET public, else protected) app.use('/api/project-transition-settings', project_transition_settingsRoutes); +// Global UI controls - routes handle their own auth (GET public, PUT protected) +app.use('/api/global-ui-control-defaults', global_ui_control_defaultsRoutes); + +// Project UI controls - routes handle their own auth (production GET public, else protected) +app.use('/api/project-ui-control-settings', project_ui_control_settingsRoutes); + app.use('/api/publish', jwtAuth, publishRoutes); app.use('/api/openai', jwtAuth, aiLimiter, openaiRoutes); diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index 54f2d5e..3e174d8 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -172,6 +172,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([ 'PROJECT_AUDIO_TRACKS', 'GLOBAL_TRANSITION_DEFAULTS', 'PROJECT_TRANSITION_SETTINGS', + 'GLOBAL_UI_CONTROL_DEFAULTS', + 'PROJECT_UI_CONTROL_SETTINGS', ]); /** diff --git a/backend/src/middlewares/runtime-public.js b/backend/src/middlewares/runtime-public.js index 281ae42..6dd9893 100644 --- a/backend/src/middlewares/runtime-public.js +++ b/backend/src/middlewares/runtime-public.js @@ -24,6 +24,7 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'background_loop', 'requires_auth', 'ui_schema_json', + 'global_ui_controls_settings_json', ], project_audio_tracks: [ 'id', diff --git a/backend/src/routes/global_ui_control_defaults.js b/backend/src/routes/global_ui_control_defaults.js new file mode 100644 index 0000000..e024639 --- /dev/null +++ b/backend/src/routes/global_ui_control_defaults.js @@ -0,0 +1,59 @@ +const express = require('express'); +const passport = require('passport'); +const Global_ui_control_defaultsService = require('../services/global_ui_control_defaults'); +const Global_ui_control_defaultsDBApi = require('../db/api/global_ui_control_defaults'); +const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); +const jwtAuth = passport.authenticate('jwt', { session: false }); + +const allowPublicRead = (req, _res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + req.isRuntimePublicRequest = true; + } + return next(); +}; + +router.use(allowPublicRead); +router.use(checkCrudPermissions('global_ui_control_defaults')); + +router.get( + '/', + wrapAsync(async (_req, res) => { + const payload = await Global_ui_control_defaultsDBApi.findOne(); + res.status(200).send(payload); + }), +); + +router.get( + '/:id', + wrapAsync(async (req, res) => { + if (!isUuidV4(req.params.id)) { + return res.status(400).send('Invalid global_ui_control_defaults id'); + } + + const payload = await Global_ui_control_defaultsDBApi.findBy({ + id: req.params.id, + }); + res.status(200).send(payload); + }), +); + +router.put( + '/:id', + jwtAuth, + wrapAsync(async (req, res) => { + await Global_ui_control_defaultsService.update( + req.body.data, + req.params.id, + req.currentUser, + ); + const payload = await Global_ui_control_defaultsDBApi.findOne(); + res.status(200).send(payload); + }), +); + +router.use('/', commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index 987280c..e188f22 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -64,28 +64,34 @@ const requireProductionOrAuth = async (req, res, next) => { } if (isProduction && isReadOnly && isPrivateProductionPresentation) { - return passport.authenticate('jwt', { session: false }, async (error, user) => { - if (error) return next(error); + return passport.authenticate( + 'jwt', + { session: false }, + async (error, user) => { + if (error) return next(error); - if (!user) { - return res.status(401).send({ message: 'Authentication required' }); - } + if (!user) { + return res.status(401).send({ message: 'Authentication required' }); + } - req.currentUser = user; + req.currentUser = user; - const canAccess = - await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( - user, - runtimeProjectSlug, - ); + const canAccess = + await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( + user, + runtimeProjectSlug, + ); - if (!canAccess) { - return res.status(403).send({ message: 'Presentation access denied' }); - } + if (!canAccess) { + return res + .status(403) + .send({ message: 'Presentation access denied' }); + } - req.isRuntimePublicRequest = true; - return next(); - })(req, res, next); + req.isRuntimePublicRequest = true; + return next(); + }, + )(req, res, next); } // Require JWT for non-production or write operations diff --git a/backend/src/routes/project_ui_control_settings.js b/backend/src/routes/project_ui_control_settings.js new file mode 100644 index 0000000..0866670 --- /dev/null +++ b/backend/src/routes/project_ui_control_settings.js @@ -0,0 +1,185 @@ +const express = require('express'); +const passport = require('passport'); +const db = require('../db/models'); +const Project_ui_control_settingsService = require('../services/project_ui_control_settings'); +const Project_ui_control_settingsDBApi = require('../db/api/project_ui_control_settings'); +const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); +const RuntimePresentationAccessService = require('../services/runtime-presentation-access'); + +const router = express.Router(); +const jwtAuth = passport.authenticate('jwt', { session: false }); + +const allowAuthenticatedRead = (req, _res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + req.isRuntimePublicRequest = true; + } + return next(); +}; + +const getRuntimeProjectSlug = async (req) => { + if (req.runtimeContext?.headerProjectSlug) { + return req.runtimeContext.headerProjectSlug; + } + + if (!isUuidV4(req.params.projectId)) { + return null; + } + + const project = await db.projects.findByPk(req.params.projectId, { + attributes: ['slug'], + }); + return project?.slug || null; +}; + +const requireProductionOrAuth = async (req, res, next) => { + const { environment } = req.params; + const isProduction = environment === 'production'; + const isReadOnly = ['GET', 'OPTIONS'].includes(req.method); + + let runtimeProjectSlug = null; + let isPrivateProductionPresentation = false; + + try { + runtimeProjectSlug = await getRuntimeProjectSlug(req); + isPrivateProductionPresentation = + await RuntimePresentationAccessService.isPrivateProductionPresentation( + runtimeProjectSlug, + ); + } catch (error) { + return next(error); + } + + if (isProduction && isReadOnly && !isPrivateProductionPresentation) { + return next(); + } + + if (isProduction && isReadOnly && isPrivateProductionPresentation) { + return passport.authenticate( + 'jwt', + { session: false }, + async (error, user) => { + if (error) return next(error); + if (!user) { + return res.status(401).send({ message: 'Authentication required' }); + } + + req.currentUser = user; + const canAccess = + await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( + user, + runtimeProjectSlug, + ); + + if (!canAccess) { + return res + .status(403) + .send({ message: 'Presentation access denied' }); + } + + req.isRuntimePublicRequest = true; + return next(); + }, + )(req, res, next); + } + + return jwtAuth(req, res, next); +}; + +router.use(allowAuthenticatedRead); +router.use(checkCrudPermissions('project_ui_control_settings')); + +router.get( + '/project/:projectId/env/:environment', + requireProductionOrAuth, + 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_ui_control_settingsService.findByProjectAndEnvironment( + projectId, + environment, + req.currentUser, + ); + + res.status(200).send(settings); + }), +); + +router.put( + '/project/:projectId/env/:environment', + jwtAuth, + 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_ui_control_settingsService.upsertForProject( + projectId, + environment, + req.body.data || {}, + req.currentUser, + ); + + res.status(200).send(settings); + }), +); + +router.delete( + '/project/:projectId/env/:environment', + jwtAuth, + 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_ui_control_settingsService.findByProjectAndEnvironment( + projectId, + environment, + req.currentUser, + ); + + if (settings) { + await Project_ui_control_settingsService.remove( + settings.id, + req.currentUser, + ); + } + + res.status(200).send({ success: true }); + }), +); + +router.get( + '/', + jwtAuth, + wrapAsync(async (req, res) => { + const payload = await Project_ui_control_settingsDBApi.findAll(req.query); + res.status(200).send(payload); + }), +); + +router.use('/', commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/runtime-access.js b/backend/src/routes/runtime-access.js index ac8f748..591e25f 100644 --- a/backend/src/routes/runtime-access.js +++ b/backend/src/routes/runtime-access.js @@ -6,16 +6,21 @@ const { wrapAsync } = require('../helpers'); const router = express.Router(); -router.get('/presentations/:slug', wrapAsync(async (req, res) => { - const slug = RuntimePresentationAccessService.normalizeSlug(req.params.slug); - res.status(200).send({ - slug, - isPrivateProductionPresentation: - await RuntimePresentationAccessService.isPrivateProductionPresentation( - slug, - ), - }); -})); +router.get( + '/presentations/:slug', + wrapAsync(async (req, res) => { + const slug = RuntimePresentationAccessService.normalizeSlug( + req.params.slug, + ); + res.status(200).send({ + slug, + isPrivateProductionPresentation: + await RuntimePresentationAccessService.isPrivateProductionPresentation( + slug, + ), + }); + }), +); router.get( '/private-production-presentations', diff --git a/backend/src/routes/sql.js b/backend/src/routes/sql.js index 39f2032..e406a9c 100644 --- a/backend/src/routes/sql.js +++ b/backend/src/routes/sql.js @@ -43,9 +43,9 @@ router.post( const { currentUser } = req; const isAdminUser = Boolean( currentUser && - currentUser.app_role && - (currentUser.app_role.name === 'Administrator' || - currentUser.app_role.globalAccess === true), + currentUser.app_role && + (currentUser.app_role.name === 'Administrator' || + currentUser.app_role.globalAccess === true), ); if (!isAdminUser) { diff --git a/backend/src/services/file.js b/backend/src/services/file.js index ff5c172..fb6d912 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -101,18 +101,18 @@ const getFileStorageProvider = () => { const hasS3 = Boolean( config.s3.bucket && - config.s3.region && - config.s3.accessKeyId && - config.s3.secretAccessKey, + config.s3.region && + config.s3.accessKeyId && + config.s3.secretAccessKey, ); if (hasS3) return 's3'; const hasGCloud = Boolean( process.env.GC_PROJECT_ID && - process.env.GC_CLIENT_EMAIL && - process.env.GC_PRIVATE_KEY && - config.gcloud.bucket && - config.gcloud.hash, + process.env.GC_CLIENT_EMAIL && + process.env.GC_PRIVATE_KEY && + config.gcloud.bucket && + config.gcloud.hash, ); if (hasGCloud) return 'gcloud'; diff --git a/backend/src/services/global_ui_control_defaults.js b/backend/src/services/global_ui_control_defaults.js new file mode 100644 index 0000000..4ba6e2f --- /dev/null +++ b/backend/src/services/global_ui_control_defaults.js @@ -0,0 +1,6 @@ +const Global_ui_control_defaultsDBApi = require('../db/api/global_ui_control_defaults'); +const { createEntityService } = require('../factories/service.factory'); + +module.exports = createEntityService(Global_ui_control_defaultsDBApi, { + entityName: 'global_ui_control_defaults', +}); diff --git a/backend/src/services/project_ui_control_settings.js b/backend/src/services/project_ui_control_settings.js new file mode 100644 index 0000000..c7a2c5e --- /dev/null +++ b/backend/src/services/project_ui_control_settings.js @@ -0,0 +1,61 @@ +const db = require('../db/models'); +const Project_ui_control_settingsDBApi = require('../db/api/project_ui_control_settings'); +const ValidationError = require('./notifications/errors/validation'); + +module.exports = class Project_ui_control_settingsService { + static async findByProjectAndEnvironment( + projectId, + environment, + currentUser, + ) { + return Project_ui_control_settingsDBApi.findByProjectAndEnvironment( + projectId, + environment, + { currentUser }, + ); + } + + static async upsertForProject(projectId, environment, data, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const result = await Project_ui_control_settingsDBApi.upsertForProject( + projectId, + environment, + data, + { currentUser, transaction }, + ); + + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const record = await Project_ui_control_settingsDBApi.findBy( + { id }, + { transaction }, + ); + + if (!record) { + throw new ValidationError('project_ui_control_settingsNotFound'); + } + + await Project_ui_control_settingsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js index a3562fb..fa10818 100644 --- a/backend/src/services/projects.js +++ b/backend/src/services/projects.js @@ -438,6 +438,14 @@ class ProjectsService extends BaseProjectsService { ); } + if (pageData.global_ui_controls_settings_json) { + pageData.global_ui_controls_settings_json = + transformUiSchemaAssetPaths( + pageData.global_ui_controls_settings_json, + assetPathMap, + ); + } + // Transform background URLs to new storage keys if (pageData.background_image_url) { pageData.background_image_url = @@ -541,6 +549,42 @@ class ProjectsService extends BaseProjectsService { ); } + // Clone project UI control settings (dev environment only) + const sourceUiControlSettings = + await db.project_ui_control_settings.findOne({ + where: { projectId: sourceProjectId, environment: 'dev' }, + transaction, + }); + + if (sourceUiControlSettings) { + const settingsData = sourceUiControlSettings.toJSON(); + delete settingsData.id; + delete settingsData.createdAt; + delete settingsData.updatedAt; + delete settingsData.deletedAt; + delete settingsData.deletedBy; + delete settingsData.importHash; + + if (settingsData.settings_json) { + settingsData.settings_json = transformUiSchemaAssetPaths( + settingsData.settings_json, + assetPathMap, + ); + } + + await db.project_ui_control_settings.create( + { + ...settingsData, + projectId: clonedProject.id, + environment: 'dev', + source_key: sourceUiControlSettings.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + await transaction.commit(); return clonedProject; } catch (error) { diff --git a/backend/src/services/publish.js b/backend/src/services/publish.js index 9899660..599eaa9 100644 --- a/backend/src/services/publish.js +++ b/backend/src/services/publish.js @@ -257,21 +257,29 @@ module.exports = class PublishService { transaction, ) { // Get source content - const [sourcePages, sourceAudioTracks, sourceTransitionSettings] = - await Promise.all([ - db.tour_pages.findAll({ - where: { projectId, environment: fromEnv }, - transaction, - }), - db.project_audio_tracks.findAll({ - where: { projectId, environment: fromEnv }, - transaction, - }), - db.project_transition_settings.findOne({ - where: { projectId, environment: fromEnv }, - transaction, - }), - ]); + const [ + sourcePages, + sourceAudioTracks, + sourceTransitionSettings, + sourceUiControlSettings, + ] = await Promise.all([ + db.tour_pages.findAll({ + where: { projectId, environment: fromEnv }, + transaction, + }), + db.project_audio_tracks.findAll({ + where: { projectId, environment: fromEnv }, + transaction, + }), + db.project_transition_settings.findOne({ + where: { projectId, environment: fromEnv }, + transaction, + }), + db.project_ui_control_settings.findOne({ + where: { projectId, environment: fromEnv }, + transaction, + }), + ]); // Clean up target environment (hard delete - paranoid models need force: true) await Promise.all([ @@ -290,6 +298,11 @@ module.exports = class PublishService { transaction, force: true, }), + db.project_ui_control_settings.destroy({ + where: { projectId, environment: toEnv }, + transaction, + force: true, + }), ]); const actorId = currentUser?.id || null; @@ -351,10 +364,26 @@ module.exports = class PublishService { ); } + if (sourceUiControlSettings) { + const settingsData = sanitizeRecordForClone(sourceUiControlSettings); + await db.project_ui_control_settings.create( + { + ...settingsData, + projectId, + environment: toEnv, + source_key: sourceUiControlSettings.id, + createdById: actorId, + updatedById: actorId, + }, + { transaction }, + ); + } + return { pages_copied: sourcePages.length, audios_copied: sourceAudioTracks.length, transition_settings_copied: sourceTransitionSettings ? 1 : 0, + ui_control_settings_copied: sourceUiControlSettings ? 1 : 0, }; } }; diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index d3a9652..e19f195 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -9,7 +9,11 @@ const Tour_pagesDBApi = require('../db/api/tour_pages'); const AssetsDBApi = require('../db/api/assets'); const Asset_variantsDBApi = require('../db/api/asset_variants'); const { createEntityService } = require('../factories/service.factory'); -const { downloadToBuffer, downloadToTempFile, uploadBuffer } = require('./file'); +const { + downloadToBuffer, + downloadToTempFile, + uploadBuffer, +} = require('./file'); const ValidationError = require('./notifications/errors/validation'); const videoProcessing = require('./videoProcessing'); const { logger } = require('../utils/logger'); @@ -132,8 +136,7 @@ class TourPagesService extends BaseService { heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null, durationSec: Number.isFinite(durationSec) && durationSec > 0 ? durationSec : null, - frameRate: - Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null, + frameRate: Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null, }; } @@ -143,11 +146,7 @@ class TourPagesService extends BaseService { } return Math.round( - widthPx * - heightPx * - YUV420_BYTES_PER_PIXEL * - durationSec * - fps, + widthPx * heightPx * YUV420_BYTES_PER_PIXEL * durationSec * fps, ); } @@ -200,7 +199,10 @@ class TourPagesService extends BaseService { const asset = await AssetsDBApi.findBy({ storage_key: storageKey }); if (!asset) { - logger.warn({ storageKey }, 'Asset not found during auto-reverse validation'); + logger.warn( + { storageKey }, + 'Asset not found during auto-reverse validation', + ); continue; } @@ -219,8 +221,7 @@ class TourPagesService extends BaseService { const { widthPx, heightPx, durationSec } = TourPagesService.getAssetVideoMetadata(asset); const frameRate = await TourPagesService.resolveAssetFrameRate(asset); - const effectiveFrameRate = - frameRate || DEFAULT_AUTO_REVERSE_FALLBACK_FPS; + const effectiveFrameRate = frameRate || DEFAULT_AUTO_REVERSE_FALLBACK_FPS; const estimatedDecodedBytes = TourPagesService.getEstimatedDecodedBytes({ widthPx, heightPx, @@ -228,10 +229,7 @@ class TourPagesService extends BaseService { fps: effectiveFrameRate, }); - if ( - sizeBytes != null && - sizeBytes > MAX_AUTO_REVERSE_SOURCE_SIZE_BYTES - ) { + if (sizeBytes != null && sizeBytes > MAX_AUTO_REVERSE_SOURCE_SIZE_BYTES) { throw new ValidationError( `Transition video "${assetLabel}" is ${TourPagesService.formatBytesToGiB(sizeBytes)}. Auto-reverse is limited to 16 GiB source files. Use a smaller video or switch reverse mode to separate_video.`, ); @@ -243,7 +241,9 @@ class TourPagesService extends BaseService { ) { const dimensionLabel = widthPx && heightPx ? `${widthPx}x${heightPx}` : 'unknown resolution'; - const durationLabel = durationSec ? `${durationSec.toFixed(2)}s` : 'unknown duration'; + const durationLabel = durationSec + ? `${durationSec.toFixed(2)}s` + : 'unknown duration'; const fpsLabel = frameRate ? `${frameRate.toFixed(3)} FPS` : `${DEFAULT_AUTO_REVERSE_FALLBACK_FPS} FPS fallback`; @@ -419,6 +419,8 @@ class TourPagesService extends BaseService { design_height: source.design_height, requires_auth: source.requires_auth, ui_schema_json: uiSchema, + global_ui_controls_settings_json: + source.global_ui_controls_settings_json || null, }; const processedPayload = diff --git a/backend/src/services/videoProcessing.js b/backend/src/services/videoProcessing.js index b85cc50..ca554f6 100644 --- a/backend/src/services/videoProcessing.js +++ b/backend/src/services/videoProcessing.js @@ -172,11 +172,12 @@ async function probeMediaMetadata(filePath) { const primaryStream = videoStream || audioStream || null; const formatDuration = Number(metadata?.format?.duration); const streamDuration = Number(primaryStream?.duration); - const durationSec = Number.isFinite(formatDuration) && formatDuration > 0 - ? formatDuration - : Number.isFinite(streamDuration) && streamDuration > 0 - ? streamDuration - : null; + const durationSec = + Number.isFinite(formatDuration) && formatDuration > 0 + ? formatDuration + : Number.isFinite(streamDuration) && streamDuration > 0 + ? streamDuration + : null; const widthPx = Number(videoStream?.width); const heightPx = Number(videoStream?.height); diff --git a/backend/watcher.js b/backend/watcher.js index 143b743..b85561a 100644 --- a/backend/watcher.js +++ b/backend/watcher.js @@ -8,60 +8,60 @@ const childEnv = { ...process.env, NODE_ENV: nodeEnv }; const log = logger.child({ module: 'watcher' }); function logCommandResult(error, stdout, stderr, successMessage) { - const output = stdout && stdout.trim(); - const errorOutput = stderr && stderr.trim(); + const output = stdout && stdout.trim(); + const errorOutput = stderr && stderr.trim(); - if (output) { - log.info({ output }, successMessage); - } + if (output) { + log.info({ output }, successMessage); + } - if (error) { - log.error( - { err: error, stderr: errorOutput }, - 'Watched database command failed', - ); - } else if (errorOutput) { - log.warn({ stderr: errorOutput }, 'Watched database command wrote stderr'); - } + if (error) { + log.error( + { err: error, stderr: errorOutput }, + 'Watched database command failed', + ); + } else if (errorOutput) { + log.warn({ stderr: errorOutput }, 'Watched database command wrote stderr'); + } } const migrationsWatcher = chokidar.watch('./src/db/migrations', { - persistent: true, - ignoreInitial: true + persistent: true, + ignoreInitial: true, }); migrationsWatcher.on('add', (filePath) => { - log.info({ filePath }, 'New migration file detected'); - exec('npm run db:migrate', { env: childEnv }, (error, stdout, stderr) => { - logCommandResult(error, stdout, stderr, 'Migration command completed'); - }); + log.info({ filePath }, 'New migration file detected'); + exec('npm run db:migrate', { env: childEnv }, (error, stdout, stderr) => { + logCommandResult(error, stdout, stderr, 'Migration command completed'); + }); }); const seedersWatcher = chokidar.watch('./src/db/seeders', { - persistent: true, - ignoreInitial: true + persistent: true, + ignoreInitial: true, }); seedersWatcher.on('add', (filePath) => { - log.info({ filePath }, 'New seed file detected'); - exec('npm run db:seed', { env: childEnv }, (error, stdout, stderr) => { - logCommandResult(error, stdout, stderr, 'Seeder command completed'); - }); + log.info({ filePath }, 'New seed file detected'); + exec('npm run db:seed', { env: childEnv }, (error, stdout, stderr) => { + logCommandResult(error, stdout, stderr, 'Seeder command completed'); + }); }); nodemon({ - script: './src/index.js', - env: childEnv, - ignore: ['./src/db/migrations', './src/db/seeders'], - delay: '500' + script: './src/index.js', + env: childEnv, + ignore: ['./src/db/migrations', './src/db/seeders'], + delay: '500', }); nodemon.on('start', () => { - log.info({ nodeEnv }, 'Nodemon started'); + log.info({ nodeEnv }, 'Nodemon started'); }); nodemon.on('restart', (files) => { - log.info({ files }, 'Nodemon restarted due to file changes'); + log.info({ files }, 'Nodemon restarted due to file changes'); }); nodemon.on('crash', () => { - log.error('Nodemon app crashed'); + log.error('Nodemon app crashed'); }); diff --git a/frontend/src/components/Constructor/ConstructorToolbar.tsx b/frontend/src/components/Constructor/ConstructorToolbar.tsx index b305881..426a0c6 100644 --- a/frontend/src/components/Constructor/ConstructorToolbar.tsx +++ b/frontend/src/components/Constructor/ConstructorToolbar.tsx @@ -206,9 +206,7 @@ const ConstructorToolbar = forwardRef(
- - Page actions - + Page actions
(
- - Elements actions - + Elements actions
); } /** - * Delete button for removing offline data (smaller, subtle styling) - */ -function DeleteButton({ onClick }: { onClick: () => void }) { - const stopButtonEvent = ( - event: - | React.MouseEvent - | React.PointerEvent - | React.TouchEvent, - ) => { - event.stopPropagation(); - }; - - const buttonStyle: CSSProperties = { - display: 'inline-flex', - justifyContent: 'center', - alignItems: 'center', - padding: 4, - borderRadius: 4, - backgroundColor: 'transparent', - border: 'none', - color: '#9CA3AF', // text-gray-400 - cursor: 'pointer', - transition: 'color 150ms', - }; - - return ( - - ); -} - -/** - * Offline toggle component using fixed pixel sizes + * Offline toggle component using resolved canvas-relative dimensions */ function OfflineControl({ projectId, projectSlug, projectName, pages, + settings, + canvasBasis, + resolveUrl, + selected, + ghost, + editMode, + onSelect, }: { projectId: string | null; projectSlug?: string; projectName?: string; pages?: PreloadPage[]; + settings: ResolvedSystemUiControlSettings; + canvasBasis: number; + resolveUrl?: (url: string | undefined) => string; + selected?: boolean; + ghost?: boolean; + editMode?: boolean; + onSelect?: () => void; }) { const { isOfflineCapable, @@ -319,6 +397,10 @@ function OfflineControl({ } const handleClick = () => { + onSelect?.(); + if (editMode) return; + if (!settings.enabled) return; + if (isDownloaded) { if (confirm('Remove offline data for this project?')) { deleteOfflineData(); @@ -352,11 +434,13 @@ function OfflineControl({ let color: 'info' | 'success' | 'danger' | 'warning' = 'info'; let title = 'Download for offline'; let spinning = false; + let isActiveState = false; if (isDownloaded) { - icon = mdiCloudCheck; + icon = mdiDelete; color = 'success'; - title = 'Available offline'; + title = 'Remove offline data'; + isActiveState = true; } else if (isDownloading) { icon = mdiLoading; color = 'info'; @@ -375,28 +459,39 @@ function OfflineControl({ const containerStyle: CSSProperties = { display: 'flex', alignItems: 'center', - gap: 4, }; return (
- {isDownloaded && ( - { - if (confirm('Remove offline data for this project?')) { - deleteOfflineData(); - } - }} - /> - )}
); } @@ -453,11 +548,10 @@ function useViewportSize() { } /** - * RuntimeControls - Main component for presentation controls + * RuntimeControls - Main component for presentation controls. * - * Renders offline toggle and fullscreen button using fixed pixel values - * to maintain usable size regardless of canvas scaling. - * Uses visualViewport API to counter pinch-zoom scaling on mobile. + * Renders offline, fullscreen, and sound buttons using canvas-relative + * dimensions and positions. * Positions controls relative to the canvas area (not viewport edges). */ export default function RuntimeControls({ @@ -474,36 +568,64 @@ export default function RuntimeControls({ onSoundToggle, showOfflineButton = true, showFullscreenButton = true, + controlsSettings = DEFAULT_UI_CONTROL_SETTINGS, + resolveUrl, + editMode = false, + selectedControl = null, + maxControlZIndex, + onControlSelect, + onControlMouseDown, }: RuntimeControlsProps) { // Counter-scale to resist pinch-zoom const counterScale = useCounterZoom(); const viewport = useViewportSize(); + const canvasBasis = + canvasWidth > 0 ? canvasWidth : viewport.width > 0 ? viewport.width : 0; - // Calculate position relative to centered canvas - // Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%) - // So we offset from viewport edge by (viewport - canvas) / 2 + padding - const padding = 16; - const rightSafetyInset = 48; - const rightOffset = + const canvasLeft = canvasWidth > 0 && viewport.width > 0 - ? (viewport.width - canvasWidth) / 2 + padding + rightSafetyInset - : padding + rightSafetyInset; - const topOffset = + ? (viewport.width - canvasWidth) / 2 + : 0; + const canvasTop = canvasHeight > 0 && viewport.height > 0 - ? (viewport.height - canvasHeight) / 2 + padding - : padding; + ? (viewport.height - canvasHeight) / 2 + : 0; + const canvasAspectRatio = + canvasWidth > 0 && canvasHeight > 0 ? canvasWidth / canvasHeight : 1; - const containerStyle: CSSProperties = { - position: 'fixed', - top: topOffset, - right: rightOffset, - display: 'flex', - alignItems: 'center', - gap: 8, - zIndex: 9999, // Above everything including modals - // Counter pinch-zoom scaling - transform: `scale(${counterScale})`, - transformOrigin: 'top right', + const getControlPositionStyle = ( + type: SystemUiControlType, + ): CSSProperties => { + const settings = controlsSettings[type]; + const bounds = getSystemControlAnchorBounds( + settings.anchor, + settings.buttonSizePercent, + canvasAspectRatio, + ); + const clampedXPercent = Math.min( + Math.max(settings.xPercent, bounds.minX), + bounds.maxX, + ); + const clampedYPercent = Math.min( + Math.max(settings.yPercent, bounds.minY), + bounds.maxY, + ); + const x = canvasLeft + canvasWidth * (clampedXPercent / 100); + const y = canvasTop + canvasHeight * (clampedYPercent / 100); + const anchorTransform = getAnchorTransform(settings.anchor); + + return { + position: 'fixed', + left: x, + top: y, + zIndex: + typeof maxControlZIndex === 'number' + ? Math.min(settings.zIndex, maxControlZIndex) + : settings.zIndex, + transform: `${anchorTransform} scale(${counterScale})`, + transformOrigin: 'top left', + pointerEvents: 'auto', + }; }; const stopControlEvent = ( @@ -515,41 +637,156 @@ export default function RuntimeControls({ event.stopPropagation(); }; - return ( -
- {showOfflineButton && ( + const renderControlWrapper = ( + type: SystemUiControlType, + content: React.ReactNode, + ) => { + const settings = controlsSettings[type]; + const shouldRender = editMode || !settings.hidden; + if (!shouldRender) return null; + + return ( +
{ + stopControlEvent(event); + onControlMouseDown?.(event, type); + }} + onMouseDown={(event) => { + stopControlEvent(event); + }} + onMouseDownCapture={(event) => { + stopControlEvent(event); + }} + onTouchEnd={stopControlEvent} + onTouchEndCapture={stopControlEvent} + > + {content} +
+ ); + }; + + const orderedTypes: SystemUiControlType[] = [ + 'offline', + 'fullscreen', + 'sound', + ].sort( + (first, second) => + controlsSettings[first].order - controlsSettings[second].order, + ) as SystemUiControlType[]; + + const renderedControls = orderedTypes.map((type) => { + const settings = controlsSettings[type]; + const selected = selectedControl === type; + const ghost = editMode && settings.hidden; + + if (type === 'offline' && showOfflineButton) { + return renderControlWrapper( + type, - )} - {showFullscreenButton && ( + settings={settings} + canvasBasis={canvasBasis} + resolveUrl={resolveUrl} + selected={selected} + ghost={ghost} + editMode={editMode} + onSelect={() => onControlSelect?.(type)} + />, + ); + } + + if (type === 'fullscreen' && showFullscreenButton) { + return renderControlWrapper( + type, { + onControlSelect?.(type); + if (!editMode && settings.enabled) { + toggleFullscreen(); + } + }} + disabled={!editMode && !settings.enabled} title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} - /> - )} - {showSoundButton && onSoundToggle && ( + resolveUrl={resolveUrl} + selected={selected} + ghost={ghost} + />, + ); + } + + if (type === 'sound' && showSoundButton && onSoundToggle) { + return renderControlWrapper( + type, { + onControlSelect?.(type); + if (!editMode && settings.enabled) { + onSoundToggle(); + } + }} + disabled={!editMode && !settings.enabled} title={isMuted ? 'Unmute sound' : 'Mute sound'} - /> - )} + resolveUrl={resolveUrl} + selected={selected} + ghost={ghost} + />, + ); + } + + return null; + }); + + return ( +
+ {renderedControls}
); } diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 94c4240..cf4953c 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -61,10 +61,15 @@ import { useTransitionSettings } from '../hooks/useTransitionSettings'; import { useAppSelector, useAppDispatch } from '../stores/hooks'; import { logoutUser } from '../stores/authSlice'; import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice'; +import { fetch as fetchGlobalUiControlDefaults } from '../stores/global_ui_control_defaults/globalUiControlDefaultsSlice'; import { fetchByProjectAndEnv as fetchProjectTransitionSettings, selectByProjectAndEnv as selectProjectTransitionSettings, } from '../stores/project_transition_settings/projectTransitionSettingsSlice'; +import { + fetchByProjectAndEnv as fetchProjectUiControlSettings, + selectByProjectAndEnv as selectProjectUiControlSettings, +} from '../stores/project_ui_control_settings/projectUiControlSettingsSlice'; import type { TransitionPhase } from '../types/presentation'; import type { CanvasElement, @@ -81,6 +86,10 @@ import { entityToProjectSettings, extractElementTransitionSettings, } from '../types/transition'; +import { + parseUiControlsSettings, + resolveUiControlsSettings, +} from '../types/uiControls'; interface RuntimePresentationProps { projectSlug: string; @@ -96,6 +105,9 @@ export default function RuntimePresentation({ const globalTransitionDefaults = useAppSelector( (state) => state.global_transition_defaults.data, ); + const globalUiControlDefaults = useAppSelector( + (state) => state.global_ui_control_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 @@ -117,6 +129,7 @@ export default function RuntimePresentation({ // Fetch global transition defaults on mount (public endpoint, no auth needed) useEffect(() => { dispatch(fetchGlobalTransitionDefaults()); + dispatch(fetchGlobalUiControlDefaults()); }, [dispatch]); // Fetch project transition settings when project is loaded @@ -129,6 +142,13 @@ export default function RuntimePresentation({ apiHeaders: runtimeApiHeaders, }), ); + dispatch( + fetchProjectUiControlSettings({ + projectId: project.id, + environment, + apiHeaders: runtimeApiHeaders, + }), + ); } }, [dispatch, project?.id, environment, runtimeApiHeaders]); @@ -154,6 +174,11 @@ export default function RuntimePresentation({ ? selectProjectTransitionSettings(state, project.id, environment) : undefined, ); + const projectUiControlSettingsEntity = useAppSelector((state) => + project?.id + ? selectProjectUiControlSettings(state, project.id, environment) + : undefined, + ); const projectTransitionSettings = useMemo( () => entityToProjectSettings(projectTransitionSettingsEntity), [projectTransitionSettingsEntity], @@ -300,6 +325,20 @@ export default function RuntimePresentation({ [pages, selectedPageId], ); + const resolvedUiControlsSettings = useMemo( + () => + resolveUiControlsSettings( + globalUiControlDefaults?.settings_json, + projectUiControlSettingsEntity?.settings_json, + parseUiControlsSettings(selectedPage?.global_ui_controls_settings_json), + ), + [ + globalUiControlDefaults?.settings_json, + projectUiControlSettingsEntity?.settings_json, + selectedPage?.global_ui_controls_settings_json, + ], + ); + // Unified page navigation state machine (replaces 6+ separate hooks) // Uses useReducer for atomic state transitions, preventing race conditions const navState = usePageNavigationState({ @@ -1401,6 +1440,8 @@ export default function RuntimePresentation({ showSoundButton={soundControl.showSoundButton} isMuted={soundControl.isMuted} onSoundToggle={soundControl.toggleSound} + controlsSettings={resolvedUiControlsSettings} + resolveUrl={resolveUrlWithBlob} /> )} diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 7d83f8f..99a7914 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -77,7 +77,9 @@ export const SelectFieldMany = ({ label: data.label, }); - const handleChange = (data: Array<{ value: string; label: string }> | null) => { + const handleChange = ( + data: Array<{ value: string; label: string }> | null, + ) => { const selectedOptions = data || []; setValue(selectedOptions); form.setFieldValue( diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index 882f83d..cc954e6 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -38,6 +38,12 @@ import { selectByProjectAndEnv, selectIsLoading as selectTransitionSettingsLoading, } from '../stores/project_transition_settings/projectTransitionSettingsSlice'; +import { + fetchByProjectAndEnv as fetchUiControlsByProjectAndEnv, + upsertByProjectAndEnv as upsertUiControlsByProjectAndEnv, + deleteByProjectAndEnv as deleteUiControlsByProjectAndEnv, + selectByProjectAndEnv as selectUiControlsByProjectAndEnv, +} from '../stores/project_ui_control_settings/projectUiControlSettingsSlice'; import type { ProjectTransitionSettings, TransitionType, @@ -136,6 +142,11 @@ const TourFlowManager = () => { ? selectTransitionSettingsLoading(state, selectedProjectId, 'dev') : false, ); + const projectUiControlsSettingsEntity = useAppSelector((state) => + selectedProjectId + ? selectUiControlsByProjectAndEnv(state, selectedProjectId, 'dev') + : undefined, + ); // Project transition settings state const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] = @@ -154,6 +165,10 @@ const TourFlowManager = () => { const [isSavingTransitionSettings, setIsSavingTransitionSettings] = useState(false); const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false); + const [isUiControlsExpanded, setIsUiControlsExpanded] = useState(false); + const [uiControlsJson, setUiControlsJson] = useState(''); + const [isSavingUiControls, setIsSavingUiControls] = useState(false); + const [uiControlsSaveSuccess, setUiControlsSaveSuccess] = useState(false); const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES'); const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS'); @@ -254,6 +269,12 @@ const TourFlowManager = () => { environment: 'dev', }), ); + dispatch( + fetchUiControlsByProjectAndEnv({ + projectId: selectedProjectId, + environment: 'dev', + }), + ); }, [selectedProjectId, dispatch]); // Sync local form state when store data changes @@ -264,6 +285,16 @@ const TourFlowManager = () => { setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? ''); }, [projectTransitionSettings]); + useEffect(() => { + setUiControlsJson( + JSON.stringify( + projectUiControlsSettingsEntity?.settings_json || {}, + null, + 2, + ), + ); + }, [projectUiControlsSettingsEntity]); + useEffect(() => { if (!selectedProjectId) return; if (routeProjectId && selectedProjectId === routeProjectId) return; @@ -641,6 +672,41 @@ const TourFlowManager = () => { } }; + const handleSaveUiControlsSettings = async () => { + if (!selectedProjectId) return; + + setIsSavingUiControls(true); + setUiControlsSaveSuccess(false); + try { + const parsed = JSON.parse(uiControlsJson || '{}'); + + if (!parsed || Object.keys(parsed).length === 0) { + await dispatch( + deleteUiControlsByProjectAndEnv({ + projectId: selectedProjectId, + environment: 'dev', + }), + ).unwrap(); + } else { + await dispatch( + upsertUiControlsByProjectAndEnv({ + projectId: selectedProjectId, + environment: 'dev', + data: { settings_json: parsed }, + }), + ).unwrap(); + } + + setUiControlsSaveSuccess(true); + setTimeout(() => setUiControlsSaveSuccess(false), 2000); + } catch (error) { + logger.error('Failed to save project UI controls settings:', error); + toast.error('Failed to save UI controls settings'); + } finally { + setIsSavingUiControls(false); + } + }; + const handleDelete = async ( event: React.MouseEvent, id: string, @@ -874,6 +940,57 @@ const TourFlowManager = () => { )} + {selectedProjectId && ( + + + + {isUiControlsExpanded && ( +
+

+ Override fixed-size fullscreen, sound, and offline button + defaults for this project in dev. Positions are + canvas-relative percentages; dimensions are CSS pixels. Empty + JSON reverts to global defaults. +

+