Added ability to customize global actions buttons (fullscreen, offline, mute)
This commit is contained in:
parent
e5ad49d07b
commit
63909ef66a
164
backend/src/db/api/global_ui_control_defaults.js
Normal file
164
backend/src/db/api/global_ui_control_defaults.js
Normal file
@ -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;
|
||||
183
backend/src/db/api/project_ui_control_settings.js
Normal file
183
backend/src/db/api/project_ui_control_settings.js
Normal file
@ -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;
|
||||
@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
34
backend/src/db/models/global_ui_control_defaults.js
Normal file
34
backend/src/db/models/global_ui_control_defaults.js
Normal file
@ -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;
|
||||
};
|
||||
62
backend/src/db/models/project_ui_control_settings.js
Normal file
62
backend/src/db/models/project_ui_control_settings.js
Normal file
@ -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;
|
||||
};
|
||||
@ -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, {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
|
||||
59
backend/src/routes/global_ui_control_defaults.js
Normal file
59
backend/src/routes/global_ui_control_defaults.js
Normal file
@ -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;
|
||||
@ -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
|
||||
|
||||
185
backend/src/routes/project_ui_control_settings.js
Normal file
185
backend/src/routes/project_ui_control_settings.js
Normal file
@ -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;
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
6
backend/src/services/global_ui_control_defaults.js
Normal file
6
backend/src/services/global_ui_control_defaults.js
Normal file
@ -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',
|
||||
});
|
||||
61
backend/src/services/project_ui_control_settings.js
Normal file
61
backend/src/services/project_ui_control_settings.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -206,9 +206,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
</div>
|
||||
|
||||
<div className={sectionClass}>
|
||||
<span className={sectionLabelClass}>
|
||||
Page actions
|
||||
</span>
|
||||
<span className={sectionLabelClass}>Page actions</span>
|
||||
<div className='flex h-10 items-center gap-2'>
|
||||
<PageSelector
|
||||
pages={pages}
|
||||
@ -329,9 +327,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
</div>
|
||||
|
||||
<div className={sectionClass}>
|
||||
<span className={sectionLabelClass}>
|
||||
Elements actions
|
||||
</span>
|
||||
<span className={sectionLabelClass}>Elements actions</span>
|
||||
<div className='flex h-10 items-center gap-2'>
|
||||
<div className='relative'>
|
||||
<button
|
||||
|
||||
@ -49,7 +49,12 @@ import {
|
||||
isInfoPanelElementType,
|
||||
} from '../../lib/elementDefaults';
|
||||
import type { CanvasElement } from '../../types/constructor';
|
||||
import {
|
||||
getSystemControlAnchorBounds,
|
||||
type SystemUiControlSettings,
|
||||
} from '../../types/uiControls';
|
||||
import { FONT_OPTIONS } from '../../lib/fonts';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
|
||||
type NavigationElementType = 'navigation_next' | 'navigation_prev';
|
||||
|
||||
@ -145,6 +150,10 @@ export function ElementEditorPanel({
|
||||
selectedElementId,
|
||||
updateSelectedElement,
|
||||
removeSelectedElement,
|
||||
selectedSystemControl,
|
||||
resolvedUiControlsSettings,
|
||||
uiControlsCanvasAspectRatio,
|
||||
updateSystemControl,
|
||||
} = useConstructorElements();
|
||||
|
||||
const { selectedMenuItem } = useConstructorMenu();
|
||||
@ -183,6 +192,24 @@ export function ElementEditorPanel({
|
||||
// Render
|
||||
// ============================================================================
|
||||
|
||||
const selectedSystemControlSettings = selectedSystemControl
|
||||
? resolvedUiControlsSettings[selectedSystemControl]
|
||||
: null;
|
||||
const selectedSystemControlBounds = selectedSystemControlSettings
|
||||
? getSystemControlAnchorBounds(
|
||||
selectedSystemControlSettings.anchor,
|
||||
selectedSystemControlSettings.buttonSizePercent,
|
||||
uiControlsCanvasAspectRatio,
|
||||
)
|
||||
: null;
|
||||
|
||||
const updateSelectedSystemControl = (
|
||||
patch: Partial<SystemUiControlSettings>,
|
||||
) => {
|
||||
if (!selectedSystemControl) return;
|
||||
updateSystemControl(selectedSystemControl, patch);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementEditorRef}
|
||||
@ -192,7 +219,7 @@ export function ElementEditorPanel({
|
||||
<ElementEditorHeader
|
||||
title={title}
|
||||
isCollapsed={isCollapsed}
|
||||
showRemoveButton={Boolean(selectedElement)}
|
||||
showRemoveButton={Boolean(selectedElement) && !selectedSystemControl}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onRemove={removeSelectedElement}
|
||||
onDragStart={onDragStart}
|
||||
@ -200,44 +227,320 @@ export function ElementEditorPanel({
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* Background Image Settings */}
|
||||
{selectedMenuItem === 'background_image' && (
|
||||
<BackgroundSettingsEditor
|
||||
type='image'
|
||||
value={pageBackground.imageUrl}
|
||||
options={assetOptions.backgroundImage}
|
||||
onChange={(value) => {
|
||||
setBackgroundImageUrl(value);
|
||||
if (value) {
|
||||
setBackgroundVideoUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{selectedSystemControl && selectedSystemControlSettings && (
|
||||
<div className='space-y-3'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
X (%)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
min={selectedSystemControlBounds?.minX ?? 0}
|
||||
max={selectedSystemControlBounds?.maxX ?? 100}
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedSystemControlSettings.xPercent}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
xPercent: Math.max(
|
||||
selectedSystemControlBounds?.minX ?? 0,
|
||||
Math.min(
|
||||
selectedSystemControlBounds?.maxX ?? 100,
|
||||
Number(event.target.value) || 0,
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
Y (%)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
min={selectedSystemControlBounds?.minY ?? 0}
|
||||
max={selectedSystemControlBounds?.maxY ?? 100}
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedSystemControlSettings.yPercent}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
yPercent: Math.max(
|
||||
selectedSystemControlBounds?.minY ?? 0,
|
||||
Math.min(
|
||||
selectedSystemControlBounds?.maxY ?? 100,
|
||||
Number(event.target.value) || 0,
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
Anchor
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedSystemControlSettings.anchor}
|
||||
onChange={(event) => {
|
||||
const anchor = event.target
|
||||
.value as SystemUiControlSettings['anchor'];
|
||||
const bounds = anchor
|
||||
? getSystemControlAnchorBounds(
|
||||
anchor,
|
||||
selectedSystemControlSettings.buttonSizePercent,
|
||||
uiControlsCanvasAspectRatio,
|
||||
)
|
||||
: selectedSystemControlBounds;
|
||||
|
||||
updateSelectedSystemControl({
|
||||
anchor,
|
||||
...(bounds
|
||||
? {
|
||||
xPercent: Math.max(
|
||||
bounds.minX,
|
||||
Math.min(
|
||||
bounds.maxX,
|
||||
selectedSystemControlSettings.xPercent,
|
||||
),
|
||||
),
|
||||
yPercent: Math.max(
|
||||
bounds.minY,
|
||||
Math.min(
|
||||
bounds.maxY,
|
||||
selectedSystemControlSettings.yPercent,
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value='center'>center</option>
|
||||
<option value='top-left'>top-left</option>
|
||||
<option value='top-right'>top-right</option>
|
||||
<option value='bottom-left'>bottom-left</option>
|
||||
<option value='bottom-right'>bottom-right</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
Order
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={selectedSystemControlSettings.order}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
order: Number(event.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectedSystemControlSettings.hidden}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
hidden: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Hidden
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={!selectedSystemControlSettings.enabled}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
enabled: !event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Disabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{[
|
||||
['Default icon', 'defaultIconUrl'],
|
||||
['Active icon', 'activeIconUrl'],
|
||||
].map(([label, key]) => {
|
||||
const value = selectedSystemControlSettings[
|
||||
key as keyof typeof selectedSystemControlSettings
|
||||
] as string;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
[key]: event.target.value,
|
||||
} as Partial<SystemUiControlSettings>)
|
||||
}
|
||||
>
|
||||
<option value=''>Use default icon</option>
|
||||
{addFallbackAssetOption(
|
||||
assetOptions.icon,
|
||||
value,
|
||||
`Current icon · ${value}`,
|
||||
).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{[
|
||||
['Button size (%)', 'buttonSizePercent'],
|
||||
['Icon size (%)', 'iconSizePercent'],
|
||||
['Radius (%)', 'borderRadiusPercent'],
|
||||
['Z-index', 'zIndex'],
|
||||
].map(([label, key]) => (
|
||||
<div key={key}>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={
|
||||
selectedSystemControlSettings[
|
||||
key as keyof typeof selectedSystemControlSettings
|
||||
] as number
|
||||
}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value) || 0;
|
||||
|
||||
if (key === 'buttonSizePercent') {
|
||||
const bounds = getSystemControlAnchorBounds(
|
||||
selectedSystemControlSettings.anchor,
|
||||
value,
|
||||
uiControlsCanvasAspectRatio,
|
||||
);
|
||||
updateSelectedSystemControl({
|
||||
buttonSizePercent: value,
|
||||
xPercent: Math.max(
|
||||
bounds.minX,
|
||||
Math.min(
|
||||
bounds.maxX,
|
||||
selectedSystemControlSettings.xPercent,
|
||||
),
|
||||
),
|
||||
yPercent: Math.max(
|
||||
bounds.minY,
|
||||
Math.min(
|
||||
bounds.maxY,
|
||||
selectedSystemControlSettings.yPercent,
|
||||
),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedSystemControl({
|
||||
[key]: value,
|
||||
} as Partial<SystemUiControlSettings>);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{[
|
||||
['Default BG', 'defaultBackgroundColor'],
|
||||
['Active BG', 'activeBackgroundColor'],
|
||||
['Hover BG', 'hoverBackgroundColor'],
|
||||
['Icon color', 'color'],
|
||||
['Default border', 'defaultBorderColor'],
|
||||
['Active border', 'activeBorderColor'],
|
||||
['Opacity', 'opacity'],
|
||||
['Shadow', 'boxShadow'],
|
||||
].map(([label, key]) => (
|
||||
<div key={key}>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={String(
|
||||
selectedSystemControlSettings[
|
||||
key as keyof typeof selectedSystemControlSettings
|
||||
] ?? '',
|
||||
)}
|
||||
onChange={(event) =>
|
||||
updateSelectedSystemControl({
|
||||
[key]:
|
||||
key === 'opacity'
|
||||
? Number(event.target.value) || 0
|
||||
: event.target.value,
|
||||
} as Partial<SystemUiControlSettings>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image Settings */}
|
||||
{!selectedSystemControl &&
|
||||
selectedMenuItem === 'background_image' && (
|
||||
<BackgroundSettingsEditor
|
||||
type='image'
|
||||
value={pageBackground.imageUrl}
|
||||
options={assetOptions.backgroundImage}
|
||||
onChange={(value) => {
|
||||
setBackgroundImageUrl(value);
|
||||
if (value) {
|
||||
setBackgroundVideoUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background Video Settings */}
|
||||
{selectedMenuItem === 'background_video' && (
|
||||
<BackgroundSettingsEditor
|
||||
type='video'
|
||||
value={pageBackground.videoUrl}
|
||||
options={assetOptions.video}
|
||||
durationNote={durationNotes.backgroundVideo}
|
||||
onChange={(value) => {
|
||||
setBackgroundVideoUrl(value);
|
||||
if (value) {
|
||||
setBackgroundImageUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
videoAutoplay={pageBackground.videoSettings.autoplay}
|
||||
videoLoop={pageBackground.videoSettings.loop}
|
||||
videoMuted={pageBackground.videoSettings.muted}
|
||||
videoStartTime={pageBackground.videoSettings.startTime}
|
||||
videoEndTime={pageBackground.videoSettings.endTime}
|
||||
onVideoSettingsChange={setBackgroundVideoSettings}
|
||||
/>
|
||||
)}
|
||||
{!selectedSystemControl &&
|
||||
selectedMenuItem === 'background_video' && (
|
||||
<BackgroundSettingsEditor
|
||||
type='video'
|
||||
value={pageBackground.videoUrl}
|
||||
options={assetOptions.video}
|
||||
durationNote={durationNotes.backgroundVideo}
|
||||
onChange={(value) => {
|
||||
setBackgroundVideoUrl(value);
|
||||
if (value) {
|
||||
setBackgroundImageUrl('');
|
||||
setBackgroundEmbedUrl('');
|
||||
}
|
||||
}}
|
||||
videoAutoplay={pageBackground.videoSettings.autoplay}
|
||||
videoLoop={pageBackground.videoSettings.loop}
|
||||
videoMuted={pageBackground.videoSettings.muted}
|
||||
videoStartTime={pageBackground.videoSettings.startTime}
|
||||
videoEndTime={pageBackground.videoSettings.endTime}
|
||||
onVideoSettingsChange={setBackgroundVideoSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Background 360 Settings */}
|
||||
{selectedMenuItem === 'background_embed' && (
|
||||
|
||||
@ -2,18 +2,13 @@
|
||||
* RuntimeControls Component
|
||||
*
|
||||
* Control buttons for runtime presentations (offline toggle, fullscreen).
|
||||
* Uses fixed pixel values (not rem or canvas units) to ensure:
|
||||
* 1. Buttons maintain usable size regardless of canvas scaling
|
||||
* 2. Consistent behavior during iOS pinch-zoom (avoiding WebKit rem/zoom bugs)
|
||||
*
|
||||
* Note: These are UI chrome controls, not design elements, so they should
|
||||
* not scale with the canvas like navigation buttons do.
|
||||
* Uses canvas-relative dimensions so button size and spacing remain consistent
|
||||
* across devices for projects with the same canvas ratio.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
mdiCloudDownload,
|
||||
mdiCloudCheck,
|
||||
mdiCloudOff,
|
||||
mdiLoading,
|
||||
mdiFullscreen,
|
||||
@ -27,7 +22,16 @@ import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||||
import type { ProjectOfflineStatus } from '../../types/offline';
|
||||
import type { PreloadPage } from '../../types/preload';
|
||||
import {
|
||||
DEFAULT_UI_CONTROL_SETTINGS,
|
||||
getAnchorTransform,
|
||||
getSystemControlAnchorBounds,
|
||||
type ResolvedSystemUiControlSettings,
|
||||
type ResolvedUiControlsSettings,
|
||||
type SystemUiControlType,
|
||||
} from '../../types/uiControls';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
|
||||
|
||||
interface RuntimeControlsProps {
|
||||
projectId: string | null;
|
||||
@ -50,6 +54,23 @@ interface RuntimeControlsProps {
|
||||
showOfflineButton?: boolean;
|
||||
/** Whether to show the fullscreen button */
|
||||
showFullscreenButton?: boolean;
|
||||
/** Resolved canvas-relative settings for system controls */
|
||||
controlsSettings?: ResolvedUiControlsSettings;
|
||||
/** Resolve custom icon URLs through preload/cache when available */
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
/** In constructor edit mode hidden controls remain selectable/visible */
|
||||
editMode?: boolean;
|
||||
/** Selected system control in constructor edit mode */
|
||||
selectedControl?: SystemUiControlType | null;
|
||||
/** Maximum z-index for system controls, used by constructor chrome */
|
||||
maxControlZIndex?: number;
|
||||
/** Called when a system control is selected in constructor edit mode */
|
||||
onControlSelect?: (control: SystemUiControlType) => void;
|
||||
/** Called before dragging a system control in constructor edit mode */
|
||||
onControlMouseDown?: (
|
||||
event: React.MouseEvent | React.PointerEvent,
|
||||
control: SystemUiControlType,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,19 +121,43 @@ if (typeof document !== 'undefined') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon component using fixed pixel sizes
|
||||
* Icon component using resolved canvas-relative pixel sizes
|
||||
*/
|
||||
function ControlIcon({
|
||||
path,
|
||||
src,
|
||||
size = 20,
|
||||
fill = 'currentColor',
|
||||
spinning = false,
|
||||
}: {
|
||||
path: string;
|
||||
path?: string;
|
||||
src?: string;
|
||||
size?: number;
|
||||
fill?: string;
|
||||
spinning?: boolean;
|
||||
}) {
|
||||
if (src) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${JSON.stringify(src)})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
animation: spinning
|
||||
? 'runtime-controls-spin 1s linear infinite'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
@ -126,30 +171,78 @@ function ControlIcon({
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<path fill={fill} d={path} />
|
||||
<path fill={fill} d={path || ''} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component using fixed pixel sizes
|
||||
* Resolve a canvas-relative percentage into a CSS pixel value.
|
||||
* CSS pixels are still used at render time, but the source of truth is canvas %.
|
||||
*/
|
||||
const resolveRelativeSize = (
|
||||
percent: number | undefined,
|
||||
canvasBasis: number,
|
||||
fallbackPx: number,
|
||||
) => {
|
||||
if (!percent || canvasBasis <= 0) return fallbackPx;
|
||||
return (canvasBasis * percent) / 100;
|
||||
};
|
||||
|
||||
/**
|
||||
* Button component using canvas-relative dimensions
|
||||
*/
|
||||
function ControlButton({
|
||||
icon,
|
||||
iconUrl,
|
||||
settings,
|
||||
canvasBasis,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
color = 'info',
|
||||
onClick,
|
||||
disabled = false,
|
||||
title,
|
||||
spinning = false,
|
||||
resolveUrl,
|
||||
selected = false,
|
||||
ghost = false,
|
||||
}: {
|
||||
icon: string;
|
||||
iconUrl?: string;
|
||||
settings?: ResolvedSystemUiControlSettings;
|
||||
canvasBasis: number;
|
||||
backgroundColor?: string;
|
||||
borderColor?: string;
|
||||
color?: 'info' | 'success' | 'danger' | 'warning';
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
spinning?: boolean;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
selected?: boolean;
|
||||
ghost?: boolean;
|
||||
}) {
|
||||
const colors = buttonColors[color];
|
||||
const buttonSize = resolveRelativeSize(
|
||||
settings?.buttonSizePercent,
|
||||
canvasBasis,
|
||||
38,
|
||||
);
|
||||
const iconSize = resolveRelativeSize(
|
||||
settings?.iconSizePercent,
|
||||
canvasBasis,
|
||||
20,
|
||||
);
|
||||
const borderRadius = resolveRelativeSize(
|
||||
settings?.borderRadiusPercent,
|
||||
canvasBasis,
|
||||
6,
|
||||
);
|
||||
const customIconUrl = iconUrl || '';
|
||||
const resolvedIconUrl = customIconUrl
|
||||
? resolveUrl?.(customIconUrl) || resolveAssetPlaybackUrl(customIconUrl)
|
||||
: '';
|
||||
const stopButtonEvent = (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
@ -163,16 +256,28 @@ function ControlButton({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.background,
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
padding: 0,
|
||||
borderRadius,
|
||||
backgroundColor:
|
||||
backgroundColor || settings?.defaultBackgroundColor || colors.background,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: colors.border,
|
||||
color: 'white',
|
||||
borderColor: selected
|
||||
? '#FACC15'
|
||||
: borderColor || settings?.defaultBorderColor || colors.border,
|
||||
color: settings?.color || 'white',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
opacity: ghost
|
||||
? 0.45
|
||||
: disabled
|
||||
? Math.min(settings?.opacity ?? 1, 0.7)
|
||||
: (settings?.opacity ?? 1),
|
||||
transition: 'background-color 150ms, border-color 150ms',
|
||||
boxShadow: selected
|
||||
? '0 0 0 2px rgba(250, 204, 21, 0.9)'
|
||||
: settings?.boxShadow || undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@ -193,86 +298,59 @@ function ControlButton({
|
||||
title={title}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = colors.backgroundHover;
|
||||
e.currentTarget.style.borderColor = colors.backgroundHover;
|
||||
e.currentTarget.style.backgroundColor =
|
||||
settings?.hoverBackgroundColor || colors.backgroundHover;
|
||||
e.currentTarget.style.borderColor =
|
||||
settings?.hoverBackgroundColor || colors.backgroundHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.background;
|
||||
e.currentTarget.style.borderColor = colors.border;
|
||||
e.currentTarget.style.backgroundColor =
|
||||
backgroundColor ||
|
||||
settings?.defaultBackgroundColor ||
|
||||
colors.background;
|
||||
e.currentTarget.style.borderColor = selected
|
||||
? '#FACC15'
|
||||
: borderColor || settings?.defaultBorderColor || colors.border;
|
||||
}}
|
||||
>
|
||||
<ControlIcon path={icon} size={20} spinning={spinning} />
|
||||
<ControlIcon
|
||||
path={icon}
|
||||
src={resolvedIconUrl}
|
||||
size={iconSize}
|
||||
spinning={spinning}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete button for removing offline data (smaller, subtle styling)
|
||||
*/
|
||||
function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||
const stopButtonEvent = (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.PointerEvent<HTMLButtonElement>
|
||||
| React.TouchEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
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 (
|
||||
<button
|
||||
type='button'
|
||||
style={buttonStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onPointerDown={stopButtonEvent}
|
||||
onPointerDownCapture={stopButtonEvent}
|
||||
onMouseDown={stopButtonEvent}
|
||||
onMouseDownCapture={stopButtonEvent}
|
||||
onTouchEnd={stopButtonEvent}
|
||||
onTouchEndCapture={stopButtonEvent}
|
||||
title='Remove offline data'
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#EF4444'; // text-red-500
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#9CA3AF'; // text-gray-400
|
||||
}}
|
||||
>
|
||||
<ControlIcon path={mdiDelete} size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div style={containerStyle}>
|
||||
<ControlButton
|
||||
icon={icon}
|
||||
settings={settings}
|
||||
canvasBasis={canvasBasis}
|
||||
iconUrl={
|
||||
isActiveState ? settings.activeIconUrl : settings.defaultIconUrl
|
||||
}
|
||||
backgroundColor={
|
||||
isActiveState
|
||||
? settings.activeBackgroundColor
|
||||
: settings.defaultBackgroundColor
|
||||
}
|
||||
borderColor={
|
||||
isActiveState
|
||||
? settings.activeBorderColor
|
||||
: settings.defaultBorderColor
|
||||
}
|
||||
color={color}
|
||||
onClick={handleClick}
|
||||
disabled={!canStore(estimatedSize) && !isDownloaded}
|
||||
disabled={
|
||||
!editMode &&
|
||||
(!settings.enabled || (!canStore(estimatedSize) && !isDownloaded))
|
||||
}
|
||||
title={title}
|
||||
spinning={spinning}
|
||||
resolveUrl={resolveUrl}
|
||||
selected={selected}
|
||||
ghost={ghost}
|
||||
/>
|
||||
{isDownloaded && (
|
||||
<DeleteButton
|
||||
onClick={() => {
|
||||
if (confirm('Remove offline data for this project?')) {
|
||||
deleteOfflineData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={stopControlEvent}
|
||||
onPointerDown={stopControlEvent}
|
||||
onPointerDownCapture={stopControlEvent}
|
||||
onMouseDown={stopControlEvent}
|
||||
onMouseDownCapture={stopControlEvent}
|
||||
onTouchEnd={stopControlEvent}
|
||||
onTouchEndCapture={stopControlEvent}
|
||||
>
|
||||
{showOfflineButton && (
|
||||
const renderControlWrapper = (
|
||||
type: SystemUiControlType,
|
||||
content: React.ReactNode,
|
||||
) => {
|
||||
const settings = controlsSettings[type];
|
||||
const shouldRender = editMode || !settings.hidden;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
style={getControlPositionStyle(type)}
|
||||
onClick={stopControlEvent}
|
||||
onPointerDown={stopControlEvent}
|
||||
onPointerDownCapture={(event) => {
|
||||
stopControlEvent(event);
|
||||
onControlMouseDown?.(event, type);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
stopControlEvent(event);
|
||||
}}
|
||||
onMouseDownCapture={(event) => {
|
||||
stopControlEvent(event);
|
||||
}}
|
||||
onTouchEnd={stopControlEvent}
|
||||
onTouchEndCapture={stopControlEvent}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
<OfflineControl
|
||||
projectId={projectId}
|
||||
projectSlug={projectSlug}
|
||||
projectName={projectName}
|
||||
pages={pages}
|
||||
/>
|
||||
)}
|
||||
{showFullscreenButton && (
|
||||
settings={settings}
|
||||
canvasBasis={canvasBasis}
|
||||
resolveUrl={resolveUrl}
|
||||
selected={selected}
|
||||
ghost={ghost}
|
||||
editMode={editMode}
|
||||
onSelect={() => onControlSelect?.(type)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'fullscreen' && showFullscreenButton) {
|
||||
return renderControlWrapper(
|
||||
type,
|
||||
<ControlButton
|
||||
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||||
settings={settings}
|
||||
canvasBasis={canvasBasis}
|
||||
iconUrl={
|
||||
isFullscreen ? settings.activeIconUrl : settings.defaultIconUrl
|
||||
}
|
||||
backgroundColor={
|
||||
isFullscreen
|
||||
? settings.activeBackgroundColor
|
||||
: settings.defaultBackgroundColor
|
||||
}
|
||||
borderColor={
|
||||
isFullscreen
|
||||
? settings.activeBorderColor
|
||||
: settings.defaultBorderColor
|
||||
}
|
||||
color='info'
|
||||
onClick={toggleFullscreen}
|
||||
onClick={() => {
|
||||
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,
|
||||
<ControlButton
|
||||
icon={isMuted ? mdiVolumeOff : mdiVolumeHigh}
|
||||
settings={settings}
|
||||
canvasBasis={canvasBasis}
|
||||
iconUrl={isMuted ? settings.activeIconUrl : settings.defaultIconUrl}
|
||||
backgroundColor={
|
||||
isMuted
|
||||
? settings.activeBackgroundColor
|
||||
: settings.defaultBackgroundColor
|
||||
}
|
||||
borderColor={
|
||||
isMuted ? settings.activeBorderColor : settings.defaultBorderColor
|
||||
}
|
||||
color='info'
|
||||
onClick={onSoundToggle}
|
||||
onClick={() => {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: maxControlZIndex ?? 900,
|
||||
}}
|
||||
onClick={stopControlEvent}
|
||||
onPointerDown={stopControlEvent}
|
||||
onMouseDown={stopControlEvent}
|
||||
onTouchEnd={stopControlEvent}
|
||||
>
|
||||
{renderedControls}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = () => {
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{selectedProjectId && (
|
||||
<CardBox className='mb-6'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center justify-between text-left'
|
||||
onClick={() => setIsUiControlsExpanded(!isUiControlsExpanded)}
|
||||
>
|
||||
<h3 className='text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
||||
Project UI Controls Settings
|
||||
</h3>
|
||||
<Icon
|
||||
path={isUiControlsExpanded ? mdiChevronUp : mdiChevronDown}
|
||||
size={0.8}
|
||||
className='text-gray-500'
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isUiControlsExpanded && (
|
||||
<div className='mt-4'>
|
||||
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||
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.
|
||||
</p>
|
||||
<textarea
|
||||
className='min-h-[180px] w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs dark:border-dark-600 dark:bg-dark-800'
|
||||
value={uiControlsJson}
|
||||
onChange={(event) => setUiControlsJson(event.target.value)}
|
||||
/>
|
||||
<div className='mt-4 flex items-center gap-3'>
|
||||
<BaseButton
|
||||
label={
|
||||
isSavingUiControls ? 'Saving...' : 'Save UI Controls'
|
||||
}
|
||||
color='info'
|
||||
small
|
||||
onClick={handleSaveUiControlsSettings}
|
||||
disabled={isSavingUiControls}
|
||||
/>
|
||||
{uiControlsSaveSuccess && (
|
||||
<span className='text-xs text-green-600'>
|
||||
Saved successfully!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<CardBoxModal
|
||||
title='Create page'
|
||||
buttonColor='info'
|
||||
|
||||
@ -34,6 +34,11 @@ import type {
|
||||
AssetOption,
|
||||
} from '../types/constructor';
|
||||
import type { TourPage, Asset } from '../types/entities';
|
||||
import type {
|
||||
ResolvedUiControlsSettings,
|
||||
SystemUiControlSettings,
|
||||
SystemUiControlType,
|
||||
} from '../types/uiControls';
|
||||
|
||||
// ============================================================================
|
||||
// Navigation Types
|
||||
@ -146,6 +151,15 @@ export interface ConstructorContextValue {
|
||||
copySelectedElement: () => void;
|
||||
pasteCopiedElement: () => CanvasElement | null;
|
||||
|
||||
// System UI controls
|
||||
selectedSystemControl: SystemUiControlType | null;
|
||||
resolvedUiControlsSettings: ResolvedUiControlsSettings;
|
||||
uiControlsCanvasAspectRatio: number;
|
||||
updateSystemControl: (
|
||||
control: SystemUiControlType,
|
||||
patch: Partial<SystemUiControlSettings>,
|
||||
) => void;
|
||||
|
||||
// Menu state
|
||||
selectedMenuItem: EditorMenuItem;
|
||||
setSelectedMenuItem: (item: EditorMenuItem) => void;
|
||||
@ -296,6 +310,10 @@ export function useConstructorElements() {
|
||||
removeSelectedElement: ctx.removeSelectedElement,
|
||||
copySelectedElement: ctx.copySelectedElement,
|
||||
pasteCopiedElement: ctx.pasteCopiedElement,
|
||||
selectedSystemControl: ctx.selectedSystemControl,
|
||||
resolvedUiControlsSettings: ctx.resolvedUiControlsSettings,
|
||||
uiControlsCanvasAspectRatio: ctx.uiControlsCanvasAspectRatio,
|
||||
updateSystemControl: ctx.updateSystemControl,
|
||||
}),
|
||||
[
|
||||
ctx.elements,
|
||||
@ -313,6 +331,10 @@ export function useConstructorElements() {
|
||||
ctx.removeSelectedElement,
|
||||
ctx.copySelectedElement,
|
||||
ctx.pasteCopiedElement,
|
||||
ctx.selectedSystemControl,
|
||||
ctx.resolvedUiControlsSettings,
|
||||
ctx.uiControlsCanvasAspectRatio,
|
||||
ctx.updateSystemControl,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,12 +24,18 @@ interface UseCanvasElementDragOptions {
|
||||
) => void;
|
||||
/** Whether dragging is enabled (e.g., only in edit mode) */
|
||||
enabled?: boolean;
|
||||
/** Optional custom clamp for element-specific bounds */
|
||||
clampPosition?: (
|
||||
elementId: string,
|
||||
xPercent: number,
|
||||
yPercent: number,
|
||||
) => { xPercent: number; yPercent: number };
|
||||
}
|
||||
|
||||
interface UseCanvasElementDragResult {
|
||||
/** Handler to attach to element's onMouseDown */
|
||||
onElementDragStart: (
|
||||
event: React.MouseEvent,
|
||||
event: React.MouseEvent | React.PointerEvent,
|
||||
elementId: string,
|
||||
currentXPercent: number,
|
||||
currentYPercent: number,
|
||||
@ -75,6 +81,7 @@ export function useCanvasElementDrag({
|
||||
canvasRef,
|
||||
onPositionChange,
|
||||
enabled = true,
|
||||
clampPosition,
|
||||
}: UseCanvasElementDragOptions): UseCanvasElementDragResult {
|
||||
const dragRef = useRef<ElementDragState | null>(null);
|
||||
|
||||
@ -85,17 +92,24 @@ export function useCanvasElementDrag({
|
||||
return;
|
||||
}
|
||||
|
||||
const onPointerMove = (event: MouseEvent) => {
|
||||
const onPointerMove = (event: MouseEvent | PointerEvent) => {
|
||||
if (!dragRef.current || !canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const rawX = event.clientX - rect.left - dragRef.current.pointerOffsetX;
|
||||
const rawY = event.clientY - rect.top - dragRef.current.pointerOffsetY;
|
||||
|
||||
const nextXPercent = clamp((rawX / rect.width) * 100, 0, 100);
|
||||
const nextYPercent = clamp((rawY / rect.height) * 100, 0, 100);
|
||||
const rawXPercent = clamp((rawX / rect.width) * 100, 0, 100);
|
||||
const rawYPercent = clamp((rawY / rect.height) * 100, 0, 100);
|
||||
const nextPosition = clampPosition
|
||||
? clampPosition(dragRef.current.id, rawXPercent, rawYPercent)
|
||||
: { xPercent: rawXPercent, yPercent: rawYPercent };
|
||||
|
||||
onPositionChange(dragRef.current.id, nextXPercent, nextYPercent);
|
||||
onPositionChange(
|
||||
dragRef.current.id,
|
||||
nextPosition.xPercent,
|
||||
nextPosition.yPercent,
|
||||
);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
@ -104,16 +118,20 @@ export function useCanvasElementDrag({
|
||||
|
||||
window.addEventListener('mousemove', onPointerMove);
|
||||
window.addEventListener('mouseup', onPointerUp);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onPointerMove);
|
||||
window.removeEventListener('mouseup', onPointerUp);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
}, [enabled, canvasRef, onPositionChange]);
|
||||
}, [enabled, canvasRef, onPositionChange, clampPosition]);
|
||||
|
||||
const onElementDragStart = useCallback(
|
||||
(
|
||||
event: React.MouseEvent,
|
||||
event: React.MouseEvent | React.PointerEvent,
|
||||
elementId: string,
|
||||
currentXPercent: number,
|
||||
currentYPercent: number,
|
||||
|
||||
@ -408,12 +408,7 @@ export function useConstructorElements({
|
||||
onElementPasted?.(pastedElement);
|
||||
|
||||
return pastedElement;
|
||||
}, [
|
||||
copiedElement,
|
||||
onElementSelected,
|
||||
onElementPasted,
|
||||
setElementsWithRef,
|
||||
]);
|
||||
}, [copiedElement, onElementSelected, onElementPasted, setElementsWithRef]);
|
||||
|
||||
const updateElementPosition = useCallback(
|
||||
(elementId: string, xPercent: number, yPercent: number) => {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { CanvasElement, PageBackgroundState } from '../types/constructor';
|
||||
import type { UiControlsSettings } from '../types/uiControls';
|
||||
import { createLocalId } from '../lib/elementDefaults';
|
||||
import { parseJsonObject } from '../lib/parseJson';
|
||||
import { logger } from '../lib/logger';
|
||||
@ -38,6 +39,7 @@ interface TourPage {
|
||||
background_audio_loop?: boolean;
|
||||
background_audio_start_time?: number | null;
|
||||
background_audio_end_time?: number | null;
|
||||
global_ui_controls_settings_json?: UiControlsSettings | string | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@ -64,6 +66,8 @@ interface UseConstructorPageActionsOptions {
|
||||
getElements?: () => CanvasElement[];
|
||||
/** Consolidated page background state */
|
||||
pageBackground: PageBackgroundState;
|
||||
/** Page-level global UI controls overrides */
|
||||
uiControlsSettings?: UiControlsSettings | null;
|
||||
/** Callback to reload data after operations */
|
||||
onReload: (preservePageId?: string) => Promise<void>;
|
||||
/** Callback to set active page ID */
|
||||
@ -130,6 +134,7 @@ export function useConstructorPageActions({
|
||||
elements,
|
||||
getElements,
|
||||
pageBackground,
|
||||
uiControlsSettings,
|
||||
onReload,
|
||||
onSetActivePageId,
|
||||
onSetMenuOpen,
|
||||
@ -235,6 +240,7 @@ export function useConstructorPageActions({
|
||||
background_audio_loop: backgroundAudioLoop,
|
||||
background_audio_start_time: backgroundAudioStartTime,
|
||||
background_audio_end_time: backgroundAudioEndTime,
|
||||
global_ui_controls_settings_json: uiControlsSettings || null,
|
||||
// Copy project design dimensions to page for presentation isolation
|
||||
design_width: project?.design_width ?? null,
|
||||
design_height: project?.design_height ?? null,
|
||||
@ -282,6 +288,7 @@ export function useConstructorPageActions({
|
||||
activePage?.ui_schema_json,
|
||||
activePageId,
|
||||
pageBackground,
|
||||
uiControlsSettings,
|
||||
elements,
|
||||
getElements,
|
||||
project?.design_width,
|
||||
@ -360,6 +367,7 @@ export function useConstructorPageActions({
|
||||
background_loop: false,
|
||||
requires_auth: false,
|
||||
ui_schema_json: { elements: [] },
|
||||
global_ui_controls_settings_json: null,
|
||||
// Copy project design dimensions to new page
|
||||
design_width: project?.design_width ?? null,
|
||||
design_height: project?.design_height ?? null,
|
||||
|
||||
@ -35,10 +35,15 @@ import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useTransitionSettings } from '../hooks/useTransitionSettings';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
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 { ElementTransitionSettings } from '../types/transition';
|
||||
import {
|
||||
entityToProjectSettings,
|
||||
@ -85,6 +90,14 @@ import type {
|
||||
GalleryCarouselMediaItem,
|
||||
} from '../types/constructor';
|
||||
import type { TourPage } from '../types/entities';
|
||||
import {
|
||||
parseUiControlsSettings,
|
||||
getSystemControlAnchorBounds,
|
||||
resolveUiControlsSettings,
|
||||
type SystemUiControlSettings,
|
||||
type SystemUiControlType,
|
||||
type UiControlsSettings,
|
||||
} from '../types/uiControls';
|
||||
|
||||
// Constructor-specific hooks
|
||||
import {
|
||||
@ -159,6 +172,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const globalTransitionDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.data,
|
||||
);
|
||||
const globalUiControlDefaults = useAppSelector(
|
||||
(state) => state.global_ui_control_defaults.data,
|
||||
);
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||
@ -184,6 +200,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
? selectProjectTransitionSettings(state, projectId, 'dev')
|
||||
: undefined,
|
||||
);
|
||||
const projectUiControlSettingsEntity = useAppSelector((state) =>
|
||||
projectId
|
||||
? selectProjectUiControlSettings(state, projectId, 'dev')
|
||||
: undefined,
|
||||
);
|
||||
const projectTransitionSettings = useMemo(
|
||||
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
||||
[projectTransitionSettingsEntity],
|
||||
@ -273,6 +294,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Fetch global transition defaults on mount
|
||||
useEffect(() => {
|
||||
dispatch(fetchGlobalTransitionDefaults());
|
||||
dispatch(fetchGlobalUiControlDefaults());
|
||||
}, [dispatch]);
|
||||
|
||||
// Fetch project transition settings for dev environment
|
||||
@ -281,6 +303,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
dispatch(
|
||||
fetchProjectTransitionSettings({ projectId, environment: 'dev' }),
|
||||
);
|
||||
dispatch(
|
||||
fetchProjectUiControlSettings({ projectId, environment: 'dev' }),
|
||||
);
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
@ -346,6 +371,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||
return ['navigation_next', 'navigation_prev'];
|
||||
}, []);
|
||||
const [pageUiControlsSettings, setPageUiControlsSettings] =
|
||||
useState<UiControlsSettings | null>(null);
|
||||
const [selectedSystemControl, setSelectedSystemControl] =
|
||||
useState<SystemUiControlType | null>(null);
|
||||
|
||||
// Element CRUD operations via useConstructorElements hook
|
||||
const {
|
||||
@ -375,6 +404,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
allowedNavigationTypes,
|
||||
initialSelectedElementId: elementIdFromRoute,
|
||||
onElementSelected: useCallback(() => {
|
||||
setSelectedSystemControl(null);
|
||||
setSelectedMenuItem('none');
|
||||
}, []),
|
||||
onSelectionCleared: useCallback(() => {
|
||||
@ -507,11 +537,30 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const didSetInitialCanvasFocus = useRef(false);
|
||||
const selectedElementIdRef = useRef<string>('');
|
||||
selectedElementIdRef.current = selectedElementId;
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const activePage = useMemo(
|
||||
() => pages.find((item) => item.id === activePageId) || null,
|
||||
[activePageId, pages],
|
||||
);
|
||||
const resolvedUiControlsSettings = useMemo(
|
||||
() =>
|
||||
resolveUiControlsSettings(
|
||||
globalUiControlDefaults?.settings_json,
|
||||
projectUiControlSettingsEntity?.settings_json,
|
||||
pageUiControlsSettings,
|
||||
),
|
||||
[
|
||||
globalUiControlDefaults?.settings_json,
|
||||
projectUiControlSettingsEntity?.settings_json,
|
||||
pageUiControlsSettings,
|
||||
],
|
||||
);
|
||||
const uiControlsCanvasAspectRatio = useMemo(
|
||||
() =>
|
||||
canvasWidth > 0 && canvasHeight > 0 ? canvasWidth / canvasHeight : 1,
|
||||
[canvasHeight, canvasWidth],
|
||||
);
|
||||
|
||||
// Existing page slugs for uniqueness validation (current environment only)
|
||||
const existingSlugs = useMemo(() => {
|
||||
@ -571,6 +620,48 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
enabled: constructorInteractionMode === 'edit',
|
||||
});
|
||||
|
||||
const updateSystemControlPosition = useCallback(
|
||||
(controlId: string, xPercent: number, yPercent: number) => {
|
||||
const control = controlId as SystemUiControlType;
|
||||
setPageUiControlsSettings((current) => ({
|
||||
...(current || {}),
|
||||
[control]: {
|
||||
...(current?.[control] || {}),
|
||||
xPercent,
|
||||
yPercent,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clampSystemControlPosition = useCallback(
|
||||
(controlId: string, xPercent: number, yPercent: number) => {
|
||||
const control = controlId as SystemUiControlType;
|
||||
const settings = resolvedUiControlsSettings[control];
|
||||
const bounds = getSystemControlAnchorBounds(
|
||||
settings.anchor,
|
||||
settings.buttonSizePercent,
|
||||
uiControlsCanvasAspectRatio,
|
||||
);
|
||||
|
||||
return {
|
||||
xPercent: clamp(xPercent, bounds.minX, bounds.maxX),
|
||||
yPercent: clamp(yPercent, bounds.minY, bounds.maxY),
|
||||
};
|
||||
},
|
||||
[resolvedUiControlsSettings, uiControlsCanvasAspectRatio],
|
||||
);
|
||||
|
||||
const { onElementDragStart: onSystemControlDragStart } = useCanvasElementDrag(
|
||||
{
|
||||
canvasRef,
|
||||
onPositionChange: updateSystemControlPosition,
|
||||
enabled: constructorInteractionMode === 'edit',
|
||||
clampPosition: clampSystemControlPosition,
|
||||
},
|
||||
);
|
||||
|
||||
// Preload orchestrator for better DX when previewing pages
|
||||
// Preloads current page + transition videos only
|
||||
// STREAM-FIRST: Constructor always uses online mode
|
||||
@ -1090,6 +1181,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
elements,
|
||||
getElements,
|
||||
pageBackground,
|
||||
uiControlsSettings: pageUiControlsSettings,
|
||||
onReload: handleReload,
|
||||
onSetActivePageId: setActivePageId,
|
||||
onSetMenuOpen: setIsMenuOpen,
|
||||
@ -1266,10 +1358,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
elementsPageIdRef.current = null;
|
||||
setElements([]);
|
||||
clearSelection();
|
||||
setSelectedSystemControl(null);
|
||||
setPageUiControlsSettings(null);
|
||||
updateBackgroundFromPage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSystemControl(null);
|
||||
setPageUiControlsSettings(
|
||||
parseUiControlsSettings(activePage.global_ui_controls_settings_json),
|
||||
);
|
||||
|
||||
const schema = parseJsonObject<ConstructorSchema>(
|
||||
activePage.ui_schema_json,
|
||||
{},
|
||||
@ -1615,6 +1714,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Thin wrappers for hook functions (handle additional state like selectedMenuItem)
|
||||
const selectElementForEdit = useCallback(
|
||||
(elementId: string) => {
|
||||
setSelectedSystemControl(null);
|
||||
selectElement(elementId);
|
||||
// Note: setSelectedMenuItem('none') is handled by onElementSelected callback
|
||||
},
|
||||
@ -1623,6 +1723,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
const selectMenuItemForEdit = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
setSelectedSystemControl(null);
|
||||
clearSelection();
|
||||
setSelectedMenuItem(item);
|
||||
},
|
||||
@ -1685,6 +1786,52 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const onSystemControlMouseDown = (
|
||||
event: React.MouseEvent | React.PointerEvent,
|
||||
control: SystemUiControlType,
|
||||
) => {
|
||||
if (!isConstructorEditMode) return;
|
||||
|
||||
clearSelection();
|
||||
setSelectedMenuItem('none');
|
||||
setSelectedSystemControl(control);
|
||||
|
||||
const settings = resolvedUiControlsSettings[control];
|
||||
onSystemControlDragStart(
|
||||
event,
|
||||
control,
|
||||
settings.xPercent,
|
||||
settings.yPercent,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Constructor fullscreen toggle failed:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const onCanvasElementClick = (element: CanvasElement) => {
|
||||
if (!isConstructorEditMode) {
|
||||
if (isNavigationElementType(element.type)) {
|
||||
@ -2032,17 +2179,19 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
|
||||
const hasEditorSelection =
|
||||
isConstructorEditMode &&
|
||||
(Boolean(selectedElement) || selectedMenuItem !== 'none');
|
||||
const editorTitle =
|
||||
selectedMenuItem === 'background_image'
|
||||
? 'Background image'
|
||||
: selectedMenuItem === 'background_video'
|
||||
? 'Background video'
|
||||
: selectedMenuItem === 'background_embed'
|
||||
? 'Background 360'
|
||||
: selectedMenuItem === 'background_audio'
|
||||
? 'Background audio'
|
||||
: selectedElement?.label || 'Element editor';
|
||||
(Boolean(selectedElement) ||
|
||||
Boolean(selectedSystemControl) ||
|
||||
selectedMenuItem !== 'none');
|
||||
const editorTitle = useMemo(() => {
|
||||
if (selectedSystemControl === 'fullscreen') return 'Fullscreen Button';
|
||||
if (selectedSystemControl === 'sound') return 'Sound Button';
|
||||
if (selectedSystemControl === 'offline') return 'Offline Button';
|
||||
if (selectedMenuItem === 'background_image') return 'Background image';
|
||||
if (selectedMenuItem === 'background_video') return 'Background video';
|
||||
if (selectedMenuItem === 'background_embed') return 'Background 360';
|
||||
if (selectedMenuItem === 'background_audio') return 'Background audio';
|
||||
return selectedElement?.label || 'Element editor';
|
||||
}, [selectedElement?.label, selectedMenuItem, selectedSystemControl]);
|
||||
|
||||
// Background image is rendered by CanvasBackground component (same as runtime)
|
||||
// No CSS background-image needed on canvas div
|
||||
@ -2114,6 +2263,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
selectedSystemControl,
|
||||
resolvedUiControlsSettings,
|
||||
uiControlsCanvasAspectRatio,
|
||||
updateSystemControl: (
|
||||
control: SystemUiControlType,
|
||||
patch: Partial<SystemUiControlSettings>,
|
||||
) => {
|
||||
setPageUiControlsSettings((current) => ({
|
||||
...(current || {}),
|
||||
[control]: {
|
||||
...(current?.[control] || {}),
|
||||
...patch,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
// Menu state
|
||||
selectedMenuItem,
|
||||
@ -2167,6 +2331,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
setBackgroundEmbedUrl,
|
||||
setBackgroundAudioUrl,
|
||||
setBackgroundVideoSettings,
|
||||
setBackgroundAudioSettings,
|
||||
elements,
|
||||
setElements,
|
||||
getElements,
|
||||
@ -2180,6 +2345,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
removeSelectedElement,
|
||||
copySelectedElement,
|
||||
pasteCopiedElement,
|
||||
selectedSystemControl,
|
||||
resolvedUiControlsSettings,
|
||||
uiControlsCanvasAspectRatio,
|
||||
selectedMenuItem,
|
||||
isMenuOpen,
|
||||
elementEditorTab,
|
||||
@ -2459,26 +2627,37 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
</BackdropPortalProvider>
|
||||
</div>
|
||||
|
||||
{!isConstructorEditMode &&
|
||||
!activeGalleryCarousel &&
|
||||
!activeInfoPanelGallery &&
|
||||
soundControl.showSoundButton && (
|
||||
<RuntimeControls
|
||||
projectId={projectId || null}
|
||||
projectSlug=''
|
||||
projectName={projectName}
|
||||
pages={pages}
|
||||
isFullscreen={false}
|
||||
toggleFullscreen={() => undefined}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
showOfflineButton={false}
|
||||
showFullscreenButton={false}
|
||||
showSoundButton={soundControl.showSoundButton}
|
||||
isMuted={soundControl.isMuted}
|
||||
onSoundToggle={soundControl.toggleSound}
|
||||
/>
|
||||
)}
|
||||
{!activeGalleryCarousel && !activeInfoPanelGallery && (
|
||||
<RuntimeControls
|
||||
projectId={projectId || null}
|
||||
projectSlug=''
|
||||
projectName={projectName}
|
||||
pages={pages}
|
||||
isFullscreen={isFullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
showOfflineButton={true}
|
||||
showFullscreenButton={true}
|
||||
showSoundButton={
|
||||
isConstructorEditMode || soundControl.showSoundButton
|
||||
}
|
||||
isMuted={soundControl.isMuted}
|
||||
onSoundToggle={soundControl.toggleSound}
|
||||
controlsSettings={resolvedUiControlsSettings}
|
||||
maxControlZIndex={900}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
editMode={isConstructorEditMode}
|
||||
selectedControl={selectedSystemControl}
|
||||
onControlSelect={(control) => {
|
||||
if (!isConstructorEditMode) return;
|
||||
clearSelection();
|
||||
setSelectedMenuItem('none');
|
||||
setSelectedSystemControl(control);
|
||||
}}
|
||||
onControlMouseDown={onSystemControlMouseDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ElementEditorPanel now uses ConstructorContext for all state */}
|
||||
{pages.length > 0 && hasEditorSelection && (
|
||||
|
||||
@ -122,7 +122,8 @@ const Dashboard = () => {
|
||||
const visibleEntities = getVisibleEntities();
|
||||
|
||||
React.useEffect(() => {
|
||||
const allowedPrivateSlugs = currentUser?.allowedPrivateProductionSlugs || [];
|
||||
const allowedPrivateSlugs =
|
||||
currentUser?.allowedPrivateProductionSlugs || [];
|
||||
if (visibleEntities.length > 0 || allowedPrivateSlugs.length === 0) return;
|
||||
|
||||
router.replace(`/p/${allowedPrivateSlugs[0]}`);
|
||||
|
||||
@ -15,6 +15,10 @@ import {
|
||||
fetch as fetchGlobalTransitionDefaults,
|
||||
update as updateGlobalTransitionDefaults,
|
||||
} from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||
import {
|
||||
fetch as fetchGlobalUiControlDefaults,
|
||||
update as updateGlobalUiControlDefaults,
|
||||
} from '../stores/global_ui_control_defaults/globalUiControlDefaultsSlice';
|
||||
import type { UiElementDefault } from '../types/constructor';
|
||||
import type {
|
||||
GlobalTransitionDefaults,
|
||||
@ -54,6 +58,9 @@ const ElementTypeDefaultsPage = () => {
|
||||
const globalLoading = useAppSelector(
|
||||
(state) => state.global_transition_defaults.loading,
|
||||
);
|
||||
const globalUiControlDefaults = useAppSelector(
|
||||
(state) => state.global_ui_control_defaults.data,
|
||||
);
|
||||
|
||||
// Local state for global transition defaults editing
|
||||
const [localTransitionType, setLocalTransitionType] =
|
||||
@ -63,6 +70,9 @@ const ElementTypeDefaultsPage = () => {
|
||||
const [localOverlayColor, setLocalOverlayColor] = useState<string>('#000000');
|
||||
const [isSavingGlobal, setIsSavingGlobal] = useState(false);
|
||||
const [globalSaveSuccess, setGlobalSaveSuccess] = useState(false);
|
||||
const [uiControlsJson, setUiControlsJson] = useState('');
|
||||
const [isSavingUiControls, setIsSavingUiControls] = useState(false);
|
||||
const [uiControlsSaveSuccess, setUiControlsSaveSuccess] = useState(false);
|
||||
|
||||
const [rows, setRows] = useState<ElementTypeDefault[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -71,6 +81,7 @@ const ElementTypeDefaultsPage = () => {
|
||||
// Load global transition defaults
|
||||
useEffect(() => {
|
||||
dispatch(fetchGlobalTransitionDefaults());
|
||||
dispatch(fetchGlobalUiControlDefaults());
|
||||
}, [dispatch]);
|
||||
|
||||
// Sync local state when global defaults are loaded
|
||||
@ -83,6 +94,14 @@ const ElementTypeDefaultsPage = () => {
|
||||
}
|
||||
}, [globalDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalUiControlDefaults) {
|
||||
setUiControlsJson(
|
||||
JSON.stringify(globalUiControlDefaults.settings_json || {}, null, 2),
|
||||
);
|
||||
}
|
||||
}, [globalUiControlDefaults]);
|
||||
|
||||
const handleSaveGlobalDefaults = async () => {
|
||||
if (!globalDefaults?.id) return;
|
||||
|
||||
@ -141,6 +160,30 @@ const ElementTypeDefaultsPage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveGlobalUiControls = async () => {
|
||||
if (!globalUiControlDefaults?.id) return;
|
||||
|
||||
setIsSavingUiControls(true);
|
||||
setUiControlsSaveSuccess(false);
|
||||
try {
|
||||
const parsed = JSON.parse(uiControlsJson || '{}');
|
||||
await dispatch(
|
||||
updateGlobalUiControlDefaults({
|
||||
id: globalUiControlDefaults.id,
|
||||
data: {
|
||||
settings_json: parsed,
|
||||
},
|
||||
}),
|
||||
).unwrap();
|
||||
setUiControlsSaveSuccess(true);
|
||||
setTimeout(() => setUiControlsSaveSuccess(false), 2000);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save global UI control defaults:', error);
|
||||
} finally {
|
||||
setIsSavingUiControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRows();
|
||||
}, [loadRows]);
|
||||
@ -266,6 +309,35 @@ const ElementTypeDefaultsPage = () => {
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='mb-6'>
|
||||
<h3 className='mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
||||
Global UI Controls Defaults
|
||||
</h3>
|
||||
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||
Fixed-size fullscreen, sound, and offline buttons. Positions are
|
||||
canvas-relative percentages; dimensions are CSS pixels.
|
||||
</p>
|
||||
<textarea
|
||||
className='min-h-[220px] w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs dark:border-dark-600 dark:bg-dark-800'
|
||||
value={uiControlsJson}
|
||||
onChange={(event) => setUiControlsJson(event.target.value)}
|
||||
/>
|
||||
<div className='mt-4 flex items-center gap-3'>
|
||||
<BaseButton
|
||||
label={isSavingUiControls ? 'Saving...' : 'Save UI Controls'}
|
||||
color='info'
|
||||
small
|
||||
onClick={handleSaveGlobalUiControls}
|
||||
disabled={isSavingUiControls || !globalUiControlDefaults}
|
||||
/>
|
||||
{uiControlsSaveSuccess && (
|
||||
<span className='text-xs text-green-600'>
|
||||
Saved successfully!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{/* Element Types List */}
|
||||
<CardBox>
|
||||
{isLoading ? (
|
||||
|
||||
@ -52,7 +52,9 @@ export default function Login() {
|
||||
if (currentUser?.id) {
|
||||
const next = router.query.next;
|
||||
const safeNextPath =
|
||||
typeof next === 'string' && next.startsWith('/') && !next.startsWith('//')
|
||||
typeof next === 'string' &&
|
||||
next.startsWith('/') &&
|
||||
!next.startsWith('//')
|
||||
? next
|
||||
: '/dashboard';
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ const initVals = {
|
||||
background_loop: false,
|
||||
requires_auth: false,
|
||||
ui_schema_json: '',
|
||||
global_ui_controls_settings_json: '',
|
||||
};
|
||||
|
||||
const EditTour_pagesPage = () => {
|
||||
@ -160,6 +161,17 @@ const EditTour_pagesPage = () => {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label='Global UI Controls Settings JSON'
|
||||
hasTextareaHeight
|
||||
>
|
||||
<Field
|
||||
name='global_ui_controls_settings_json'
|
||||
as='textarea'
|
||||
placeholder='Global UI Controls Settings JSON'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
|
||||
@ -74,104 +74,108 @@ const UsersNew = () => {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
{({ setFieldValue }) => (
|
||||
<Form>
|
||||
<FormField label='First Name'>
|
||||
<Field name='firstName' placeholder='First Name' />
|
||||
</FormField>
|
||||
<Form>
|
||||
<FormField label='First Name'>
|
||||
<Field name='firstName' placeholder='First Name' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Last Name'>
|
||||
<Field name='lastName' placeholder='Last Name' />
|
||||
</FormField>
|
||||
<FormField label='Last Name'>
|
||||
<Field name='lastName' placeholder='Last Name' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Phone Number'>
|
||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||
</FormField>
|
||||
<FormField label='Phone Number'>
|
||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='E-Mail'>
|
||||
<Field name='email' placeholder='E-Mail' />
|
||||
</FormField>
|
||||
<FormField label='E-Mail'>
|
||||
<Field name='email' placeholder='E-Mail' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Disabled' labelFor='disabled'>
|
||||
<Field name='disabled' id='disabled' component={SwitchField} />
|
||||
</FormField>
|
||||
<FormField label='Disabled' labelFor='disabled'>
|
||||
<Field
|
||||
name='disabled'
|
||||
id='disabled'
|
||||
component={SwitchField}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Field
|
||||
label='Avatar'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path='users/avatar'
|
||||
name='avatar'
|
||||
id='avatar'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormImagePicker}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Field
|
||||
label='Avatar'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path='users/avatar'
|
||||
name='avatar'
|
||||
id='avatar'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormImagePicker}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='App Role' labelFor='app_role'>
|
||||
<Field
|
||||
name='app_role'
|
||||
id='app_role'
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef='roles'
|
||||
onOptionChange={(option) => {
|
||||
const label = option?.label || '';
|
||||
setSelectedRoleLabel(label);
|
||||
if (label !== 'Public') {
|
||||
setFieldValue(
|
||||
'allowed_private_production_project_ids',
|
||||
[],
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label='App Role' labelFor='app_role'>
|
||||
<Field
|
||||
name='app_role'
|
||||
id='app_role'
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef='roles'
|
||||
onOptionChange={(option) => {
|
||||
const label = option?.label || '';
|
||||
setSelectedRoleLabel(label);
|
||||
if (label !== 'Public') {
|
||||
setFieldValue(
|
||||
'allowed_private_production_project_ids',
|
||||
[],
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{selectedRoleLabel === 'Public' && (
|
||||
<FormField
|
||||
label='Allowed Private Production Presentations'
|
||||
labelFor='allowed_private_production_project_ids'
|
||||
>
|
||||
<Field
|
||||
name='allowed_private_production_project_ids'
|
||||
id='allowed_private_production_project_ids'
|
||||
itemRef='runtime-access/private-production-presentations'
|
||||
options={[]}
|
||||
component={SelectFieldMany}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{selectedRoleLabel === 'Public' && (
|
||||
<FormField
|
||||
label='Allowed Private Production Presentations'
|
||||
labelFor='allowed_private_production_project_ids'
|
||||
label='Custom Permissions'
|
||||
labelFor='custom_permissions'
|
||||
>
|
||||
<Field
|
||||
name='allowed_private_production_project_ids'
|
||||
id='allowed_private_production_project_ids'
|
||||
itemRef='runtime-access/private-production-presentations'
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef='permissions'
|
||||
options={[]}
|
||||
component={SelectFieldMany}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label='Custom Permissions'
|
||||
labelFor='custom_permissions'
|
||||
>
|
||||
<Field
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef='permissions'
|
||||
options={[]}
|
||||
component={SelectFieldMany}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||
<BaseButton
|
||||
type='reset'
|
||||
color='danger'
|
||||
outline
|
||||
label='Cancel'
|
||||
onClick={() => router.push('/users/users-list')}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type='submit' color='info' label='Submit' />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||
<BaseButton
|
||||
type='reset'
|
||||
color='danger'
|
||||
outline
|
||||
label='Cancel'
|
||||
onClick={() => router.push('/users/users-list')}
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardBox>
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
fulfilledNotify,
|
||||
rejectNotify,
|
||||
resetNotify,
|
||||
} from '../../helpers/notifyStateHandler';
|
||||
import type { ApiError } from '../../types/api';
|
||||
import type { GlobalUiControlDefaults } from '../../types/uiControls';
|
||||
|
||||
interface NotifyState {
|
||||
showNotification: boolean;
|
||||
textNotification: string;
|
||||
typeNotification: string;
|
||||
}
|
||||
|
||||
interface GlobalUiControlDefaultsState {
|
||||
data: GlobalUiControlDefaults | null;
|
||||
loading: boolean;
|
||||
notify: NotifyState;
|
||||
}
|
||||
|
||||
const initialState: GlobalUiControlDefaultsState = {
|
||||
data: null,
|
||||
loading: false,
|
||||
notify: {
|
||||
showNotification: false,
|
||||
textNotification: '',
|
||||
typeNotification: '',
|
||||
},
|
||||
};
|
||||
|
||||
function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
||||
return axios.isAxiosError(error);
|
||||
}
|
||||
|
||||
export const fetch = createAsyncThunk<
|
||||
GlobalUiControlDefaults,
|
||||
void,
|
||||
{ rejectValue: ApiError }
|
||||
>('global_ui_control_defaults/fetch', async () => {
|
||||
const result = await axios.get<GlobalUiControlDefaults>(
|
||||
'global-ui-control-defaults',
|
||||
);
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export const update = createAsyncThunk<
|
||||
GlobalUiControlDefaults,
|
||||
{ id: string; data: Partial<GlobalUiControlDefaults> },
|
||||
{ rejectValue: ApiError }
|
||||
>('global_ui_control_defaults/update', async (payload, { rejectWithValue }) => {
|
||||
try {
|
||||
const result = await axios.put<GlobalUiControlDefaults>(
|
||||
`global-ui-control-defaults/${payload.id}`,
|
||||
{
|
||||
id: payload.id,
|
||||
data: payload.data,
|
||||
},
|
||||
);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
return rejectWithValue(error.response.data as ApiError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const globalUiControlDefaultsSlice = createSlice({
|
||||
name: 'global_ui_control_defaults',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearState: (state) => {
|
||||
state.data = null;
|
||||
},
|
||||
setData: (state, action: PayloadAction<GlobalUiControlDefaults | null>) => {
|
||||
state.data = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(fetch.pending, (state) => {
|
||||
state.loading = true;
|
||||
resetNotify(state);
|
||||
});
|
||||
builder.addCase(fetch.fulfilled, (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.loading = false;
|
||||
});
|
||||
builder.addCase(fetch.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
rejectNotify(state, action);
|
||||
});
|
||||
builder.addCase(update.pending, (state) => {
|
||||
state.loading = true;
|
||||
resetNotify(state);
|
||||
});
|
||||
builder.addCase(update.fulfilled, (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.loading = false;
|
||||
fulfilledNotify(state, 'Global UI controls defaults have been updated');
|
||||
});
|
||||
builder.addCase(update.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
rejectNotify(state, action);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearState, setData } = globalUiControlDefaultsSlice.actions;
|
||||
|
||||
export default globalUiControlDefaultsSlice.reducer;
|
||||
@ -0,0 +1,232 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
fulfilledNotify,
|
||||
rejectNotify,
|
||||
resetNotify,
|
||||
} from '../../helpers/notifyStateHandler';
|
||||
import type { ApiError } from '../../types/api';
|
||||
import type { ProjectUiControlSettingsEntity } from '../../types/uiControls';
|
||||
|
||||
interface NotifyState {
|
||||
showNotification: boolean;
|
||||
textNotification: string;
|
||||
typeNotification: string;
|
||||
}
|
||||
|
||||
interface ProjectUiControlSettingsState {
|
||||
byProjectEnv: Record<string, ProjectUiControlSettingsEntity | null>;
|
||||
loading: boolean;
|
||||
loadingKeys: Record<string, boolean>;
|
||||
notify: NotifyState;
|
||||
}
|
||||
|
||||
const initialState: ProjectUiControlSettingsState = {
|
||||
byProjectEnv: {},
|
||||
loading: false,
|
||||
loadingKeys: {},
|
||||
notify: {
|
||||
showNotification: false,
|
||||
textNotification: '',
|
||||
typeNotification: '',
|
||||
},
|
||||
};
|
||||
|
||||
function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
||||
return axios.isAxiosError(error);
|
||||
}
|
||||
|
||||
function buildKey(projectId: string, environment: string): string {
|
||||
return `${projectId}:${environment}`;
|
||||
}
|
||||
|
||||
export const fetchByProjectAndEnv = createAsyncThunk<
|
||||
{ key: string; data: ProjectUiControlSettingsEntity | null },
|
||||
{
|
||||
projectId: string;
|
||||
environment: 'dev' | 'stage' | 'production';
|
||||
apiHeaders?: Record<string, string>;
|
||||
},
|
||||
{ rejectValue: ApiError }
|
||||
>(
|
||||
'project_ui_control_settings/fetchByProjectAndEnv',
|
||||
async ({ projectId, environment, apiHeaders }, { rejectWithValue }) => {
|
||||
const key = buildKey(projectId, environment);
|
||||
try {
|
||||
const result = await axios.get<ProjectUiControlSettingsEntity | null>(
|
||||
`project-ui-control-settings/project/${projectId}/env/${environment}`,
|
||||
{ headers: apiHeaders || {} },
|
||||
);
|
||||
return { key, data: result.data };
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
if (error.response.status === 404) {
|
||||
return { key, data: null };
|
||||
}
|
||||
return rejectWithValue(error.response.data as ApiError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const upsertByProjectAndEnv = createAsyncThunk<
|
||||
{ key: string; data: ProjectUiControlSettingsEntity },
|
||||
{
|
||||
projectId: string;
|
||||
environment: 'dev' | 'stage' | 'production';
|
||||
data: Partial<ProjectUiControlSettingsEntity>;
|
||||
},
|
||||
{ rejectValue: ApiError }
|
||||
>(
|
||||
'project_ui_control_settings/upsertByProjectAndEnv',
|
||||
async ({ projectId, environment, data }, { rejectWithValue }) => {
|
||||
const key = buildKey(projectId, environment);
|
||||
try {
|
||||
const result = await axios.put<ProjectUiControlSettingsEntity>(
|
||||
`project-ui-control-settings/project/${projectId}/env/${environment}`,
|
||||
{ data },
|
||||
);
|
||||
return { key, data: result.data };
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
return rejectWithValue(error.response.data as ApiError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const deleteByProjectAndEnv = createAsyncThunk<
|
||||
{ key: string },
|
||||
{ projectId: string; environment: 'dev' | 'stage' | 'production' },
|
||||
{ rejectValue: ApiError }
|
||||
>(
|
||||
'project_ui_control_settings/deleteByProjectAndEnv',
|
||||
async ({ projectId, environment }, { rejectWithValue }) => {
|
||||
const key = buildKey(projectId, environment);
|
||||
try {
|
||||
await axios.delete(
|
||||
`project-ui-control-settings/project/${projectId}/env/${environment}`,
|
||||
);
|
||||
return { key };
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
return rejectWithValue(error.response.data as ApiError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const projectUiControlSettingsSlice = createSlice({
|
||||
name: 'project_ui_control_settings',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearState: (state) => {
|
||||
state.byProjectEnv = {};
|
||||
state.loadingKeys = {};
|
||||
},
|
||||
clearForProject: (state, action: PayloadAction<string>) => {
|
||||
const projectId = action.payload;
|
||||
Object.keys(state.byProjectEnv).forEach((key) => {
|
||||
if (key.startsWith(`${projectId}:`)) {
|
||||
delete state.byProjectEnv[key];
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
const setPending = (
|
||||
state: ProjectUiControlSettingsState,
|
||||
projectId: string,
|
||||
environment: string,
|
||||
) => {
|
||||
const key = buildKey(projectId, environment);
|
||||
state.loadingKeys[key] = true;
|
||||
state.loading = true;
|
||||
resetNotify(state);
|
||||
};
|
||||
const clearLoading = (
|
||||
state: ProjectUiControlSettingsState,
|
||||
key: string,
|
||||
) => {
|
||||
delete state.loadingKeys[key];
|
||||
state.loading = Object.keys(state.loadingKeys).length > 0;
|
||||
};
|
||||
|
||||
builder.addCase(fetchByProjectAndEnv.pending, (state, action) => {
|
||||
setPending(state, action.meta.arg.projectId, action.meta.arg.environment);
|
||||
});
|
||||
builder.addCase(fetchByProjectAndEnv.fulfilled, (state, action) => {
|
||||
const { key, data } = action.payload;
|
||||
state.byProjectEnv[key] = data;
|
||||
clearLoading(state, key);
|
||||
});
|
||||
builder.addCase(fetchByProjectAndEnv.rejected, (state, action) => {
|
||||
const key = buildKey(
|
||||
action.meta.arg.projectId,
|
||||
action.meta.arg.environment,
|
||||
);
|
||||
clearLoading(state, key);
|
||||
rejectNotify(state, action);
|
||||
});
|
||||
builder.addCase(upsertByProjectAndEnv.pending, (state, action) => {
|
||||
setPending(state, action.meta.arg.projectId, action.meta.arg.environment);
|
||||
});
|
||||
builder.addCase(upsertByProjectAndEnv.fulfilled, (state, action) => {
|
||||
const { key, data } = action.payload;
|
||||
state.byProjectEnv[key] = data;
|
||||
clearLoading(state, key);
|
||||
fulfilledNotify(state, 'Project UI controls settings saved');
|
||||
});
|
||||
builder.addCase(upsertByProjectAndEnv.rejected, (state, action) => {
|
||||
const key = buildKey(
|
||||
action.meta.arg.projectId,
|
||||
action.meta.arg.environment,
|
||||
);
|
||||
clearLoading(state, key);
|
||||
rejectNotify(state, action);
|
||||
});
|
||||
builder.addCase(deleteByProjectAndEnv.pending, (state, action) => {
|
||||
setPending(state, action.meta.arg.projectId, action.meta.arg.environment);
|
||||
});
|
||||
builder.addCase(deleteByProjectAndEnv.fulfilled, (state, action) => {
|
||||
const { key } = action.payload;
|
||||
state.byProjectEnv[key] = null;
|
||||
clearLoading(state, key);
|
||||
fulfilledNotify(state, 'Project UI controls settings cleared');
|
||||
});
|
||||
builder.addCase(deleteByProjectAndEnv.rejected, (state, action) => {
|
||||
const key = buildKey(
|
||||
action.meta.arg.projectId,
|
||||
action.meta.arg.environment,
|
||||
);
|
||||
clearLoading(state, key);
|
||||
rejectNotify(state, action);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearState, clearForProject } =
|
||||
projectUiControlSettingsSlice.actions;
|
||||
|
||||
export const selectByProjectAndEnv = (
|
||||
state: { project_ui_control_settings: ProjectUiControlSettingsState },
|
||||
projectId: string,
|
||||
environment: 'dev' | 'stage' | 'production',
|
||||
): ProjectUiControlSettingsEntity | null | undefined => {
|
||||
const key = buildKey(projectId, environment);
|
||||
return state.project_ui_control_settings.byProjectEnv[key];
|
||||
};
|
||||
|
||||
export const selectIsLoading = (
|
||||
state: { project_ui_control_settings: ProjectUiControlSettingsState },
|
||||
projectId: string,
|
||||
environment: 'dev' | 'stage' | 'production',
|
||||
): boolean => {
|
||||
const key = buildKey(projectId, environment);
|
||||
return state.project_ui_control_settings.loadingKeys[key] ?? false;
|
||||
};
|
||||
|
||||
export default projectUiControlSettingsSlice.reducer;
|
||||
@ -20,6 +20,8 @@ import pwa_cachesSlice from './pwa_caches/pwa_cachesSlice';
|
||||
import access_logsSlice from './access_logs/access_logsSlice';
|
||||
import globalTransitionDefaultsSlice from './global_transition_defaults/globalTransitionDefaultsSlice';
|
||||
import projectTransitionSettingsSlice from './project_transition_settings/projectTransitionSettingsSlice';
|
||||
import globalUiControlDefaultsSlice from './global_ui_control_defaults/globalUiControlDefaultsSlice';
|
||||
import projectUiControlSettingsSlice from './project_ui_control_settings/projectUiControlSettingsSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@ -44,6 +46,8 @@ export const store = configureStore({
|
||||
access_logs: access_logsSlice,
|
||||
global_transition_defaults: globalTransitionDefaultsSlice,
|
||||
project_transition_settings: projectTransitionSettingsSlice,
|
||||
global_ui_control_defaults: globalUiControlDefaultsSlice,
|
||||
project_ui_control_settings: projectUiControlSettingsSlice,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -6,6 +6,11 @@ import {
|
||||
GlobalTransitionDefaults,
|
||||
ProjectTransitionSettingsEntity,
|
||||
} from './transition';
|
||||
import {
|
||||
GlobalUiControlDefaults,
|
||||
ProjectUiControlSettingsEntity,
|
||||
UiControlsSettings,
|
||||
} from './uiControls';
|
||||
|
||||
// Base entity interface that all entities extend
|
||||
export interface BaseEntity {
|
||||
@ -53,6 +58,7 @@ export interface Project extends BaseEntity {
|
||||
design_width?: number;
|
||||
design_height?: number;
|
||||
production_presentation_visibility?: 'public' | 'private';
|
||||
global_ui_controls_settings_json?: UiControlsSettings | string | null;
|
||||
// Note: transition_settings is now stored in project_transition_settings table
|
||||
// with environment awareness (dev, stage, production)
|
||||
is_deleted?: boolean;
|
||||
@ -118,6 +124,7 @@ export interface TourPage extends BaseEntity {
|
||||
source_key?: string;
|
||||
requires_auth?: boolean;
|
||||
ui_schema_json?: string;
|
||||
global_ui_controls_settings_json?: UiControlsSettings | string | null;
|
||||
// Background URL fields (direct storage paths)
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
@ -307,12 +314,14 @@ export type EntityName =
|
||||
| 'tour_pages'
|
||||
| 'project_audio_tracks'
|
||||
| 'project_transition_settings'
|
||||
| 'project_ui_control_settings'
|
||||
| 'publish_events'
|
||||
| 'pwa_caches'
|
||||
| 'presigned_url_requests'
|
||||
| 'access_logs'
|
||||
| 'element_type_defaults'
|
||||
| 'global_transition_defaults';
|
||||
| 'global_transition_defaults'
|
||||
| 'global_ui_control_defaults';
|
||||
|
||||
// Entity type map for generic lookups
|
||||
export interface EntityTypeMap {
|
||||
@ -326,10 +335,12 @@ export interface EntityTypeMap {
|
||||
tour_pages: TourPage;
|
||||
project_audio_tracks: ProjectAudioTrack;
|
||||
project_transition_settings: ProjectTransitionSettingsEntity;
|
||||
project_ui_control_settings: ProjectUiControlSettingsEntity;
|
||||
publish_events: PublishEvent;
|
||||
pwa_caches: PwaCache;
|
||||
presigned_url_requests: PresignedUrlRequest;
|
||||
access_logs: AccessLog;
|
||||
element_type_defaults: UIElement;
|
||||
global_transition_defaults: GlobalTransitionDefaults;
|
||||
global_ui_control_defaults: GlobalUiControlDefaults;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { PreloadPage, PreloadPageLink, PreloadElement } from './preload';
|
||||
import type { UiControlsSettings } from './uiControls';
|
||||
|
||||
/**
|
||||
* Runtime project data
|
||||
@ -21,6 +22,7 @@ export interface RuntimeProject {
|
||||
og_image_url?: string;
|
||||
design_width?: number;
|
||||
design_height?: number;
|
||||
global_ui_controls_settings_json?: UiControlsSettings | string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,6 +33,7 @@ export interface RuntimePage extends PreloadPage {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
ui_schema_json?: string;
|
||||
global_ui_controls_settings_json?: UiControlsSettings | string | null;
|
||||
environment?: 'dev' | 'stage' | 'production';
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
|
||||
256
frontend/src/types/uiControls.ts
Normal file
256
frontend/src/types/uiControls.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import type { BaseEntity } from './entities';
|
||||
|
||||
export type SystemUiControlType = 'fullscreen' | 'sound' | 'offline';
|
||||
export type SystemUiControlAnchor =
|
||||
| 'center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
export interface SystemUiControlSettings {
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
xPercent?: number;
|
||||
yPercent?: number;
|
||||
anchor?: SystemUiControlAnchor;
|
||||
buttonSizePercent?: number;
|
||||
iconSizePercent?: number;
|
||||
defaultIconUrl?: string;
|
||||
activeIconUrl?: string;
|
||||
defaultBackgroundColor?: string;
|
||||
activeBackgroundColor?: string;
|
||||
hoverBackgroundColor?: string;
|
||||
color?: string;
|
||||
defaultBorderColor?: string;
|
||||
activeBorderColor?: string;
|
||||
borderRadiusPercent?: number;
|
||||
opacity?: number;
|
||||
boxShadow?: string;
|
||||
zIndex?: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type UiControlsSettings = Partial<
|
||||
Record<SystemUiControlType, SystemUiControlSettings>
|
||||
>;
|
||||
|
||||
export type ResolvedSystemUiControlSettings = Required<
|
||||
Omit<
|
||||
SystemUiControlSettings,
|
||||
'defaultIconUrl' | 'activeIconUrl' | 'boxShadow'
|
||||
>
|
||||
> & {
|
||||
defaultIconUrl: string;
|
||||
activeIconUrl: string;
|
||||
boxShadow: string;
|
||||
};
|
||||
|
||||
export type ResolvedUiControlsSettings = Record<
|
||||
SystemUiControlType,
|
||||
ResolvedSystemUiControlSettings
|
||||
>;
|
||||
|
||||
export interface GlobalUiControlDefaults extends BaseEntity {
|
||||
settings_json: UiControlsSettings;
|
||||
}
|
||||
|
||||
export interface ProjectUiControlSettingsEntity extends BaseEntity {
|
||||
projectId: string;
|
||||
environment: 'dev' | 'stage' | 'production';
|
||||
source_key?: string;
|
||||
settings_json: UiControlsSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_UI_CONTROL_SETTINGS: ResolvedUiControlsSettings = {
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
const CONTROL_TYPES: SystemUiControlType[] = ['offline', 'fullscreen', 'sound'];
|
||||
|
||||
const hasValue = (value: unknown) => value !== undefined && value !== null;
|
||||
|
||||
const mergeControl = (
|
||||
type: SystemUiControlType,
|
||||
...sources: Array<UiControlsSettings | null | undefined>
|
||||
): ResolvedSystemUiControlSettings => {
|
||||
const base = DEFAULT_UI_CONTROL_SETTINGS[type];
|
||||
const merged: ResolvedSystemUiControlSettings = { ...base };
|
||||
|
||||
sources.forEach((source) => {
|
||||
const settings = source?.[type];
|
||||
if (!settings) return;
|
||||
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
if (hasValue(value)) {
|
||||
(merged as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
export const resolveUiControlsSettings = (
|
||||
globalSettings?: UiControlsSettings | null,
|
||||
projectSettings?: UiControlsSettings | null,
|
||||
pageSettings?: UiControlsSettings | null,
|
||||
): ResolvedUiControlsSettings => {
|
||||
return CONTROL_TYPES.reduce((acc, type) => {
|
||||
acc[type] = mergeControl(
|
||||
type,
|
||||
globalSettings,
|
||||
projectSettings,
|
||||
pageSettings,
|
||||
);
|
||||
return acc;
|
||||
}, {} as ResolvedUiControlsSettings);
|
||||
};
|
||||
|
||||
export const parseUiControlsSettings = (
|
||||
value: unknown,
|
||||
): UiControlsSettings | null => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? (parsed as UiControlsSettings)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof value === 'object' ? (value as UiControlsSettings) : null;
|
||||
};
|
||||
|
||||
export const getAnchorTransform = (anchor: SystemUiControlAnchor): string => {
|
||||
switch (anchor) {
|
||||
case 'top-left':
|
||||
return 'translate(0, 0)';
|
||||
case 'top-right':
|
||||
return 'translate(-100%, 0)';
|
||||
case 'bottom-left':
|
||||
return 'translate(0, -100%)';
|
||||
case 'bottom-right':
|
||||
return 'translate(-100%, -100%)';
|
||||
case 'center':
|
||||
default:
|
||||
return 'translate(-50%, -50%)';
|
||||
}
|
||||
};
|
||||
|
||||
export const getSystemControlAnchorBounds = (
|
||||
anchor: SystemUiControlAnchor,
|
||||
buttonSizePercent: number,
|
||||
canvasAspectRatio = 1,
|
||||
) => {
|
||||
const buttonHeightPercent = buttonSizePercent * canvasAspectRatio;
|
||||
|
||||
switch (anchor) {
|
||||
case 'top-left':
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: 100 - buttonSizePercent,
|
||||
minY: 0,
|
||||
maxY: 100 - buttonHeightPercent,
|
||||
};
|
||||
case 'top-right':
|
||||
return {
|
||||
minX: buttonSizePercent,
|
||||
maxX: 100,
|
||||
minY: 0,
|
||||
maxY: 100 - buttonHeightPercent,
|
||||
};
|
||||
case 'bottom-left':
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: 100 - buttonSizePercent,
|
||||
minY: buttonHeightPercent,
|
||||
maxY: 100,
|
||||
};
|
||||
case 'bottom-right':
|
||||
return {
|
||||
minX: buttonSizePercent,
|
||||
maxX: 100,
|
||||
minY: buttonHeightPercent,
|
||||
maxY: 100,
|
||||
};
|
||||
case 'center':
|
||||
default: {
|
||||
const halfWidth = buttonSizePercent / 2;
|
||||
const halfHeight = buttonHeightPercent / 2;
|
||||
return {
|
||||
minX: halfWidth,
|
||||
maxX: 100 - halfWidth,
|
||||
minY: halfHeight,
|
||||
maxY: 100 - halfHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user