Added ability to customize global actions buttons (fullscreen, offline, mute)

This commit is contained in:
Dmitri 2026-06-28 15:04:07 +02:00
parent e5ad49d07b
commit 63909ef66a
50 changed files with 3301 additions and 436 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -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',
]);
/**

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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