offline mode improved

This commit is contained in:
Dmitri 2026-04-05 18:46:16 +04:00
parent cef7c80d8f
commit 4bf9339a7f
197 changed files with 5208 additions and 3971 deletions

View File

@ -36,8 +36,6 @@ fetchAndCachePublicRole().catch((error) => {
'Critical error during permissions middleware initialization:',
error,
);
// Decide here if the process should exit if the Public role is essential.
// process.exit(1);
});
/**

View File

@ -222,13 +222,13 @@ const uploadLimiter = createRateLimiter({
});
/**
* Download limiter - More permissive limits for file downloads
* 200 requests per minute per IP (supports asset preloading)
* Download limiter - Permissive limits for file downloads
* 1000 requests per minute per IP (supports offline mode downloading full presentations)
*/
const downloadLimiter = createRateLimiter({
keyPrefix: 'download',
windowMs: 60 * 1000, // 1 minute
max: 200,
max: 1000,
message: 'Too many download requests. Please slow down.',
skipFailedRequests: true, // Don't penalize for errors
});

View File

@ -1,106 +1,23 @@
const express = require('express');
const { createEntityRouter, isUuidV4 } = require('../factories/router.factory');
const ProjectsService = require('../services/projects');
const ProjectsDBApi = require('../db/api/projects');
const { wrapAsync, isUuidV4 } = require('../helpers');
const { wrapAsync } = require('../helpers');
const router = express.Router();
const { parse } = require('json2csv');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
router.use(checkCrudPermissions('projects'));
/**
* @swagger
* components:
* schemas:
* Projects:
* type: object
* properties:
* name:
* type: string
* default: name
* slug:
* type: string
* default: slug
* description:
* type: string
* default: description
* logo_url:
* type: string
* default: logo_url
* favicon_url:
* type: string
* default: favicon_url
* og_image_url:
* type: string
* default: og_image_url
*
*/
/**
* @swagger
* tags:
* name: Projects
* description: The Projects managing API
*/
/**
* @swagger
* /api/projects:
* post:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Add new item
* description: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Projects"
* responses:
* 200:
* description: The item was successfully added
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*/
router.post(
'/',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await ProjectsService.create(
req.body.data,
req.currentUser,
true,
link.host,
);
res.status(200).send(payload);
}),
);
// Create base router with factory (includes all standard CRUD endpoints)
const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
permissionEntity: 'projects',
csvFields: [
'id',
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
],
});
// Custom endpoint: Clone project
router.post(
'/:id/clone',
wrapAsync(async (req, res) => {
@ -116,410 +33,7 @@ router.post(
}),
);
/**
* @swagger
* /api/budgets/bulk-import:
* post:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Bulk import items
* description: Bulk import items
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated items
* type: array
* items:
* $ref: "#/components/schemas/Projects"
* responses:
* 200:
* description: The items were successfully imported
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*
*/
router.post(
'/bulk-import',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await ProjectsService.bulkImport(req, res, true, link.host);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Update the data of the selected item
* description: Update the data of the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to update
* required: true
* schema:
* type: string
* requestBody:
* description: Set new item data
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* description: ID of the updated item
* type: string
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Projects"
* required:
* - id
* responses:
* 200:
* description: The item data was successfully updated
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.put(
'/:id',
wrapAsync(async (req, res) => {
await ProjectsService.update(req.body.data, req.body.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects/{id}:
* delete:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Delete the selected item
* description: Delete the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to delete
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.delete(
'/:id',
wrapAsync(async (req, res) => {
await ProjectsService.remove(req.params.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects/deleteByIds:
* post:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Delete the selected item list
* description: Delete the selected item list
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* ids:
* description: IDs of the updated items
* type: array
* responses:
* 200:
* description: The items was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Items not found
* 500:
* description: Some server error
*/
router.post(
'/deleteByIds',
wrapAsync(async (req, res) => {
await ProjectsService.deleteByIds(req.body.data, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects:
* get:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Get all projects
* description: Get all projects
* responses:
* 200:
* description: Projects list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/',
wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await ProjectsDBApi.findAll(req.query, {
currentUser,
runtimeContext,
});
if (filetype && filetype === 'csv') {
const fields = [
'id',
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv);
} catch (err) {
console.error(err);
}
} else {
res.status(200).send(payload);
}
}),
);
/**
* @swagger
* /api/projects/count:
* get:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Count all projects
* description: Count all projects
* responses:
* 200:
* description: Projects count successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/count',
wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await ProjectsDBApi.findAll(req.query, {
countOnly: true,
currentUser,
runtimeContext,
});
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects/autocomplete:
* get:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Find all projects that match search criteria
* description: Find all projects that match search criteria
* responses:
* 200:
* description: Projects list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Projects"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get('/autocomplete', async (req, res) => {
const payload = await ProjectsDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
);
res.status(200).send(payload);
});
/**
* @swagger
* /api/projects/{id}:
* get:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Get selected item
* description: Get selected item
* parameters:
* - in: path
* name: id
* description: ID of item to get
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Projects"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.get(
'/:id',
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid project id');
}
const runtimeContext = req.runtimeContext;
const payload = await ProjectsDBApi.findBy(
{ id: req.params.id },
{ runtimeContext },
);
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/projects/{id}/offline-manifest:
* get:
* security:
* - bearerAuth: []
* tags: [Projects]
* summary: Get offline manifest for PWA download
* description: Returns a manifest of all assets needed to use the project offline
* parameters:
* - in: path
* name: id
* description: Project ID
* required: true
* schema:
* type: string
* - in: query
* name: variant
* description: Device type for variant selection (mobile or desktop)
* schema:
* type: string
* enum: [mobile, desktop]
* default: desktop
* responses:
* 200:
* description: Offline manifest successfully generated
* 400:
* description: Invalid project ID
* 404:
* description: Project not found
* 500:
* description: Server error
*/
// Custom endpoint: Get offline manifest for PWA download
router.get(
'/:id/offline-manifest',
wrapAsync(async (req, res) => {
@ -530,15 +44,19 @@ router.get(
const PWAManifestService = require('../services/pwa_manifest');
const { variant = 'desktop' } = req.query;
// Build base URL for asset proxy endpoints
const protocol = req.protocol;
const host = req.get('host');
const baseUrl = `${protocol}://${host}`;
const manifest = await PWAManifestService.generateManifest(
req.params.id,
variant,
baseUrl,
);
res.status(200).json(manifest);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -19,7 +19,6 @@ const publishHandler = wrapAsync(async (req, res) => {
});
router.post('/', publishHandler);
router.post('/publish', publishHandler);
/**
* @swagger

View File

@ -1,5 +1,6 @@
const express = require('express');
const SearchService = require('../services/search');
const { wrapAsync } = require('../helpers');
const router = express.Router();
@ -32,23 +33,21 @@ router.use(checkCrudPermissions('search'));
* description: Internal server error
*/
router.post('/', async (req, res) => {
const { searchQuery } = req.body;
router.post(
'/',
wrapAsync(async (req, res) => {
const { searchQuery } = req.body;
if (!searchQuery) {
return res.status(400).json({ error: 'Please enter a search query' });
}
if (!searchQuery) {
return res.status(400).json({ error: 'Please enter a search query' });
}
try {
const foundMatches = await SearchService.search(
searchQuery,
req.currentUser,
);
res.json(foundMatches);
} catch (error) {
console.error('Internal Server Error', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
}),
);
module.exports = router;

View File

@ -1,42 +1,13 @@
const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const { validateReadOnlySql } = require('../utils/sqlValidator');
const router = express.Router();
const MAX_SQL_LENGTH = 5000;
const MAX_SQL_ROWS = 1000;
const SQL_TIMEOUT_MS = 5000;
const validateReadOnlyQuery = (sql) => {
if (typeof sql !== 'string' || !sql.trim()) {
return 'SQL is required';
}
if (sql.length > MAX_SQL_LENGTH) {
return `SQL is too long (max ${MAX_SQL_LENGTH} characters)`;
}
const normalized = sql.trim().replace(/;+\s*$/, '');
if (!/^(select|with)\b/i.test(normalized)) {
return 'Only SELECT statements are allowed';
}
if (normalized.includes(';')) {
return 'Only a single statement is allowed';
}
if (/--|\/\*/.test(normalized)) {
return 'SQL comments are not allowed';
}
if (/\b(pg_sleep|set_config|copy)\b/i.test(normalized)) {
return 'Restricted SQL function detected';
}
return null;
};
/**
* @swagger
* /api/sql:
@ -84,12 +55,12 @@ router.post(
}
const { sql } = req.body;
const validationError = validateReadOnlyQuery(sql);
if (validationError) {
return res.status(400).json({ error: validationError });
const validation = validateReadOnlySql(sql, { maxLength: MAX_SQL_LENGTH });
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const normalized = sql.trim().replace(/;+\s*$/, '');
const normalized = validation.normalized;
const wrappedSql = `SELECT * FROM (${normalized}) AS query_result LIMIT ${MAX_SQL_ROWS}`;
const rows = await db.sequelize.transaction(async (transaction) => {

View File

@ -1,446 +1,30 @@
const express = require('express');
const { createEntityRouter } = require('../factories/router.factory');
const UsersService = require('../services/users');
const UsersDBApi = require('../db/api/users');
const wrapAsync = require('../helpers').wrapAsync;
const { wrapAsync } = require('../helpers');
const router = express.Router();
const { parse } = require('json2csv');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
router.use(checkCrudPermissions('users'));
/**
* @swagger
* components:
* schemas:
* Users:
* type: object
* properties:
* firstName:
* type: string
* default: firstName
* lastName:
* type: string
* default: lastName
* phoneNumber:
* type: string
* default: phoneNumber
* email:
* type: string
* default: email
*/
/**
* @swagger
* tags:
* name: Users
* description: The Users managing API
*/
/**
* @swagger
* /api/users:
* post:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Add new item
* description: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Users"
* responses:
* 200:
* description: The item was successfully added
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*/
router.post(
'/',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await UsersService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/budgets/bulk-import:
* post:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Bulk import items
* description: Bulk import items
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated items
* type: array
* items:
* $ref: "#/components/schemas/Users"
* responses:
* 200:
* description: The items were successfully imported
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*
*/
router.post(
'/bulk-import',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await UsersService.bulkImport(req, res, true, link.host);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/users/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Update the data of the selected item
* description: Update the data of the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to update
* required: true
* schema:
* type: string
* requestBody:
* description: Set new item data
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* description: ID of the updated item
* type: string
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Users"
* required:
* - id
* responses:
* 200:
* description: The item data was successfully updated
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.put(
'/:id',
wrapAsync(async (req, res) => {
await UsersService.update(req.body.data, req.body.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/users/{id}:
* delete:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Delete the selected item
* description: Delete the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to delete
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.delete(
'/:id',
wrapAsync(async (req, res) => {
await UsersService.remove(req.params.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/users/deleteByIds:
* post:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Delete the selected item list
* description: Delete the selected item list
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* ids:
* description: IDs of the updated items
* type: array
* responses:
* 200:
* description: The items was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Items not found
* 500:
* description: Some server error
*/
router.post(
'/deleteByIds',
wrapAsync(async (req, res) => {
await UsersService.deleteByIds(req.body.data, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/users:
* get:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Get all users
* description: Get all users
* responses:
* 200:
* description: Users list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/',
wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const payload = await UsersDBApi.findAll(req.query, { currentUser });
if (filetype && filetype === 'csv') {
const fields = ['id', 'firstName', 'lastName', 'phoneNumber', 'email'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv);
} catch (err) {
console.error(err);
}
} else {
res.status(200).send(payload);
}
}),
);
/**
* @swagger
* /api/users/count:
* get:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Count all users
* description: Count all users
* responses:
* 200:
* description: Users count successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/count',
wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const payload = await UsersDBApi.findAll(req.query, null, {
countOnly: true,
currentUser,
});
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/users/autocomplete:
* get:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Find all users that match search criteria
* description: Find all users that match search criteria
* responses:
* 200:
* description: Users list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Users"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get('/autocomplete', async (req, res) => {
const payload = await UsersDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
);
res.status(200).send(payload);
// Create base router with factory (includes all standard CRUD endpoints)
const router = createEntityRouter('users', UsersService, UsersDBApi, {
permissionEntity: 'users',
csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'],
});
/**
* @swagger
* /api/users/{id}:
* get:
* security:
* - bearerAuth: []
* tags: [Users]
* summary: Get selected item
* description: Get selected item
* parameters:
* - in: path
* name: id
* description: ID of item to get
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Users"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.get(
'/:id',
wrapAsync(async (req, res) => {
const payload = await UsersDBApi.findBy({ id: req.params.id });
delete payload.password;
res.status(200).send(payload);
}),
// Override GET /:id to remove password from response
// Note: This needs to be added BEFORE the router is exported
// The factory already registered this route, so we add middleware to sanitize
const originalGetById = router.stack.find(
(layer) => layer.route?.path === '/:id' && layer.route?.methods?.get
);
router.use('/', require('../helpers').commonErrorHandler);
if (originalGetById) {
originalGetById.route.stack[0].handle = wrapAsync(async (req, res) => {
// Call original handler with a custom response
const payload = await UsersDBApi.findBy({ id: req.params.id });
if (payload) {
delete payload.password;
}
res.status(200).send(payload);
});
}
module.exports = router;

View File

@ -1,11 +1,21 @@
const db = require('../db/models');
const ProjectsDBApi = require('../db/api/projects');
const processFile = require('../middlewares/upload');
const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class ProjectsService {
// Generate base service from factory
const BaseProjectsService = createEntityService(ProjectsDBApi, {
entityName: 'Projects',
});
/**
* Projects service with slug validation and cloning functionality
* Extends factory-generated service with custom project logic
*/
class ProjectsService extends BaseProjectsService {
/**
* Normalize slug to URL-safe format
*/
static normalizeSlug(value) {
return (
String(value || 'project')
@ -16,13 +26,15 @@ module.exports = class ProjectsService {
);
}
/**
* Generate unique slug for cloning
*/
static async generateUniqueSlug(baseSlug, transaction) {
const normalizedBase = ProjectsService.normalizeSlug(baseSlug);
let counter = 0;
let hasUniqueSlug = false;
let uniqueSlug = '';
let uniqueSlug = null;
while (!hasUniqueSlug) {
while (uniqueSlug === null) {
const suffix = counter === 0 ? '-copy' : `-copy-${counter + 1}`;
const candidate = `${normalizedBase}${suffix}`;
@ -34,7 +46,6 @@ module.exports = class ProjectsService {
if (!existing) {
uniqueSlug = candidate;
hasUniqueSlug = true;
} else {
counter += 1;
}
@ -44,12 +55,7 @@ module.exports = class ProjectsService {
}
/**
* Validate slug uniqueness before create/update
* @param {string} slug - Slug to validate
* @param {string|null} excludeId - Project ID to exclude (for updates)
* @param {Transaction} transaction - DB transaction
* @throws {ValidationError} if slug already exists
* @returns {string} Normalized slug
* Validate slug uniqueness
*/
static async validateSlugUniqueness(slug, excludeId, transaction) {
const normalizedSlug = ProjectsService.normalizeSlug(slug);
@ -72,10 +78,12 @@ module.exports = class ProjectsService {
return normalizedSlug;
}
/**
* Create project with slug validation
*/
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
// Validate slug uniqueness if provided
if (data.slug) {
data.slug = await ProjectsService.validateSlugUniqueness(
data.slug,
@ -84,19 +92,55 @@ module.exports = class ProjectsService {
);
}
const createdProject = await ProjectsDBApi.create(data, {
const project = await ProjectsDBApi.create(data, {
currentUser,
transaction,
});
await transaction.commit();
return createdProject;
return project;
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* Update project with slug validation
*/
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const project = await ProjectsDBApi.findBy({ id }, { transaction });
if (!project) {
throw new ValidationError('projectsNotFound');
}
if (data.slug && data.slug !== project.slug) {
data.slug = await ProjectsService.validateSlugUniqueness(
data.slug,
id,
transaction,
);
}
const updated = await ProjectsDBApi.update(id, data, {
currentUser,
transaction,
});
await transaction.commit();
return updated;
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* Clone project with all assets
*/
static async cloneFromProject(sourceProjectId, currentUser) {
const transaction = await db.sequelize.transaction();
@ -137,12 +181,10 @@ module.exports = class ProjectsService {
favicon_url: sourceProject.favicon_url,
og_image_url: sourceProject.og_image_url,
},
{
currentUser,
transaction,
},
{ currentUser, transaction },
);
// Clone assets and variants
for (const sourceAsset of sourceProject.assets_project || []) {
const clonedAsset = await db.assets.create(
{
@ -189,102 +231,6 @@ module.exports = class ProjectsService {
throw error;
}
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
});
await ProjectsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let projects = await ProjectsDBApi.findBy({ id }, { transaction });
if (!projects) {
throw new ValidationError('projectsNotFound');
}
// Validate slug uniqueness if slug is being changed
if (data.slug && data.slug !== projects.slug) {
data.slug = await ProjectsService.validateSlugUniqueness(
data.slug,
id,
transaction,
);
}
const updatedProjects = await ProjectsDBApi.update(id, data, {
currentUser,
transaction,
});
await transaction.commit();
return updatedProjects;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await ProjectsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await ProjectsDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};
module.exports = ProjectsService;

View File

@ -94,9 +94,10 @@ class PWAManifestService {
* Generate offline manifest for a project
* @param {string} projectId - Project ID
* @param {string} deviceType - 'mobile' or 'desktop' (affects variant selection)
* @param {string} baseUrl - Base URL for proxy endpoints (e.g., 'http://localhost:8080')
* @returns {Object} Offline manifest
*/
static async generateManifest(projectId, deviceType = 'desktop') {
static async generateManifest(projectId, deviceType = 'desktop', baseUrl = '') {
// Fetch all project data
const [assetsResult, pagesResult] = await Promise.all([
AssetsDBApi.findAll({ project: projectId }, {}),
@ -116,6 +117,49 @@ class PWAManifestService {
return Math.round(parseFloat(sizeMb) * 1024 * 1024);
};
// Helper to convert URL to proxy URL (avoids CORS issues with S3)
// Extracts storage key from full URLs for backend proxy
const toProxyUrl = (url) => {
if (!url) return url;
let storageKey = url;
// If it's a full S3 URL, extract the path after the bucket/hash
if (url.includes('.s3.') || url.includes('s3.amazonaws.com')) {
// URL format: https://bucket.s3.amazonaws.com/hash/path/to/file
// or: https://s3.region.amazonaws.com/bucket/hash/path/to/file
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
// Skip the hash prefix (first path segment after bucket)
if (pathParts.length > 1) {
storageKey = pathParts.slice(1).join('/');
} else {
storageKey = pathParts.join('/');
}
} catch {
// If URL parsing fails, use as-is
}
} else if (url.includes('storage.googleapis.com')) {
// GCloud URL format: https://storage.googleapis.com/bucket/hash/path/to/file
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
// Skip bucket and hash (first two segments)
if (pathParts.length > 2) {
storageKey = pathParts.slice(2).join('/');
}
} catch {
// If URL parsing fails, use as-is
}
} else if (url.startsWith('http')) {
// Other external URL - return as-is (can't proxy)
return url;
}
return `${baseUrl}/api/file/download?privateUrl=${encodeURIComponent(storageKey)}`;
};
// Helper to add an asset to the manifest
const addAsset = (
id,
@ -132,7 +176,7 @@ class PWAManifestService {
manifestAssets.push({
id: id || `url-${Date.now()}-${Math.random().toString(36).slice(2)}`,
url,
url: toProxyUrl(url),
filename: filename || url.split('/').pop() || 'unknown',
variantType: variantType || 'original',
assetType: assetType || getAssetType(mimeType, filename),

View File

@ -6,43 +6,18 @@ const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const { validateReadOnlySql } = require('../utils/sqlValidator');
const WIDGET_SQL_MAX_LENGTH = 5000;
const WIDGET_SQL_MAX_ROWS = 1000;
const WIDGET_SQL_TIMEOUT_MS = 5000;
const validateWidgetSql = (sql) => {
if (typeof sql !== 'string' || !sql.trim()) {
throw new ValidationError('Widget query must be a non-empty SQL string');
const result = validateReadOnlySql(sql, { maxLength: WIDGET_SQL_MAX_LENGTH });
if (!result.valid) {
throw new ValidationError(result.error);
}
if (sql.length > WIDGET_SQL_MAX_LENGTH) {
throw new ValidationError(
`Widget query is too long (max ${WIDGET_SQL_MAX_LENGTH} characters)`,
);
}
const normalized = sql.trim().replace(/;+\s*$/, '');
if (!/^(select|with)\b/i.test(normalized)) {
throw new ValidationError('Widget query must be a SELECT statement');
}
if (normalized.includes(';')) {
throw new ValidationError('Widget query must contain a single statement');
}
if (/--|\/\*/.test(normalized)) {
throw new ValidationError('SQL comments are not allowed in widget queries');
}
if (/\b(pg_sleep|set_config|copy)\b/i.test(normalized)) {
throw new ValidationError(
'Restricted SQL function detected in widget query',
);
}
return normalized;
return result.normalized;
};
const runSafeWidgetQuery = async (sql) => {

View File

@ -1,149 +1,63 @@
const db = require('../db/models');
const UsersDBApi = require('../db/api/users');
const processFile = require('../middlewares/upload');
const { createEntityService } = require('../factories/service.factory');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const config = require('../config');
const stream = require('stream');
const AuthService = require('./auth');
module.exports = class UsersService {
// Generate base service from factory
const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users' });
/**
* Users service with email invitation functionality
* Extends factory-generated service with custom user logic
*/
class UsersService extends BaseUsersService {
/**
* Create user with email validation and optional invitation
*/
static async create(data, currentUser, sendInvitationEmails = true, host) {
let transaction = await db.sequelize.transaction();
const transaction = await db.sequelize.transaction();
const email = data.email;
let email = data.email;
let emailsToInvite = [];
try {
if (email) {
let user = await UsersDBApi.findBy({ email }, { transaction });
if (user) {
throw new ValidationError('iam.errors.userAlreadyExists');
} else {
await UsersDBApi.create(
{ data },
{
currentUser,
transaction,
},
);
emailsToInvite.push(email);
}
} else {
if (!email) {
throw new ValidationError('iam.errors.emailRequired');
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
if (emailsToInvite && emailsToInvite.length) {
if (!sendInvitationEmails) return;
AuthService.sendPasswordResetEmail(email, 'invitation', host);
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction();
let emailsToInvite = [];
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => {
console.log('results csv', results);
resolve();
})
.on('error', (error) => reject(error));
});
const hasAllEmails = results.every((result) => result.email);
if (!hasAllEmails) {
throw new ValidationError('importer.errors.userEmailMissing');
const existingUser = await UsersDBApi.findBy({ email }, { transaction });
if (existingUser) {
throw new ValidationError('iam.errors.userAlreadyExists');
}
await UsersDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
emailsToInvite = results.map((result) => result.email);
await UsersDBApi.create({ data }, { currentUser, transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) {
emailsToInvite.forEach((email) => {
// Send invitation email after successful commit
if (sendInvitationEmails) {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let users = await UsersDBApi.findBy({ id }, { transaction });
if (!users) {
throw new ValidationError('iam.errors.userNotFound');
}
const updatedUser = await UsersDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedUser;
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* Remove user with self-deletion and permission checks
*/
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
if (currentUser.id === id) {
throw new ValidationError('iam.errors.deletingHimself');
}
if (currentUser.app_role?.name !== config.roles.admin) {
throw new ValidationError('errors.forbidden.message');
}
await UsersDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
if (currentUser.id === id) {
throw new ValidationError('iam.errors.deletingHimself');
}
if (currentUser.app_role?.name !== config.roles.admin) {
throw new ValidationError('errors.forbidden.message');
}
// Delegate to parent (factory) implementation
return super.remove(id, currentUser);
}
};
}
module.exports = UsersService;

View File

@ -0,0 +1,53 @@
/**
* SQL Validator
*
* Shared validation for read-only SQL queries (widgets, admin SQL executor).
* Ensures queries are SELECT-only and don't contain dangerous patterns.
*/
const DEFAULT_MAX_LENGTH = 5000;
const RESTRICTED_FUNCTIONS = /\b(pg_sleep|set_config|copy)\b/i;
/**
* Validate a SQL query for read-only execution
* @param {string} sql - The SQL query to validate
* @param {object} options - Validation options
* @param {number} [options.maxLength=5000] - Maximum allowed query length
* @returns {{ valid: boolean, error?: string, normalized?: string }}
*/
function validateReadOnlySql(sql, options = {}) {
const maxLength = options.maxLength || DEFAULT_MAX_LENGTH;
if (typeof sql !== 'string' || !sql.trim()) {
return { valid: false, error: 'SQL query must be a non-empty string' };
}
if (sql.length > maxLength) {
return {
valid: false,
error: `SQL query is too long (max ${maxLength} characters)`,
};
}
const normalized = sql.trim().replace(/;+\s*$/, '');
if (!/^(select|with)\b/i.test(normalized)) {
return { valid: false, error: 'Only SELECT statements are allowed' };
}
if (normalized.includes(';')) {
return { valid: false, error: 'Only a single statement is allowed' };
}
if (/--|\/\*/.test(normalized)) {
return { valid: false, error: 'SQL comments are not allowed' };
}
if (RESTRICTED_FUNCTIONS.test(normalized)) {
return { valid: false, error: 'Restricted SQL function detected' };
}
return { valid: true, normalized };
}
module.exports = { validateReadOnlySql };

View File

@ -18,6 +18,7 @@
"@reduxjs/toolkit": "^2.1.0",
"@serwist/next": "^9.5.7",
"@tailwindcss/typography": "^0.5.13",
"@tanstack/react-query": "^5.96.2",
"apexcharts": "^5.0.0",
"axios": "^1.8.4",
"chart.js": "^4.4.1",
@ -54,7 +55,6 @@
"react-select-async-paginate": "^0.7.11",
"react-switch": "^7.0.0",
"react-toastify": "^11.0.2",
"swr": "^2.0.0",
"uuid": "^9.0.0",
"zod": "^4.3.6"
},

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import type { ColorButtonKey } from './interfaces';
import type { ColorButtonKey } from './types/ui';
export const gradientBgBase = 'bg-gradient-to-tr';
export const colorBgBase = 'bg-violet-50/50';

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { AccessLog } from '../../types/entities';
type Props = {
access_logs: any[];
access_logs: AccessLog[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { AccessLog } from '../../types/entities';
type Props = {
access_logs: any[];
access_logs: AccessLog[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { MenuAsideItem } from '../interfaces';
import { MenuAsideItem } from '../types/menu';
import AsideMenuLayer from './AsideMenuLayer';
import OverlayLayer from './OverlayLayer';

View File

@ -4,7 +4,7 @@ import BaseIcon from './BaseIcon';
import Link from 'next/link';
import { getButtonColor } from '../colors';
import AsideMenuList from './AsideMenuList';
import { MenuAsideItem } from '../interfaces';
import { MenuAsideItem } from '../types/menu';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';

View File

@ -2,7 +2,7 @@ import React from 'react';
import { mdiLogout, mdiClose } from '@mdi/js';
import BaseIcon from './BaseIcon';
import AsideMenuList from './AsideMenuList';
import { MenuAsideItem } from '../interfaces';
import { MenuAsideItem } from '../types/menu';
import { useAppSelector } from '../stores/hooks';
import Link from 'next/link';

View File

@ -1,5 +1,5 @@
import React from 'react';
import { MenuAsideItem } from '../interfaces';
import { MenuAsideItem } from '../types/menu';
import AsideMenuItem from './AsideMenuItem';
import { useAppSelector } from '../stores/hooks';
import { hasPermission } from '../helpers/userPermissions';

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { AssetVariant } from '../../types/entities';
type Props = {
asset_variants: any[];
asset_variants: AssetVariant[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { AssetVariant } from '../../types/entities';
type Props = {
asset_variants: any[];
asset_variants: AssetVariant[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Asset } from '../../types/entities';
type Props = {
assets: any[];
assets: Asset[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Asset } from '../../types/entities';
type Props = {
assets: any[];
assets: Asset[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -2,7 +2,7 @@ import React from 'react';
import Link from 'next/link';
import { getButtonColor } from '../colors';
import BaseIcon from './BaseIcon';
import type { ColorButtonKey } from '../interfaces';
import type { ColorButtonKey } from '../types/ui';
import { useAppSelector } from '../stores/hooks';
type Props = {

View File

@ -1,6 +1,6 @@
import { mdiClose } from '@mdi/js';
import { ReactNode } from 'react';
import type { ColorButtonKey } from '../interfaces';
import type { ColorButtonKey } from '../types/ui';
import BaseButton from './BaseButton';
import BaseButtons from './BaseButtons';
import CardBox from './CardBox';

View File

@ -7,17 +7,9 @@
*/
import React from 'react';
import type { AssetOption } from './types';
import type { AssetOption, VideoPlaybackSettings } from './types';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
export interface VideoPlaybackSettings {
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
startTime?: number | null;
endTime?: number | null;
}
interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio';
value: string;

View File

@ -4,7 +4,7 @@
* Draggable menu panel with actions for adding elements, backgrounds, etc.
*/
import React from 'react';
import React, { forwardRef } from 'react';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import {
@ -19,12 +19,8 @@ import {
mdiExitToApp,
} from '@mdi/js';
import MenuActionButton from './MenuActionButton';
import type {
Position,
EditorMenuItem,
CanvasElementType,
NavigationElementType,
} from './types';
import type { Position, CanvasElementType, NavigationElementType } from './types';
import type { EditorMenuItem } from '../../types/constructor';
interface ConstructorMenuProps {
position: Position;
@ -43,27 +39,32 @@ interface ConstructorMenuProps {
onExit: () => void;
}
const ConstructorMenu: React.FC<ConstructorMenuProps> = ({
position,
isOpen,
allowedNavigationTypes,
isCreatingPage,
isSaving,
isSavingToStage,
onDragStart,
onToggleOpen,
onSelectMenuItem,
onAddElement,
onCreatePage,
onSave,
onSaveToStage,
onExit,
}) => {
return (
<div
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: position.x, top: position.y }}
>
const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
(
{
position,
isOpen,
allowedNavigationTypes,
isCreatingPage,
isSaving,
isSavingToStage,
onDragStart,
onToggleOpen,
onSelectMenuItem,
onAddElement,
onCreatePage,
onSave,
onSaveToStage,
onExit,
},
ref,
) => {
return (
<div
ref={ref}
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: position.x, top: position.y }}
>
<div
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
onMouseDown={onDragStart}
@ -166,7 +167,10 @@ const ConstructorMenu: React.FC<ConstructorMenuProps> = ({
</div>
)}
</div>
);
};
);
},
);
ConstructorMenu.displayName = 'ConstructorMenu';
export default ConstructorMenu;

View File

@ -3,9 +3,23 @@
*
* Renders the element editor sidebar in the constructor.
* Handles element settings, background settings, and transition creation.
*
* Uses ConstructorContext for all state - only receives local UI props.
*/
import React from 'react';
import {
useConstructorContext,
useConstructorElements,
useConstructorBackground,
useConstructorAssets,
useConstructorCollectionOps,
useConstructorDuration,
useConstructorNavigation,
useConstructorTransitionCreation,
useConstructorEditorTab,
useConstructorMenu,
} from '../../context/ConstructorContext';
import {
ElementSettingsTabsCompact,
StyleSettingsSectionCompact,
@ -35,137 +49,33 @@ import {
isMediaElementType,
isVideoPlayerElementType,
} from '../../lib/elementDefaults';
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
} from '../../types/constructor';
import type { CanvasElement } from '../../types/constructor';
type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
type NavigationElementType = 'navigation_next' | 'navigation_prev';
type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
type TourPage = {
id: string;
name?: string;
slug?: string;
sort_order?: number;
};
interface AssetOption {
value: string;
label: string;
}
// ============================================================================
// Props Interface (Local UI props only)
// ============================================================================
interface ElementEditorPanelProps {
// Refs and positioning
/** Ref for outside click detection */
elementEditorRef: React.RefObject<HTMLDivElement | null>;
/** Draggable position */
position: { x: number; y: number };
/** Whether panel is collapsed */
isCollapsed: boolean;
/** Toggle collapse state */
onToggleCollapse: () => void;
/** Start dragging the panel */
onDragStart: (event: React.MouseEvent) => void;
// Editor state
/** Panel title */
title: string;
activeTab: 'general' | 'css' | 'effects';
onTabChange: (tab: 'general' | 'css' | 'effects') => void;
// Selected element
selectedElement: CanvasElement | null;
selectedMenuItem: EditorMenuItem;
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
// Background settings
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundAudioUrl: string;
onBackgroundImageChange: (value: string) => void;
onBackgroundVideoChange: (value: string) => void;
onBackgroundAudioChange: (value: string) => void;
// Background video playback settings
backgroundVideoAutoplay: boolean;
backgroundVideoLoop: boolean;
backgroundVideoMuted: boolean;
backgroundVideoStartTime: number | null;
backgroundVideoEndTime: number | null;
onBackgroundVideoSettingsChange: (settings: {
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
startTime?: number | null;
endTime?: number | null;
}) => void;
// Transition creation
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
isCreatingTransition: boolean;
onNewTransitionNameChange: (value: string) => void;
onNewTransitionVideoUrlChange: (value: string) => void;
onNewTransitionSupportsReverseChange: (value: boolean) => void;
onCreateTransition: () => void;
// Duration notes
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
newTransitionDurationNote: string;
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
// Asset options
backgroundImageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
audioAssetOptions: AssetOption[];
transitionVideoAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
imageAssetOptions: AssetOption[];
// Navigation settings
allowedNavigationTypes: NavigationElementType[];
pages: TourPage[];
activePageId: string;
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Gallery/Carousel operations
galleryCards: {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
};
galleryInfoSpans: {
add: () => void;
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
remove: (spanId: string) => void;
};
carouselSlides: {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
// Navigation type normalization
normalizeNavigationType: (
element: CanvasElement,
nextType: NavigationElementType,
) => CanvasElement;
// Duration resolver
getDuration: (url: string) => number | undefined;
}
// ============================================================================
// CSS Property Handler
// ============================================================================
/**
* Handle CSS property changes with unit conversion
*/
@ -215,6 +125,10 @@ const handleCssPropertyChange = (
}
};
// ============================================================================
// Component
// ============================================================================
export function ElementEditorPanel({
elementEditorRef,
position,
@ -222,53 +136,48 @@ export function ElementEditorPanel({
onToggleCollapse,
onDragStart,
title,
activeTab,
onTabChange,
selectedElement,
selectedMenuItem,
onRemoveElement,
onUpdateElement,
backgroundImageUrl,
backgroundVideoUrl,
backgroundAudioUrl,
onBackgroundImageChange,
onBackgroundVideoChange,
onBackgroundAudioChange,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
onBackgroundVideoSettingsChange,
newTransitionName,
newTransitionVideoUrl,
newTransitionSupportsReverse,
isCreatingTransition,
onNewTransitionNameChange,
onNewTransitionVideoUrlChange,
onNewTransitionSupportsReverseChange,
onCreateTransition,
backgroundVideoDurationNote,
backgroundAudioDurationNote,
newTransitionDurationNote,
selectedMediaDurationNote,
selectedTransitionDurationNote,
backgroundImageAssetOptions,
videoAssetOptions,
audioAssetOptions,
transitionVideoAssetOptions,
iconAssetOptions,
imageAssetOptions,
allowedNavigationTypes,
pages,
activePageId,
onPreviewTransition,
galleryCards,
galleryInfoSpans,
carouselSlides,
normalizeNavigationType,
getDuration,
}: ElementEditorPanelProps) {
// Get state from context
const {
selectedElement,
selectedElementId,
updateSelectedElement,
removeSelectedElement,
} = useConstructorElements();
const { selectedMenuItem } = useConstructorMenu();
const {
pageBackground,
setBackgroundImageUrl,
setBackgroundVideoUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
} = useConstructorBackground();
const { assetOptions } = useConstructorAssets();
const { galleryCards, galleryInfoSpans, carouselSlides } =
useConstructorCollectionOps();
const { getDuration, durationNotes } = useConstructorDuration();
const {
pages,
activePageId,
allowedNavigationTypes,
normalizeNavigationType,
onPreviewTransition,
} = useConstructorNavigation();
const transitionCreation = useConstructorTransitionCreation();
const { activeTab, setActiveTab } = useConstructorEditorTab();
// ============================================================================
// Render
// ============================================================================
return (
<div
ref={elementEditorRef}
@ -280,74 +189,79 @@ export function ElementEditorPanel({
isCollapsed={isCollapsed}
showRemoveButton={Boolean(selectedElement)}
onToggleCollapse={onToggleCollapse}
onRemove={onRemoveElement}
onRemove={removeSelectedElement}
onDragStart={onDragStart}
/>
{!isCollapsed && (
<>
{/* Background Image Settings */}
{selectedMenuItem === 'background_image' && (
<BackgroundSettingsEditor
type='image'
value={backgroundImageUrl}
options={backgroundImageAssetOptions}
value={pageBackground.imageUrl}
options={assetOptions.backgroundImage}
onChange={(value) => {
onBackgroundImageChange(value);
if (value) onBackgroundVideoChange('');
setBackgroundImageUrl(value);
if (value) setBackgroundVideoUrl('');
}}
/>
)}
{/* Background Video Settings */}
{selectedMenuItem === 'background_video' && (
<BackgroundSettingsEditor
type='video'
value={backgroundVideoUrl}
options={videoAssetOptions}
durationNote={backgroundVideoDurationNote}
value={pageBackground.videoUrl}
options={assetOptions.video}
durationNote={durationNotes.backgroundVideo}
onChange={(value) => {
onBackgroundVideoChange(value);
if (value) onBackgroundImageChange('');
setBackgroundVideoUrl(value);
if (value) setBackgroundImageUrl('');
}}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted}
videoStartTime={backgroundVideoStartTime}
videoEndTime={backgroundVideoEndTime}
onVideoSettingsChange={onBackgroundVideoSettingsChange}
videoAutoplay={pageBackground.videoSettings.autoplay}
videoLoop={pageBackground.videoSettings.loop}
videoMuted={pageBackground.videoSettings.muted}
videoStartTime={pageBackground.videoSettings.startTime}
videoEndTime={pageBackground.videoSettings.endTime}
onVideoSettingsChange={setBackgroundVideoSettings}
/>
)}
{/* Background Audio Settings */}
{selectedMenuItem === 'background_audio' && (
<BackgroundSettingsEditor
type='audio'
value={backgroundAudioUrl}
options={audioAssetOptions}
durationNote={backgroundAudioDurationNote}
onChange={onBackgroundAudioChange}
value={pageBackground.audioUrl}
options={assetOptions.audio}
durationNote={durationNotes.backgroundAudio}
onChange={setBackgroundAudioUrl}
/>
)}
{/* Create Transition Form */}
{selectedMenuItem === 'create_transition' && (
<CreateTransitionForm
name={newTransitionName}
videoUrl={newTransitionVideoUrl}
supportsReverse={newTransitionSupportsReverse}
videoOptions={transitionVideoAssetOptions}
durationNote={newTransitionDurationNote}
isCreating={isCreatingTransition}
onNameChange={onNewTransitionNameChange}
onVideoUrlChange={onNewTransitionVideoUrlChange}
onSupportsReverseChange={onNewTransitionSupportsReverseChange}
onSubmit={onCreateTransition}
name={transitionCreation.name}
videoUrl={transitionCreation.videoUrl}
supportsReverse={transitionCreation.supportsReverse}
videoOptions={assetOptions.transitionVideo}
durationNote={durationNotes.newTransition}
isCreating={transitionCreation.isCreating}
onNameChange={transitionCreation.setName}
onVideoUrlChange={transitionCreation.setVideoUrl}
onSupportsReverseChange={transitionCreation.setSupportsReverse}
onSubmit={transitionCreation.create}
/>
)}
{/* Element Settings */}
{selectedElement && (
<>
<ElementSettingsTabsCompact
activeTab={activeTab}
onTabChange={(tab) =>
onTabChange(tab as 'general' | 'css' | 'effects')
setActiveTab(tab as 'general' | 'css' | 'effects')
}
tabs={[
{ id: 'general', label: 'General' },
@ -356,6 +270,7 @@ export function ElementEditorPanel({
]}
/>
{/* General Tab */}
{activeTab === 'general' && (
<>
<CommonSettingsSectionCompact
@ -371,19 +286,20 @@ export function ElementEditorPanel({
showPosition={false}
onChange={(prop, value) => {
if (prop === 'label') {
onUpdateElement({ label: value });
updateSelectedElement({ label: value });
} else if (prop === 'appearDelaySec') {
onUpdateElement({
updateSelectedElement({
appearDelaySec: normalizeAppearDelaySec(value),
});
} else if (prop === 'appearDurationSec') {
onUpdateElement({
updateSelectedElement({
appearDurationSec: normalizeAppearDurationSec(value),
});
}
}}
/>
{/* Navigation Settings */}
{isNavigationElementType(selectedElement.type) && (
<NavigationSettingsSectionCompact
type={
@ -407,35 +323,35 @@ export function ElementEditorPanel({
}
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={iconAssetOptions}
transitionVideoOptions={transitionVideoAssetOptions}
iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo}
pages={pages}
activePageId={activePageId}
selectedMediaDurationNote={selectedMediaDurationNote}
activePageId={activePageId || ''}
selectedMediaDurationNote={durationNotes.selectedMedia}
selectedTransitionDurationNote={
selectedTransitionDurationNote
durationNotes.selectedTransition
}
onChange={(prop, value) => {
if (prop === 'type') {
const nextType = value as NavigationElementType;
onUpdateElement(
updateSelectedElement(
normalizeNavigationType(selectedElement, nextType),
);
} else if (prop === 'transitionVideoUrl') {
const nextVideoUrl = value as string;
const resolvedDuration = getDuration(nextVideoUrl);
onUpdateElement({
updateSelectedElement({
transitionVideoUrl: nextVideoUrl,
transitionDurationSec:
resolvedDuration || undefined,
});
} else if (prop === 'targetPageSlug') {
onUpdateElement({
updateSelectedElement({
targetPageSlug: value as string,
targetPageId: '',
});
} else {
onUpdateElement({
updateSelectedElement({
[prop]: value,
});
}
@ -444,177 +360,174 @@ export function ElementEditorPanel({
/>
)}
{selectedElement &&
isTooltipElementType(selectedElement.type) && (
<TooltipSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
tooltipTitle={selectedElement.tooltipTitle || ''}
tooltipText={selectedElement.tooltipText || ''}
tooltipTitleFontFamily={
selectedElement.tooltipTitleFontFamily || ''
}
tooltipTextFontFamily={
selectedElement.tooltipTextFontFamily || ''
}
iconAssetOptions={iconAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{/* Tooltip Settings */}
{isTooltipElementType(selectedElement.type) && (
<TooltipSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
tooltipTitle={selectedElement.tooltipTitle || ''}
tooltipText={selectedElement.tooltipText || ''}
tooltipTitleFontFamily={
selectedElement.tooltipTitleFontFamily || ''
}
tooltipTextFontFamily={
selectedElement.tooltipTextFontFamily || ''
}
iconAssetOptions={assetOptions.icon}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isDescriptionElementType(selectedElement.type) && (
<DescriptionSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
descriptionTitle={
selectedElement.descriptionTitle || ''
}
descriptionText={selectedElement.descriptionText || ''}
descriptionTitleFontSize={
selectedElement.descriptionTitleFontSize || '48px'
}
descriptionTextFontSize={
selectedElement.descriptionTextFontSize || '36px'
}
descriptionTitleFontFamily={
selectedElement.descriptionTitleFontFamily ||
'inherit'
}
descriptionTextFontFamily={
selectedElement.descriptionTextFontFamily || 'inherit'
}
descriptionTitleColor={
selectedElement.descriptionTitleColor || '#000000'
}
descriptionTextColor={
selectedElement.descriptionTextColor || '#4B5563'
}
iconAssetOptions={iconAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{/* Description Settings */}
{isDescriptionElementType(selectedElement.type) && (
<DescriptionSettingsSectionCompact
iconUrl={selectedElement.iconUrl || ''}
descriptionTitle={selectedElement.descriptionTitle || ''}
descriptionText={selectedElement.descriptionText || ''}
descriptionTitleFontSize={
selectedElement.descriptionTitleFontSize || '48px'
}
descriptionTextFontSize={
selectedElement.descriptionTextFontSize || '36px'
}
descriptionTitleFontFamily={
selectedElement.descriptionTitleFontFamily || 'inherit'
}
descriptionTextFontFamily={
selectedElement.descriptionTextFontFamily || 'inherit'
}
descriptionTitleColor={
selectedElement.descriptionTitleColor || '#000000'
}
descriptionTextColor={
selectedElement.descriptionTextColor || '#4B5563'
}
iconAssetOptions={assetOptions.icon}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isMediaElementType(selectedElement.type) && (
<MediaSettingsSectionCompact
mediaType={
isVideoPlayerElementType(selectedElement.type)
? 'video'
: 'audio'
}
mediaUrl={selectedElement.mediaUrl || ''}
mediaAutoplay={Boolean(selectedElement.mediaAutoplay)}
mediaLoop={Boolean(selectedElement.mediaLoop)}
mediaMuted={Boolean(selectedElement.mediaMuted)}
videoAssetOptions={videoAssetOptions}
audioAssetOptions={audioAssetOptions}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value })
}
/>
)}
{/* Media Settings */}
{isMediaElementType(selectedElement.type) && (
<MediaSettingsSectionCompact
mediaType={
isVideoPlayerElementType(selectedElement.type)
? 'video'
: 'audio'
}
mediaUrl={selectedElement.mediaUrl || ''}
mediaAutoplay={Boolean(selectedElement.mediaAutoplay)}
mediaLoop={Boolean(selectedElement.mediaLoop)}
mediaMuted={Boolean(selectedElement.mediaMuted)}
videoAssetOptions={assetOptions.video}
audioAssetOptions={assetOptions.audio}
onChange={(prop, value) =>
updateSelectedElement({ [prop]: value })
}
/>
)}
{selectedElement &&
isGalleryElementType(selectedElement.type) && (
<>
<GallerySettingsSectionCompact
galleryHeaderImageUrl={
selectedElement.galleryHeaderImageUrl || ''
}
galleryHeaderText={
selectedElement.galleryHeaderText || ''
}
galleryTitle={selectedElement.galleryTitle || ''}
galleryInfoSpans={
selectedElement.galleryInfoSpans || []
}
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={imageAssetOptions}
iconAssetOptions={iconAssetOptions}
onUpdateHeader={(patch) => onUpdateElement(patch)}
onAddInfoSpan={galleryInfoSpans.add}
onUpdateInfoSpan={galleryInfoSpans.update}
onRemoveInfoSpan={galleryInfoSpans.remove}
onAddCard={galleryCards.add}
onUpdateCard={galleryCards.update}
onRemoveCard={galleryCards.remove}
/>
<GalleryCarouselSettingsSectionCompact
prevIconUrl={
selectedElement.galleryCarouselPrevIconUrl || ''
}
nextIconUrl={
selectedElement.galleryCarouselNextIconUrl || ''
}
backIconUrl={
selectedElement.galleryCarouselBackIconUrl || ''
}
backLabel={
selectedElement.galleryCarouselBackLabel || ''
}
prevWidth={
selectedElement.galleryCarouselPrevWidth || ''
}
prevHeight={
selectedElement.galleryCarouselPrevHeight || ''
}
nextWidth={
selectedElement.galleryCarouselNextWidth || ''
}
nextHeight={
selectedElement.galleryCarouselNextHeight || ''
}
backWidth={
selectedElement.galleryCarouselBackWidth || ''
}
backHeight={
selectedElement.galleryCarouselBackHeight || ''
}
iconAssetOptions={iconAssetOptions}
onUpdateElement={onUpdateElement}
/>
</>
)}
{selectedElement &&
isCarouselElementType(selectedElement.type) && (
<CarouselSettingsSectionCompact
carouselSlides={selectedElement.carouselSlides || []}
carouselPrevIconUrl={
selectedElement.carouselPrevIconUrl || ''
{/* Gallery Settings */}
{isGalleryElementType(selectedElement.type) && (
<>
<GallerySettingsSectionCompact
galleryHeaderImageUrl={
selectedElement.galleryHeaderImageUrl || ''
}
carouselNextIconUrl={
selectedElement.carouselNextIconUrl || ''
galleryHeaderText={
selectedElement.galleryHeaderText || ''
}
carouselCaptionFontFamily={
selectedElement.carouselCaptionFontFamily || ''
galleryTitle={selectedElement.galleryTitle || ''}
galleryInfoSpans={
selectedElement.galleryInfoSpans || []
}
carouselFullWidth={
selectedElement.carouselFullWidth || false
}
carouselPrevWidth={
selectedElement.carouselPrevWidth || ''
}
carouselPrevHeight={
selectedElement.carouselPrevHeight || ''
}
carouselNextWidth={
selectedElement.carouselNextWidth || ''
}
carouselNextHeight={
selectedElement.carouselNextHeight || ''
}
iconAssetOptions={iconAssetOptions}
imageAssetOptions={imageAssetOptions}
onUpdateElement={onUpdateElement}
onAddSlide={carouselSlides.add}
onUpdateSlide={carouselSlides.update}
onRemoveSlide={carouselSlides.remove}
galleryCards={selectedElement.galleryCards || []}
imageAssetOptions={assetOptions.image}
iconAssetOptions={assetOptions.icon}
onUpdateHeader={(patch) => updateSelectedElement(patch)}
onAddInfoSpan={galleryInfoSpans.add}
onUpdateInfoSpan={galleryInfoSpans.update}
onRemoveInfoSpan={galleryInfoSpans.remove}
onAddCard={galleryCards.add}
onUpdateCard={galleryCards.update}
onRemoveCard={galleryCards.remove}
/>
)}
<GalleryCarouselSettingsSectionCompact
prevIconUrl={
selectedElement.galleryCarouselPrevIconUrl || ''
}
nextIconUrl={
selectedElement.galleryCarouselNextIconUrl || ''
}
backIconUrl={
selectedElement.galleryCarouselBackIconUrl || ''
}
backLabel={
selectedElement.galleryCarouselBackLabel || ''
}
prevWidth={
selectedElement.galleryCarouselPrevWidth || ''
}
prevHeight={
selectedElement.galleryCarouselPrevHeight || ''
}
nextWidth={
selectedElement.galleryCarouselNextWidth || ''
}
nextHeight={
selectedElement.galleryCarouselNextHeight || ''
}
backWidth={
selectedElement.galleryCarouselBackWidth || ''
}
backHeight={
selectedElement.galleryCarouselBackHeight || ''
}
iconAssetOptions={assetOptions.icon}
onUpdateElement={updateSelectedElement}
/>
</>
)}
{/* Carousel Settings */}
{isCarouselElementType(selectedElement.type) && (
<CarouselSettingsSectionCompact
carouselSlides={selectedElement.carouselSlides || []}
carouselPrevIconUrl={
selectedElement.carouselPrevIconUrl || ''
}
carouselNextIconUrl={
selectedElement.carouselNextIconUrl || ''
}
carouselCaptionFontFamily={
selectedElement.carouselCaptionFontFamily || ''
}
carouselFullWidth={
selectedElement.carouselFullWidth || false
}
carouselPrevWidth={
selectedElement.carouselPrevWidth || ''
}
carouselPrevHeight={
selectedElement.carouselPrevHeight || ''
}
carouselNextWidth={
selectedElement.carouselNextWidth || ''
}
carouselNextHeight={
selectedElement.carouselNextHeight || ''
}
iconAssetOptions={assetOptions.icon}
imageAssetOptions={assetOptions.image}
onUpdateElement={updateSelectedElement}
onAddSlide={carouselSlides.add}
onUpdateSlide={carouselSlides.update}
onRemoveSlide={carouselSlides.remove}
/>
)}
</>
)}
@ -659,7 +572,7 @@ export function ElementEditorPanel({
selectedElement.galleryHeaderTextAlign || 'center',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showDimensions
@ -689,7 +602,7 @@ export function ElementEditorPanel({
selectedElement.galleryTitleTextAlign || 'center',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showTextAlign
@ -723,7 +636,7 @@ export function ElementEditorPanel({
selectedElement.gallerySpanTextAlign || 'center',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
updateSelectedElement({ [prop]: value || undefined })
}
showFont
showGap
@ -762,7 +675,7 @@ export function ElementEditorPanel({
selectedElement.galleryCardMinHeight || '',
}}
onChange={(prop, value) =>
onUpdateElement({ [prop]: value || undefined })
updateSelectedElement({ [prop]: value || undefined })
}
showGap
showColumns
@ -774,43 +687,41 @@ export function ElementEditorPanel({
</p>
</div>
)}
</>
)}
{activeTab === 'css' && (
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(selectedElement.minWidth),
maxWidth: extractNumericValue(selectedElement.maxWidth),
minHeight: extractNumericValue(selectedElement.minHeight),
maxHeight: extractNumericValue(selectedElement.maxHeight),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: selectedElement.gap || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity || '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor: selectedElement.backgroundColor || '',
color: selectedElement.color || '',
}}
onChange={(prop, value) =>
handleCssPropertyChange(prop, value, onUpdateElement)
}
/>
<StyleSettingsSectionCompact
values={{
width: extractNumericValue(selectedElement.width),
height: extractNumericValue(selectedElement.height),
minWidth: extractNumericValue(selectedElement.minWidth),
maxWidth: extractNumericValue(selectedElement.maxWidth),
minHeight: extractNumericValue(selectedElement.minHeight),
maxHeight: extractNumericValue(selectedElement.maxHeight),
margin: selectedElement.margin || '',
padding: selectedElement.padding || '',
gap: selectedElement.gap || '',
fontSize: selectedElement.fontSize || '',
lineHeight: selectedElement.lineHeight || '',
fontWeight: selectedElement.fontWeight || '',
border: extractNumericValue(selectedElement.border),
borderRadius: extractNumericValue(
selectedElement.borderRadius,
),
opacity: selectedElement.opacity || '',
boxShadow: selectedElement.boxShadow || '',
display: selectedElement.display || '',
position: selectedElement.position || '',
justifyContent: selectedElement.justifyContent || '',
alignItems: selectedElement.alignItems || '',
textAlign: selectedElement.textAlign || '',
zIndex: selectedElement.zIndex || '',
backgroundColor: selectedElement.backgroundColor || '',
color: selectedElement.color || '',
}}
onChange={(prop, value) =>
handleCssPropertyChange(prop, value, updateSelectedElement)
}
/>
</>
)}
{/* Effects Tab */}
@ -840,7 +751,7 @@ export function ElementEditorPanel({
selectedElement.activeBackgroundColor || '',
}}
onChange={(prop, value) => {
onUpdateElement({
updateSelectedElement({
[prop]: value || undefined,
});
}}

View File

@ -12,28 +12,21 @@ import type {
GalleryCard,
CarouselSlide,
NavigationButtonKind,
PageBackgroundVideoSettings,
EditorMenuItem,
EditorTab,
} from '../../types/constructor';
/**
* Partial video settings for update callbacks
*/
export type VideoPlaybackSettings = Partial<PageBackgroundVideoSettings>;
/**
* Constructor interaction mode
*/
export type ConstructorInteractionMode = 'edit' | 'interact';
/**
* Editor menu item types
*/
export type EditorMenuItem =
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
/**
* Editor tab types
*/
export type ElementEditorTab = 'general' | 'css' | 'effects';
/**
* Tour page type
*/
@ -173,17 +166,6 @@ export interface ElementEditorHeaderProps {
onDragStart: (event: React.MouseEvent) => void;
}
/**
* Video playback settings for background video
*/
export interface VideoPlaybackSettings {
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
startTime?: number | null;
endTime?: number | null;
}
/**
* Background settings editor props
*/
@ -225,7 +207,7 @@ export interface CreateTransitionFormProps {
export interface ElementEditorPanelProps {
position: Position;
isCollapsed: boolean;
activeTab: ElementEditorTab;
activeTab: EditorTab;
selectedElement: CanvasElement | null;
selectedMenuItem: EditorMenuItem;
@ -273,7 +255,7 @@ export interface ElementEditorPanelProps {
onPositionChange: (position: Position) => void;
onDragStart: (event: React.MouseEvent) => void;
onToggleCollapse: () => void;
onTabChange: (tab: ElementEditorTab) => void;
onTabChange: (tab: EditorTab) => void;
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
onUpdateBackgroundImage: (url: string) => void;

View File

@ -11,20 +11,9 @@ import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
import type { GridColDef } from '@mui/x-data-grid';
import type { AsyncThunk } from '@reduxjs/toolkit';
import type { NotificationState } from '../../types/redux';
import type { EntitySliceState } from '../../types/redux';
import type { BaseEntity } from '../../types/entities';
/**
* Entity slice state shape - matches InternalSliceState from createEntitySlice
*/
interface EntitySliceState<T> {
[key: string]: T[] | boolean | number | NotificationState | unknown[];
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
}
/**
* Configuration for creating a table component
*/

View File

@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { ColorButtonKey } from '../interfaces';
import { ColorButtonKey } from '../types/ui';
import BaseButton from './BaseButton';
import FileUploader from './Uploaders/UploadService';
import { mdiReload } from '@mdi/js';
import { useAppSelector } from '../stores/hooks';
import type { FieldInputProps, FormikProps } from 'formik';
import type { ImageFile } from '../types/entities';
type Props = {
label?: string;
@ -13,8 +15,8 @@ type Props = {
isRoundIcon?: boolean;
path: string;
schema: object;
field: any;
form: any;
field: FieldInputProps<ImageFile[] | null>;
form: FormikProps<Record<string, unknown>>;
};
const FormFilePicker = ({

View File

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { ColorButtonKey } from '../interfaces';
import { ColorButtonKey } from '../types/ui';
import BaseButton from './BaseButton';
import FileUploader from './Uploaders/UploadService';
import { mdiReload } from '@mdi/js';
import { useAppSelector } from '../stores/hooks';
import type { FieldInputProps, FormikProps } from 'formik';
import type { ImageFile } from '../types/entities';
type Props = {
label?: string;
@ -13,8 +15,8 @@ type Props = {
isRoundIcon?: boolean;
path: string;
schema: object;
field: any;
form: any;
field: FieldInputProps<ImageFile[] | null>;
form: FormikProps<Record<string, unknown>>;
};
const FormImagePicker = ({

View File

@ -26,19 +26,10 @@ import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { Field, Form, Formik } from 'formik';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem, FilterFields } from '../../types/filters';
import type { NotificationState } from '../../types/redux';
import type { NotificationState, EntitySliceState } from '../../types/redux';
import type { AsyncThunk } from '@reduxjs/toolkit';
import type { BaseEntity } from '../../types/entities';
// Matches InternalSliceState from createEntitySlice
interface EntitySliceState<T> {
[key: string]: T[] | boolean | number | NotificationState | unknown[];
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
}
// Props interface for GenericTable
interface GenericTableProps<T extends BaseEntity> {
entityName: string;
@ -81,8 +72,8 @@ function GenericTable<T extends BaseEntity>({
const { currentUser } = useAppSelector((state) => state.auth);
const entityState = useAppSelector(sliceSelector);
// Extract state values - rows are stored under the entity name key
const rows = (entityState[entityName] as T[]) || [];
// Extract state values - rows are stored under the 'data' key
const rows = entityState.data || [];
const { count, loading, notify, refetch } = entityState;
// Style selectors

View File

@ -1,5 +1,5 @@
import React from 'react';
import { ColorKey } from '../interfaces';
import { ColorKey } from '../types/ui';
import { colorsBgLight, colorsText } from '../colors';
import BaseIcon from './BaseIcon';

View File

@ -4,7 +4,7 @@ import { containerMaxW } from '../config';
import BaseIcon from './BaseIcon';
import NavBarItemPlain from './NavBarItemPlain';
import NavBarMenuList from './NavBarMenuList';
import { MenuNavBarItem } from '../interfaces';
import { MenuNavBarItem } from '../types/menu';
import { useAppSelector } from '../stores/hooks';
type Props = {

View File

@ -6,7 +6,7 @@ import BaseIcon from './BaseIcon';
import UserAvatarCurrentUser from './UserAvatarCurrentUser';
import NavBarMenuList from './NavBarMenuList';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { MenuNavBarItem } from '../interfaces';
import { MenuNavBarItem } from '../types/menu';
import { setDarkMode } from '../stores/styleSlice';
import { logoutUser } from '../stores/authSlice';
import { useRouter } from 'next/router';

View File

@ -1,5 +1,5 @@
import React from 'react';
import { MenuNavBarItem } from '../interfaces';
import { MenuNavBarItem } from '../types/menu';
import NavBarItem from './NavBarItem';
type Props = {

View File

@ -1,6 +1,6 @@
import { mdiClose } from '@mdi/js';
import React, { ReactNode, useState } from 'react';
import { ColorKey } from '../interfaces';
import { ColorKey } from '../types/ui';
import { colorsBgLight, colorsOutline } from '../colors';
import BaseButton from './BaseButton';
import BaseIcon from './BaseIcon';

View File

@ -5,7 +5,7 @@
* Shows download status, size estimate, and provides download/delete actions.
*/
import React from 'react';
import React, { useEffect, useRef } from 'react';
import {
mdiCloudDownload,
mdiCloudCheck,
@ -13,9 +13,11 @@ import {
mdiDelete,
} from '@mdi/js';
import Icon from '@mdi/react';
import { toast } from 'react-toastify';
import BaseButton from '../BaseButton';
import { useOfflineMode } from '../../hooks/useOfflineMode';
import { useStorageQuota } from '../../hooks/useStorageQuota';
import type { ProjectOfflineStatus } from '../../types/offline';
interface OfflineToggleProps {
projectId: string | null;
@ -55,6 +57,26 @@ export function OfflineToggle({
const { canStore, isWarning, isCritical } = useStorageQuota();
// Track previous status to detect completion transition
const prevStatusRef = useRef<ProjectOfflineStatus>(status);
// Show toast notification when download completes
useEffect(() => {
console.log('[OfflineToggle] Status changed:', {
prev: prevStatusRef.current,
current: status,
});
// Only show toast when transitioning FROM downloading TO downloaded
if (prevStatusRef.current === 'downloading' && status === 'downloaded') {
console.log('[OfflineToggle] Showing toast - download complete!');
toast.success('Presentation ready for offline mode!', {
position: 'bottom-center',
autoClose: 5000,
});
}
prevStatusRef.current = status;
}, [status]);
// Don't render if offline not supported
if (!isOfflineCapable) {
return null;
@ -69,7 +91,8 @@ export function OfflineToggle({
} else if (isDownloading) {
pauseDownload();
} else if (status === 'error') {
resumeDownload();
// Retry by starting fresh download
startDownload();
} else {
// Check storage before starting
if (isCritical) {

View File

@ -10,7 +10,7 @@ import BaseIcon from './BaseIcon';
type Props = {
currentPage: number;
numPages: number;
setCurrentPage: any;
setCurrentPage: (page: number) => void;
};
export const Pagination = ({

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PermissionEntity } from '../../types/entities';
type Props = {
permissions: any[];
permissions: PermissionEntity[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PermissionEntity } from '../../types/entities';
type Props = {
permissions: any[];
permissions: PermissionEntity[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PresignedUrlRequest } from '../../types/entities';
type Props = {
presigned_url_requests: any[];
presigned_url_requests: PresignedUrlRequest[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PresignedUrlRequest } from '../../types/entities';
type Props = {
presigned_url_requests: any[];
presigned_url_requests: PresignedUrlRequest[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { ProjectAudioTrack } from '../../types/entities';
type Props = {
project_audio_tracks: any[];
project_audio_tracks: ProjectAudioTrack[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { ProjectAudioTrack } from '../../types/entities';
type Props = {
project_audio_tracks: any[];
project_audio_tracks: ProjectAudioTrack[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { ProjectMembership } from '../../types/entities';
type Props = {
project_memberships: any[];
project_memberships: ProjectMembership[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { ProjectMembership } from '../../types/entities';
type Props = {
project_memberships: any[];
project_memberships: ProjectMembership[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Project } from '../../types/entities';
type Props = {
projects: any[];
projects: Project[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Project } from '../../types/entities';
type Props = {
projects: any[];
projects: Project[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PublishEvent } from '../../types/entities';
type Props = {
publish_events: any[];
publish_events: PublishEvent[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PublishEvent } from '../../types/entities';
type Props = {
publish_events: any[];
publish_events: PublishEvent[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PwaCache } from '../../types/entities';
type Props = {
pwa_caches: any[];
pwa_caches: PwaCache[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { PwaCache } from '../../types/entities';
type Props = {
pwa_caches: any[];
pwa_caches: PwaCache[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Role } from '../../types/entities';
type Props = {
roles: any[];
roles: Role[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { Role } from '../../types/entities';
type Props = {
roles: any[];
roles: Role[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -15,9 +15,10 @@ import {
hasAnyEffects,
type ElementEffectProperties,
} from '../lib/elementEffects';
import type { CanvasElement } from '../types/constructor';
interface RuntimeElementProps {
element: any;
element: CanvasElement;
onClick: () => void;
/** Optional URL resolver for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;

View File

@ -16,6 +16,8 @@ import React, {
useRef,
useState,
} from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
@ -38,6 +40,7 @@ import {
isTransitionBlocking,
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
interface RuntimePresentationProps {
projectSlug: string;
@ -76,7 +79,7 @@ export default function RuntimePresentation({
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: any;
element: CanvasElement;
initialIndex: number;
} | null>(null);
@ -306,7 +309,7 @@ export default function RuntimePresentation({
);
const handleElementClick = useCallback(
(element: any) => {
(element: CanvasElement) => {
// Block navigation while transition is actively playing or buffering
if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
@ -340,7 +343,7 @@ export default function RuntimePresentation({
// Handler for gallery card clicks
const handleGalleryCardClick = useCallback(
(element: any, cardIndex: number) => {
(element: CanvasElement, cardIndex: number) => {
if (element.galleryCards?.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
@ -542,7 +545,7 @@ export default function RuntimePresentation({
{/* Page elements - z-10 ensures they appear above backdrop layer */}
<div className='absolute inset-0 z-10'>
{pageElements.map((element: any) => (
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
@ -637,6 +640,20 @@ export default function RuntimePresentation({
/>
)}
</BackdropPortalProvider>
{/* Toast notifications for offline download status */}
<ToastContainer
position='bottom-center'
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme='dark'
/>
</div>
</>
);

View File

@ -1,5 +1,5 @@
import React, { ReactNode } from 'react';
import { BgKey } from '../interfaces';
import { BgKey } from '../types/ui';
import {
gradientBgPurplePink,
gradientBgDark,

View File

@ -31,7 +31,7 @@ export const SelectField = ({
setValue(option);
};
async function callApi(inputValue: string, loadedOptions: any[]) {
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {

View File

@ -38,7 +38,7 @@ export const SelectFieldMany = ({
label: data.label,
});
const handleChange = (data: any) => {
const handleChange = (data: Array<{ value: string; label: string }>) => {
setValue(data);
form.setFieldValue(
field.name,
@ -46,7 +46,7 @@ export const SelectFieldMany = ({
);
};
async function callApi(inputValue: string, loadedOptions: any[]) {
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {

View File

@ -1,12 +1,13 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[];
export const ApexAreaChart = ({ widget }) => {
const dataForLineChart = (value: any[]) => {
export const ApexAreaChart = ({ widget }: ChartComponentProps) => {
const dataForLineChart = (value: ChartValueArray) => {
if (!value?.length || value?.length > 10000)
return [{ name: '', data: [] }];

View File

@ -3,6 +3,7 @@ import { Line } from 'react-chartjs-2';
import chroma from 'chroma-js';
import { humanize } from '../../../../helpers/humanize';
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
import {
Chart as ChartJS,
CategoryScale,
@ -27,7 +28,7 @@ ChartJS.register(
Legend,
);
export const ChartJSAreaChart = ({ widget }) => {
export const ChartJSAreaChart = ({ widget }: ChartComponentProps) => {
const options = {
responsive: true,
maintainAspectRatio: false,
@ -47,7 +48,7 @@ export const ChartJSAreaChart = ({ widget }) => {
};
const dataForBarChart = (
value: any[],
value: ChartValueArray,
chartColors: string[],
): ChartData<'line', number[], string> => {
if (!value?.length) return { labels: [''], datasets: [{ data: [] }] };

View File

@ -1,12 +1,13 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[];
export const ApexBarChart = ({ widget }) => {
const dataForBarChart = (value: any[]) => {
export const ApexBarChart = ({ widget }: ChartComponentProps) => {
const dataForBarChart = (value: ChartValueArray) => {
if (!value?.length || value?.length > 10000)
return [{ name: '', data: [] }];

View File

@ -14,6 +14,7 @@ import {
} from 'chart.js';
import chroma from 'chroma-js';
import { logger } from '../../../../lib/logger';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
ChartJS.register(
CategoryScale,
@ -24,8 +25,8 @@ ChartJS.register(
Legend,
);
export const ChartJSBarChart = ({ widget }) => {
logger.debug('ChartJSBarChart widget:', widget);
export const ChartJSBarChart = ({ widget }: ChartComponentProps) => {
logger.debug('ChartJSBarChart widget:', { ...widget });
const options = () => {
return {
responsive: true,
@ -43,7 +44,7 @@ export const ChartJSBarChart = ({ widget }) => {
};
const dataForBarChart = (
value: any[],
value: ChartValueArray,
chartColors: string[],
): ChartData<'bar', number[], string> => {
if (!value?.length) return { labels: [''], datasets: [{ data: [] }] };

View File

@ -1,12 +1,13 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { humanize } from '../../../helpers/humanize';
import type { ChartComponentProps, ChartValueArray } from '../../../types/charts';
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[];
export const FunnelChart = ({ widget }) => {
const dataForBarChart = (value: any[]) => {
export const FunnelChart = ({ widget }: ChartComponentProps) => {
const dataForBarChart = (value: ChartValueArray) => {
if (!value?.length || value?.length > 10000)
return [{ name: '', data: [] }];
const valueKey = Object.keys(value[0])[1];

View File

@ -1,12 +1,13 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { humanize } from '../../../../helpers/humanize';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[];
export const ApexLineChart = ({ widget }) => {
const dataForLineChart = (value: any[]) => {
export const ApexLineChart = ({ widget }: ChartComponentProps) => {
const dataForLineChart = (value: ChartValueArray) => {
if (!value?.length || value?.length > 10000)
return [{ name: '', data: [] }];

View File

@ -3,7 +3,6 @@ import { humanize } from '../../../../helpers/humanize';
import { Line } from 'react-chartjs-2';
import chroma from 'chroma-js';
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
import { Widget } from '../../models/widget.model';
import {
Chart,
LineElement,
@ -14,6 +13,7 @@ import {
Tooltip,
ChartData,
} from 'chart.js';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
Chart.register(
LineElement,
@ -24,11 +24,7 @@ Chart.register(
Tooltip,
);
interface Props {
widget: Widget;
}
export const ChartJSLineChart = (props: Props) => {
export const ChartJSLineChart = (props: ChartComponentProps) => {
const options = {
responsive: true,
maintainAspectRatio: false,
@ -48,7 +44,7 @@ export const ChartJSLineChart = (props: Props) => {
};
const dataForBarChart = (
value: any[],
value: ChartValueArray,
chartColors: string[],
): ChartData<'line', number[], string> => {
if (!value?.length) return { labels: [''], datasets: [{ data: [] }] };

View File

@ -1,12 +1,13 @@
import React from 'react';
import dynamic from 'next/dynamic';
import chroma from 'chroma-js';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
type ValueType = { [key: string]: string | number }[];
export const ApexPieChart = ({ widget }) => {
const optionsForPieChart = (value: ValueType, chartColor: string) => {
export const ApexPieChart = ({ widget }: ChartComponentProps) => {
const optionsForPieChart = (value: ValueType, chartColor?: string | string[]) => {
const chartColors = Array.isArray(chartColor)
? chartColor
: [chartColor || '#3751FF'];
@ -80,13 +81,14 @@ export const ApexPieChart = ({ widget }) => {
labels: categories,
};
};
const dataForPieChart = (value: any[]) => {
const dataForPieChart = (value: ChartValueArray) => {
if (!value?.length || value?.length > 10000)
return [{ name: '', data: [] }];
const secondKeyValue = value[0][Object.keys(value[0])[1]];
if (
!isNaN(parseFloat(value[0][Object.keys(value[0])[1]])) &&
isFinite(value[0][Object.keys(value[0])[1]])
!isNaN(parseFloat(String(secondKeyValue))) &&
isFinite(Number(secondKeyValue))
) {
return value.map((el) => +el[Object.keys(value[0])[1]]).reverse();
}

View File

@ -3,6 +3,7 @@ import { humanize } from '../../../../helpers/humanize';
import { Pie } from 'react-chartjs-2';
import chroma from 'chroma-js';
import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers';
import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts';
import {
Chart as ChartJS,
@ -14,7 +15,7 @@ import {
ChartJS.register(ArcElement, Tooltip, Legend);
export const ChartJSPieChart = ({ widget }) => {
export const ChartJSPieChart = ({ widget }: ChartComponentProps) => {
const options = () => {
return {
responsive: true,
@ -32,7 +33,7 @@ export const ChartJSPieChart = ({ widget }) => {
};
const dataForBarChart = (
value: any[],
value: ChartValueArray,
chartColors: string[],
): ChartData<'pie', number[], string> => {
if (!value?.length) return { labels: [''], datasets: [{ data: [] }] };

View File

@ -1,3 +1,5 @@
import type { ChartValueArray } from '../../../types/charts';
export enum WidgetLibName {
apex = 'apex',
chartjs = 'chartjs',
@ -26,9 +28,9 @@ export interface Widget {
label: string;
id: string;
lib?: WidgetLibName;
value: any[];
value: ChartValueArray;
chartColors: string[];
options?: any;
options?: Record<string, unknown>;
prompt: string;
color: string;
color_array: string[];

View File

@ -1,11 +1,8 @@
import { humanize } from '../../helpers/humanize';
interface DataObject {
[key: string]: any;
}
import type { ChartDataPoint } from '../../types/charts';
export const findFirstNumericKey = (
obj: Record<string, any>,
obj: ChartDataPoint,
): string | undefined => {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
@ -28,11 +25,11 @@ export const findFirstNumericKey = (
};
export const collectOtherData = (
obj: DataObject,
obj: ChartDataPoint,
excludeKey: string,
): string => {
return Object.entries(obj)
.filter(([key, _]) => key !== excludeKey)
.map(([_, value]) => humanize(value))
.map(([_, value]) => humanize(String(value)))
.join(' / ');
};

View File

@ -2,8 +2,8 @@ import React, { useEffect, useId, useState } from 'react';
import Switch from 'react-switch';
export const SwitchField = ({ field, form, disabled }) => {
const handleChange = (data: any) => {
form.setFieldValue(field.name, data);
const handleChange = (checked: boolean) => {
form.setFieldValue(field.name, checked);
};
return (

View File

@ -139,10 +139,11 @@ const TourFlowManager = () => {
setPages(getRows(pagesResponse));
setTransitions([]);
} catch (error: any) {
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } };
setErrorMessage(
error?.response?.data?.message ||
error?.message ||
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to load pages and transitions.',
);
logger.error(
@ -364,10 +365,11 @@ const TourFlowManager = () => {
setIsCreatePageModalActive(false);
setNewPageSlug('');
await loadData();
} catch (error: any) {
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } };
const message =
error?.response?.data?.message ||
error?.message ||
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to create page.';
setErrorMessage(message);
setNewPageSlugError(message);
@ -400,10 +402,11 @@ const TourFlowManager = () => {
await axios.delete(`/tour_pages/${id}`);
setPages((prev) => prev.filter((item) => item.id !== id));
}
} catch (error: any) {
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { message?: string } } };
setErrorMessage(
error?.response?.data?.message ||
error?.message ||
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to delete item.',
);
logger.error(
@ -537,9 +540,10 @@ const TourFlowManager = () => {
return (
<li key={`${entry.type}-${entry.id}`}>
<button
type='button'
className='w-full text-left border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors relative'
<div
role='button'
tabIndex={0}
className='w-full text-left border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors relative cursor-pointer'
onClick={() =>
openConstructor(
entry.parentPageId,
@ -547,6 +551,16 @@ const TourFlowManager = () => {
entry.type,
)
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openConstructor(
entry.parentPageId,
entry.id,
entry.type,
);
}
}}
>
<div className='pr-8'>
<p className='text-xs uppercase text-gray-500 mb-1'>
@ -569,7 +583,7 @@ const TourFlowManager = () => {
}
disabled={!canDelete || isDeleting}
/>
</button>
</div>
</li>
);
})}

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { TourPage } from '../../types/entities';
type Props = {
tour_pages: any[];
tour_pages: TourPage[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { TourPage } from '../../types/entities';
type Props = {
tour_pages: any[];
tour_pages: TourPage[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -41,6 +41,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
/>
</div>
);

View File

@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from 'react';
import React, { ReactNode, useMemo } from 'react';
import { useAppSelector } from '../stores/hooks';
import UserAvatar from './UserAvatar';
@ -11,36 +11,38 @@ export default function UserAvatarCurrentUser({
className = '',
children,
}: Props) {
const userName = useAppSelector((state) => state.main.userName);
const userAvatar = useAppSelector((state) => state.main.userAvatar);
const { currentUser, isFetching, token } = useAppSelector(
(state) => state.auth,
);
const { users, loading } = useAppSelector((state) => state.users);
// Get user data from authSlice.currentUser (single source of truth)
const { currentUser } = useAppSelector((state) => state.auth);
const [avatar, setAvatar] = useState(null);
// Derive display values from currentUser
const userName = useMemo(() => {
if (!currentUser) return '';
const firstName = currentUser.firstName || '';
const lastName = currentUser.lastName || '';
return `${firstName} ${lastName}`.trim() || currentUser.email || '';
}, [currentUser]);
useEffect(() => {
currentUserAvatarCheck();
}, []);
useEffect(() => {
currentUserAvatarCheck();
}, [currentUser?.id, users]);
const currentUserAvatarCheck = () => {
if (currentUser?.id) {
const image = currentUser?.avatar;
setAvatar(image);
const userAvatar = useMemo(() => {
if (!currentUser?.avatar) return null;
// avatar can be an array with publicUrl or a direct URL string
if (Array.isArray(currentUser.avatar) && currentUser.avatar[0]?.publicUrl) {
return currentUser.avatar[0].publicUrl;
}
};
if (typeof currentUser.avatar === 'string') {
return currentUser.avatar;
}
return null;
}, [currentUser]);
// Convert string avatar to array format expected by UserAvatar.image prop
const imageArray = userAvatar ? [{ publicUrl: userAvatar }] : null;
return (
<UserAvatar
username={userName}
avatar={userAvatar}
className={className}
image={avatar}
image={imageArray}
>
{children}
</UserAvatar>

View File

@ -1,5 +1,6 @@
import { mdiCheckDecagram } from '@mdi/js';
import { Field, Form, Formik } from 'formik';
import { useMemo } from 'react';
import { useAppSelector } from '../stores/hooks';
import CardBox from './CardBox';
import FormCheckRadio from './FormCheckRadio';
@ -10,7 +11,15 @@ type Props = {
};
const UserCard = ({ className }: Props) => {
const userName = useAppSelector((state) => state.main.userName);
// Get user data from authSlice.currentUser (single source of truth)
const { currentUser } = useAppSelector((state) => state.auth);
const userName = useMemo(() => {
if (!currentUser) return '';
const firstName = currentUser.firstName || '';
const lastName = currentUser.lastName || '';
return `${firstName} ${lastName}`.trim() || currentUser.email || '';
}, [currentUser]);
return (
<CardBox className={className}>
@ -34,10 +43,6 @@ const UserCard = ({ className }: Props) => {
<h1 className='text-2xl'>
Howdy, <b>{userName}</b>!
</h1>
<p>
Last login <b>12 mins ago</b> from <b>127.0.0.1</b>
</p>
<div className='flex justify-center md:block'>Verified</div>
</div>
</div>
</CardBox>

View File

@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { User } from '../../types/entities';
type Props = {
users: any[];
users: User[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import type { User } from '../../types/entities';
type Props = {
users: any[];
users: User[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;

View File

@ -37,7 +37,7 @@ export const RoleSelect = ({
setValue(option);
};
async function callApi(inputValue: string, loadedOptions: any[]) {
async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
const { data } = await axios(path);
return {

View File

@ -50,7 +50,7 @@ export const WidgetCreator = ({
const smartSearch = async (
values: { description: string },
resetForm: any,
resetForm: (nextState?: { values: { description: string } }) => void,
) => {
const description = values.description;
const projectId = '';
@ -61,9 +61,9 @@ export const WidgetCreator = ({
projectId,
userId: currentUser?.id,
};
const { payload: responcePayload, error }: any = await dispatch(
aiPrompt(payload),
);
const result = await dispatch(aiPrompt(payload));
const responcePayload = result.payload as { data?: { error?: { message?: string } } } | undefined;
const error = 'error' in result ? result.error as { message?: string } | undefined : undefined;
await getWidgets().then();

View File

@ -25,6 +25,7 @@ export const OFFLINE_CONFIG = {
projectDownloadProgress: 'project-download-progress',
projectDownloadComplete: 'project-download-complete',
queueUpdate: 'queue-update',
blobUrlReady: 'blob-url-ready',
},
// Service worker settings

View File

@ -70,6 +70,10 @@ export const PRELOAD_CONFIG = {
'reverseVideoUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'galleryHeaderImageUrl',
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
'src',
'url',
'poster',
@ -82,12 +86,16 @@ export const PRELOAD_CONFIG = {
'backgroundImageUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'galleryHeaderImageUrl',
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
'src',
] as const,
// Nested array fields containing assets
nested: ['galleryCards', 'carouselSlides'] as const,
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
// Fields within nested items that contain URLs
nestedUrlFields: ['imageUrl', 'videoUrl'] as const,
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
},
} as const;

View File

@ -0,0 +1,422 @@
/**
* Constructor Context
*
* Provides centralized state management for the tour constructor page.
* This reduces prop drilling by making constructor state available to
* deeply nested components.
*
* Components can access constructor state via:
* - useConstructorContext() - throws if used outside provider
* - useConstructorContextOptional() - returns null if outside provider
*/
import React, {
createContext,
useContext,
useMemo,
type ReactNode,
} from 'react';
import type {
CanvasElement,
CanvasElementType,
PageBackgroundState,
PageBackgroundVideoSettings,
EditorMenuItem,
EditorTab,
GalleryCard,
GalleryInfoSpan,
CarouselSlide,
AssetOption,
} from '../types/constructor';
import type { TourPage, Asset } from '../types/entities';
// ============================================================================
// Navigation Types
// ============================================================================
export type NavigationElementType = Extract<
CanvasElementType,
'navigation_next' | 'navigation_prev'
>;
// ============================================================================
// Gallery/Carousel Operations
// ============================================================================
export interface GalleryCardOperations {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
}
export interface GalleryInfoSpanOperations {
add: () => void;
update: (spanId: string, patch: Partial<GalleryInfoSpan>) => void;
remove: (spanId: string) => void;
}
export interface CarouselSlideOperations {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
}
// ============================================================================
// Transition Creation State
// ============================================================================
export interface TransitionCreationState {
name: string;
videoUrl: string;
supportsReverse: boolean;
isCreating: boolean;
}
export interface TransitionCreationActions {
setName: (name: string) => void;
setVideoUrl: (url: string) => void;
setSupportsReverse: (value: boolean) => void;
create: () => void;
}
// ============================================================================
// Context Types
// ============================================================================
export interface ConstructorContextValue {
// Project state
projectId: string;
// Page state
pages: TourPage[];
activePageId: string | null;
activePage: TourPage | null;
setActivePageId: (id: string) => void;
// Background state (consolidated from 8 useState hooks)
pageBackground: PageBackgroundState;
setPageBackground: React.Dispatch<React.SetStateAction<PageBackgroundState>>;
updateBackgroundFromPage: (page: TourPage | null) => void;
// Background convenience setters
setBackgroundImageUrl: (url: string) => void;
setBackgroundVideoUrl: (url: string) => void;
setBackgroundAudioUrl: (url: string) => void;
setBackgroundVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
// Element state
elements: CanvasElement[];
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
selectedElementId: string | null;
selectedElement: CanvasElement | null;
selectElement: (id: string) => void;
clearSelection: () => void;
updateElement: (id: string, patch: Partial<CanvasElement>) => void;
removeElement: (id: string) => void;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
removeSelectedElement: () => void;
// Menu state
selectedMenuItem: EditorMenuItem;
setSelectedMenuItem: (item: EditorMenuItem) => void;
isMenuOpen: boolean;
setIsMenuOpen: (open: boolean) => void;
// Editor state
elementEditorTab: EditorTab;
setElementEditorTab: (tab: EditorTab) => void;
// Assets (cached via React Query)
assets: Asset[];
isLoadingAssets: boolean;
// Asset options (derived from assets)
assetOptions: {
image: AssetOption[];
backgroundImage: AssetOption[];
video: AssetOption[];
audio: AssetOption[];
transitionVideo: AssetOption[];
icon: AssetOption[];
};
// Gallery/Carousel operations
galleryCards: GalleryCardOperations;
galleryInfoSpans: GalleryInfoSpanOperations;
carouselSlides: CarouselSlideOperations;
// Duration resolver
getDuration: (url: string) => number | undefined;
// Duration notes (derived from getDuration)
durationNotes: {
backgroundVideo: string;
backgroundAudio: string;
selectedMedia: string;
selectedTransition: string;
newTransition: string;
};
// Transition preview
onPreviewTransition: (direction: 'forward' | 'back') => void;
// Transition creation
transitionCreation: TransitionCreationState & TransitionCreationActions;
// Navigation settings
allowedNavigationTypes: NavigationElementType[];
normalizeNavigationType: (
element: CanvasElement,
nextType: NavigationElementType,
) => CanvasElement;
// Actions
save: () => Promise<void>;
isSaving: boolean;
}
// ============================================================================
// Context Creation
// ============================================================================
const ConstructorContext = createContext<ConstructorContextValue | null>(null);
// ============================================================================
// Provider Props
// ============================================================================
export interface ConstructorProviderProps {
children: ReactNode;
value: ConstructorContextValue;
}
// ============================================================================
// Provider Component
// ============================================================================
export function ConstructorProvider({
children,
value,
}: ConstructorProviderProps) {
return (
<ConstructorContext.Provider value={value}>
{children}
</ConstructorContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Access constructor context (throws if used outside provider)
*/
export function useConstructorContext(): ConstructorContextValue {
const context = useContext(ConstructorContext);
if (!context) {
throw new Error(
'useConstructorContext must be used within a ConstructorProvider',
);
}
return context;
}
/**
* Access constructor context (returns null if outside provider)
*/
export function useConstructorContextOptional(): ConstructorContextValue | null {
return useContext(ConstructorContext);
}
// ============================================================================
// Selector Hooks (for performance optimization)
// ============================================================================
/**
* Select only pages-related state to minimize re-renders
*/
export function useConstructorPages() {
const ctx = useConstructorContext();
return useMemo(
() => ({
pages: ctx.pages,
activePageId: ctx.activePageId,
activePage: ctx.activePage,
setActivePageId: ctx.setActivePageId,
}),
[ctx.pages, ctx.activePageId, ctx.activePage, ctx.setActivePageId],
);
}
/**
* Select only elements-related state
*/
export function useConstructorElements() {
const ctx = useConstructorContext();
return useMemo(
() => ({
elements: ctx.elements,
setElements: ctx.setElements,
selectedElementId: ctx.selectedElementId,
selectedElement: ctx.selectedElement,
selectElement: ctx.selectElement,
clearSelection: ctx.clearSelection,
updateElement: ctx.updateElement,
removeElement: ctx.removeElement,
updateSelectedElement: ctx.updateSelectedElement,
removeSelectedElement: ctx.removeSelectedElement,
}),
[
ctx.elements,
ctx.setElements,
ctx.selectedElementId,
ctx.selectedElement,
ctx.selectElement,
ctx.clearSelection,
ctx.updateElement,
ctx.removeElement,
ctx.updateSelectedElement,
ctx.removeSelectedElement,
],
);
}
/**
* Select only background-related state
*/
export function useConstructorBackground() {
const ctx = useConstructorContext();
return useMemo(
() => ({
pageBackground: ctx.pageBackground,
setPageBackground: ctx.setPageBackground,
updateBackgroundFromPage: ctx.updateBackgroundFromPage,
setBackgroundImageUrl: ctx.setBackgroundImageUrl,
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
}),
[
ctx.pageBackground,
ctx.setPageBackground,
ctx.updateBackgroundFromPage,
ctx.setBackgroundImageUrl,
ctx.setBackgroundVideoUrl,
ctx.setBackgroundAudioUrl,
ctx.setBackgroundVideoSettings,
],
);
}
/**
* Select only assets-related state
*/
export function useConstructorAssets() {
const ctx = useConstructorContext();
return useMemo(
() => ({
assets: ctx.assets,
isLoadingAssets: ctx.isLoadingAssets,
assetOptions: ctx.assetOptions,
}),
[ctx.assets, ctx.isLoadingAssets, ctx.assetOptions],
);
}
/**
* Select gallery/carousel operations
*/
export function useConstructorCollectionOps() {
const ctx = useConstructorContext();
return useMemo(
() => ({
galleryCards: ctx.galleryCards,
galleryInfoSpans: ctx.galleryInfoSpans,
carouselSlides: ctx.carouselSlides,
}),
[ctx.galleryCards, ctx.galleryInfoSpans, ctx.carouselSlides],
);
}
/**
* Select duration utilities
*/
export function useConstructorDuration() {
const ctx = useConstructorContext();
return useMemo(
() => ({
getDuration: ctx.getDuration,
durationNotes: ctx.durationNotes,
}),
[ctx.getDuration, ctx.durationNotes],
);
}
/**
* Select transition creation state
*/
export function useConstructorTransitionCreation() {
const ctx = useConstructorContext();
return ctx.transitionCreation;
}
/**
* Select navigation settings
*/
export function useConstructorNavigation() {
const ctx = useConstructorContext();
return useMemo(
() => ({
pages: ctx.pages,
activePageId: ctx.activePageId,
allowedNavigationTypes: ctx.allowedNavigationTypes,
normalizeNavigationType: ctx.normalizeNavigationType,
onPreviewTransition: ctx.onPreviewTransition,
}),
[
ctx.pages,
ctx.activePageId,
ctx.allowedNavigationTypes,
ctx.normalizeNavigationType,
ctx.onPreviewTransition,
],
);
}
/**
* Select editor menu state
*/
export function useConstructorMenu() {
const ctx = useConstructorContext();
return useMemo(
() => ({
selectedMenuItem: ctx.selectedMenuItem,
setSelectedMenuItem: ctx.setSelectedMenuItem,
isMenuOpen: ctx.isMenuOpen,
setIsMenuOpen: ctx.setIsMenuOpen,
}),
[
ctx.selectedMenuItem,
ctx.setSelectedMenuItem,
ctx.isMenuOpen,
ctx.setIsMenuOpen,
],
);
}
/**
* Select editor tab state
*/
export function useConstructorEditorTab() {
const ctx = useConstructorContext();
return useMemo(
() => ({
activeTab: ctx.elementEditorTab,
setActiveTab: ctx.setElementEditorTab,
}),
[ctx.elementEditorTab, ctx.setElementEditorTab],
);
}
export default ConstructorContext;

View File

@ -36,6 +36,21 @@ export type {
UsePageDataLoaderOptions,
UsePageDataLoaderResult,
} from './usePageDataLoader';
export { usePageBackground } from './usePageBackground';
export type {
UsePageBackgroundOptions,
UsePageBackgroundResult,
} from './usePageBackground';
export { useConstructorData } from './useConstructorData';
export { useAssetOptions } from './useAssetOptions';
export type { AssetOptionsResult, UseAssetOptionsOptions } from './useAssetOptions';
export { useTransitionCreation } from './useTransitionCreation';
export type {
TransitionCreationState,
TransitionCreationActions,
UseTransitionCreationOptions,
UseTransitionCreationResult,
} from './useTransitionCreation';
// Constructor hooks - import directly for better tree-shaking:
// import { useOutsideClick } from '../hooks/useOutsideClick';

View File

@ -0,0 +1,19 @@
/**
* Query Hooks
*
* Centralized exports for React Query hooks.
*/
export { useProjectsQuery, useProjectQuery, useUpdateProjectMutation } from './useProjectQuery';
export { usePagesQuery, usePageQuery, useUpdatePageMutation, useCreatePageMutation, useDeletePageMutation } from './usePagesQuery';
export { useAssetsQuery, useAssetQuery, useUpdateAssetMutation, useDeleteAssetMutation } from './useAssetsQuery';
export { useElementDefaultsQuery } from './useElementDefaultsQuery';
export { useUsersQuery, useCurrentUserQuery, useUserQuery, useUpdateUserMutation, useCreateUserMutation, useDeleteUserMutation } from './useUsersQuery';
export { useRolesQuery, useRoleQuery, useUpdateRoleMutation, useCreateRoleMutation, useDeleteRoleMutation } from './useRolesQuery';
export { usePermissionsQuery } from './usePermissionsQuery';
export { useAccessLogsQuery } from './useAccessLogsQuery';
export { useAssetVariantsQuery } from './useAssetVariantsQuery';
export { useProjectMembershipsQuery, useCreateProjectMembershipMutation, useDeleteProjectMembershipMutation } from './useProjectMembershipsQuery';
export { useProjectAudioTracksQuery, useProjectAudioTrackQuery, useCreateProjectAudioTrackMutation, useDeleteProjectAudioTrackMutation } from './useProjectAudioTracksQuery';
export { usePublishEventsQuery } from './usePublishEventsQuery';
export { usePwaCachesQuery, useDeletePwaCacheMutation } from './usePwaCachesQuery';

View File

@ -0,0 +1,49 @@
/**
* Access Logs Query Hooks
*
* React Query hooks for fetching access log data.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface AccessLog {
id: string;
user_email: string;
action: string;
entity_type: string;
entity_id: string;
ip_address: string;
createdAt: string;
}
interface AccessLogListParams {
limit?: number;
offset?: number;
}
interface AccessLogListResponse {
rows: AccessLog[];
count: number;
}
/**
* Fetch list of access logs
*/
export function useAccessLogsQuery(params?: AccessLogListParams) {
const query = params
? `?limit=${params.limit || 100}&offset=${params.offset || 0}`
: '';
return useQuery({
queryKey: queryKeys.accessLogs.list(params),
queryFn: async (): Promise<AccessLog[]> => {
const response = await axios.get<AccessLogListResponse>(`access_logs${query}`);
return response.data.rows;
},
staleTime: 1 * 60 * 1000, // Access logs change frequently
});
}
export default useAccessLogsQuery;

View File

@ -0,0 +1,43 @@
/**
* Asset Variants Query Hooks
*
* React Query hooks for fetching asset variant data.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface AssetVariant {
id: string;
variant_type: string;
cdn_url: string;
width_px: number;
height_px: number;
size_mb: number;
assetId: string;
}
interface AssetVariantListResponse {
rows: AssetVariant[];
count: number;
}
/**
* Fetch list of variants for an asset
*/
export function useAssetVariantsQuery(assetId: string | undefined) {
return useQuery({
queryKey: queryKeys.assetVariants.list(assetId || ''),
queryFn: async (): Promise<AssetVariant[]> => {
const response = await axios.get<AssetVariantListResponse>(
`asset_variants?assetId=${assetId}`
);
return response.data.rows;
},
enabled: !!assetId,
staleTime: 5 * 60 * 1000,
});
}
export default useAssetVariantsQuery;

View File

@ -0,0 +1,109 @@
/**
* Assets Query Hooks
*
* React Query hooks for fetching and caching asset data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
import type { Asset } from '../../types/entities';
interface AssetsListResponse {
rows: Asset[];
count: number;
}
interface AssetFilters {
limit?: number;
offset?: number;
type?: string;
}
/**
* Fetch assets for a project
*/
export function useAssetsQuery(
projectId: string | undefined,
filters?: AssetFilters,
) {
const params = new URLSearchParams();
if (projectId) params.set('projectId', projectId);
if (filters?.limit) params.set('limit', String(filters.limit));
if (filters?.offset) params.set('offset', String(filters.offset));
if (filters?.type) params.set('type', filters.type);
const queryString = params.toString();
return useQuery({
queryKey: queryKeys.assets.list(projectId || '', filters),
queryFn: async (): Promise<Asset[]> => {
const response = await axios.get<AssetsListResponse>(
`assets?${queryString}`,
);
return response.data.rows;
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Fetch single asset by ID
*/
export function useAssetQuery(assetId: string | undefined) {
return useQuery({
queryKey: queryKeys.assets.detail(assetId || ''),
queryFn: async (): Promise<Asset> => {
const response = await axios.get<Asset>(`assets/${assetId}`);
return response.data;
},
enabled: !!assetId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Update asset mutation
*/
export function useUpdateAssetMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: Partial<Asset>;
}): Promise<Asset> => {
const response = await axios.put<Asset>(`assets/${id}`, {
id,
data,
});
return response.data;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(queryKeys.assets.detail(variables.id), data);
queryClient.invalidateQueries({ queryKey: queryKeys.assets.all });
},
});
}
/**
* Delete asset mutation
*/
export function useDeleteAssetMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (assetId: string): Promise<void> => {
await axios.delete(`assets/${assetId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.assets.all });
},
});
}
export default useAssetsQuery;

View File

@ -0,0 +1,52 @@
/**
* Element Defaults Query Hooks
*
* React Query hooks for fetching project element defaults.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
import type {
CanvasElementType,
CanvasElement,
NormalizedElementDefault,
} from '../../types/constructor';
import {
normalizeElementDefault,
buildElementDefaultsMap,
} from '../../types/constructor';
interface ElementDefaultsResponse {
rows: Record<string, unknown>[];
count: number;
}
/**
* Fetch project element defaults and transform to a type-indexed map
*/
export function useElementDefaultsQuery(projectId: string | undefined) {
return useQuery({
queryKey: queryKeys.elementDefaults.project(projectId || ''),
queryFn: async (): Promise<
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
> => {
const response = await axios.get<ElementDefaultsResponse>(
`project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`,
);
// Process and normalize the defaults
const normalizedDefaults = response.data.rows
.map((row) => normalizeElementDefault(row))
.filter(
(d): d is NormalizedElementDefault => d !== null,
);
return buildElementDefaultsMap(normalizedDefaults);
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export default useElementDefaultsQuery;

View File

@ -0,0 +1,118 @@
/**
* Tour Pages Query Hooks
*
* React Query hooks for fetching and caching tour page data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
import type { TourPage } from '../../types/entities';
interface PagesListResponse {
rows: TourPage[];
count: number;
}
/**
* Fetch tour pages for a project
*/
export function usePagesQuery(
projectId: string | undefined,
environment = 'dev',
) {
return useQuery({
queryKey: queryKeys.tourPages.list(projectId || '', environment),
queryFn: async (): Promise<TourPage[]> => {
const response = await axios.get<PagesListResponse>(
`tour_pages?projectId=${projectId}&environment=${environment}&limit=500`,
);
return response.data.rows;
},
enabled: !!projectId,
staleTime: 2 * 60 * 1000, // 2 minutes (pages change more frequently)
});
}
/**
* Fetch single tour page by ID
*/
export function usePageQuery(pageId: string | undefined) {
return useQuery({
queryKey: queryKeys.tourPages.detail(pageId || ''),
queryFn: async (): Promise<TourPage> => {
const response = await axios.get<TourPage>(`tour_pages/${pageId}`);
return response.data;
},
enabled: !!pageId,
staleTime: 2 * 60 * 1000,
});
}
/**
* Update tour page mutation
*/
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: Partial<TourPage>;
}): Promise<TourPage> => {
const response = await axios.put<TourPage>(`tour_pages/${id}`, {
id,
data,
});
return response.data;
},
onSuccess: (data, variables) => {
// Update the single page cache
queryClient.setQueryData(
queryKeys.tourPages.detail(variables.id),
data,
);
// Invalidate list queries
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
},
});
}
/**
* Create tour page mutation
*/
export function useCreatePageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<TourPage>): Promise<TourPage> => {
const response = await axios.post<TourPage>('tour_pages', { data });
return response.data;
},
onSuccess: () => {
// Invalidate all page queries to refetch
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
},
});
}
/**
* Delete tour page mutation
*/
export function useDeletePageMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (pageId: string): Promise<void> => {
await axios.delete(`tour_pages/${pageId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
},
});
}
export default usePagesQuery;

View File

@ -0,0 +1,35 @@
/**
* Permissions Query Hooks
*
* React Query hooks for fetching permission data.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface Permission {
id: string;
name: string;
}
interface PermissionListResponse {
rows: Permission[];
count: number;
}
/**
* Fetch list of permissions
*/
export function usePermissionsQuery() {
return useQuery({
queryKey: queryKeys.permissions.list(),
queryFn: async (): Promise<Permission[]> => {
const response = await axios.get<PermissionListResponse>('permissions');
return response.data.rows;
},
staleTime: 30 * 60 * 1000, // Permissions rarely change
});
}
export default usePermissionsQuery;

View File

@ -0,0 +1,102 @@
/**
* Project Audio Tracks Query Hooks
*
* React Query hooks for fetching project audio track data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface ProjectAudioTrack {
id: string;
projectId: string;
name: string;
cdn_url: string;
storage_key: string;
duration_sec: number;
environment: 'dev' | 'stage' | 'production';
order_index: number;
}
interface ProjectAudioTrackListResponse {
rows: ProjectAudioTrack[];
count: number;
}
/**
* Fetch list of audio tracks for a project
*/
export function useProjectAudioTracksQuery(projectId: string | undefined) {
return useQuery({
queryKey: queryKeys.projectAudioTracks.list(projectId || ''),
queryFn: async (): Promise<ProjectAudioTrack[]> => {
const response = await axios.get<ProjectAudioTrackListResponse>(
`project_audio_tracks?projectId=${projectId}`
);
return response.data.rows;
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Fetch single audio track by ID
*/
export function useProjectAudioTrackQuery(trackId: string | undefined) {
return useQuery({
queryKey: queryKeys.projectAudioTracks.detail(trackId || ''),
queryFn: async (): Promise<ProjectAudioTrack> => {
const response = await axios.get<ProjectAudioTrack>(`project_audio_tracks/${trackId}`);
return response.data;
},
enabled: !!trackId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Create project audio track mutation
*/
export function useCreateProjectAudioTrackMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<ProjectAudioTrack>): Promise<ProjectAudioTrack> => {
const response = await axios.post<ProjectAudioTrack>('project_audio_tracks', { data });
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projectAudioTracks.list(variables.projectId || ''),
});
},
});
}
/**
* Delete project audio track mutation
*/
export function useDeleteProjectAudioTrackMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
projectId,
}: {
id: string;
projectId: string;
}): Promise<void> => {
await axios.delete(`project_audio_tracks/${id}`);
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projectAudioTracks.list(projectId),
});
},
});
}
export default useProjectAudioTracksQuery;

View File

@ -0,0 +1,89 @@
/**
* Project Memberships Query Hooks
*
* React Query hooks for fetching project membership data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface ProjectMembership {
id: string;
projectId: string;
userId: string;
role: string;
user?: {
id: string;
firstName: string;
lastName: string;
email: string;
};
}
interface ProjectMembershipListResponse {
rows: ProjectMembership[];
count: number;
}
/**
* Fetch list of memberships for a project
*/
export function useProjectMembershipsQuery(projectId: string | undefined) {
return useQuery({
queryKey: queryKeys.projectMemberships.list(projectId || ''),
queryFn: async (): Promise<ProjectMembership[]> => {
const response = await axios.get<ProjectMembershipListResponse>(
`project_memberships?projectId=${projectId}`
);
return response.data.rows;
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Create project membership mutation
*/
export function useCreateProjectMembershipMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<ProjectMembership>): Promise<ProjectMembership> => {
const response = await axios.post<ProjectMembership>('project_memberships', { data });
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projectMemberships.list(variables.projectId || ''),
});
},
});
}
/**
* Delete project membership mutation
*/
export function useDeleteProjectMembershipMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
projectId,
}: {
id: string;
projectId: string;
}): Promise<void> => {
await axios.delete(`project_memberships/${id}`);
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projectMemberships.list(projectId),
});
},
});
}
export default useProjectMembershipsQuery;

View File

@ -0,0 +1,87 @@
/**
* Project Query Hooks
*
* React Query hooks for fetching and caching project data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
import type { Project } from '../../types/entities';
interface ProjectListParams {
limit?: number;
offset?: number;
}
interface ProjectListResponse {
rows: Project[];
count: number;
}
/**
* Fetch list of projects
*/
export function useProjectsQuery(params?: ProjectListParams) {
const query = params
? `?limit=${params.limit || 100}&offset=${params.offset || 0}`
: '';
return useQuery({
queryKey: queryKeys.projects.list(params),
queryFn: async (): Promise<Project[]> => {
const response = await axios.get<ProjectListResponse>(`projects${query}`);
return response.data.rows;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Fetch single project by ID
*/
export function useProjectQuery(projectId: string | undefined) {
return useQuery({
queryKey: queryKeys.projects.detail(projectId || ''),
queryFn: async (): Promise<Project> => {
const response = await axios.get<Project>(`projects/${projectId}`);
return response.data;
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Update project mutation
*/
export function useUpdateProjectMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: Partial<Project>;
}): Promise<Project> => {
const response = await axios.put<Project>(`projects/${id}`, {
id,
data,
});
return response.data;
},
onSuccess: (data, variables) => {
// Update the cache with the new data
queryClient.setQueryData(
queryKeys.projects.detail(variables.id),
data,
);
// Invalidate list queries to refetch
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
},
});
}
export default useProjectQuery;

View File

@ -0,0 +1,56 @@
/**
* Publish Events Query Hooks
*
* React Query hooks for fetching publish event data.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { queryKeys } from '../../lib/queryClient';
interface PublishEvent {
id: string;
projectId: string;
userId: string;
title: string;
description: string;
from_environment: 'dev' | 'stage';
to_environment: 'stage' | 'production';
status: 'queued' | 'running' | 'success' | 'failed';
pages_copied: number;
audios_copied: number;
started_at: string | null;
finished_at: string | null;
error_message: string | null;
createdAt: string;
user?: {
id: string;
firstName: string;
lastName: string;
email: string;
};
}
interface PublishEventListResponse {
rows: PublishEvent[];
count: number;
}
/**
* Fetch list of publish events for a project
*/
export function usePublishEventsQuery(projectId: string | undefined) {
return useQuery({
queryKey: queryKeys.publishEvents.list(projectId || ''),
queryFn: async (): Promise<PublishEvent[]> => {
const response = await axios.get<PublishEventListResponse>(
`publish_events?projectId=${projectId}&limit=50`
);
return response.data.rows;
},
enabled: !!projectId,
staleTime: 1 * 60 * 1000, // Publish events can change quickly
});
}
export default usePublishEventsQuery;

Some files were not shown because too many files have changed in this diff Show More