offline mode improved
This commit is contained in:
parent
cef7c80d8f
commit
4bf9339a7f
@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -19,7 +19,6 @@ const publishHandler = wrapAsync(async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/', publishHandler);
|
||||
router.post('/publish', publishHandler);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
53
backend/src/utils/sqlValidator.js
Normal file
53
backend/src/utils/sqlValidator.js
Normal 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 };
|
||||
@ -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
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -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 = ({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MenuNavBarItem } from '../interfaces';
|
||||
import { MenuNavBarItem } from '../types/menu';
|
||||
import NavBarItem from './NavBarItem';
|
||||
|
||||
type Props = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -10,7 +10,7 @@ import BaseIcon from './BaseIcon';
|
||||
type Props = {
|
||||
currentPage: number;
|
||||
numPages: number;
|
||||
setCurrentPage: any;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
export const Pagination = ({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { BgKey } from '../interfaces';
|
||||
import { BgKey } from '../types/ui';
|
||||
import {
|
||||
gradientBgPurplePink,
|
||||
gradientBgDark,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: [] }];
|
||||
|
||||
|
||||
@ -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: [] }] };
|
||||
|
||||
@ -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: [] }];
|
||||
|
||||
|
||||
@ -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: [] }] };
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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: [] }];
|
||||
|
||||
|
||||
@ -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: [] }] };
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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: [] }] };
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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(' / ');
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -41,6 +41,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
422
frontend/src/context/ConstructorContext.tsx
Normal file
422
frontend/src/context/ConstructorContext.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
19
frontend/src/hooks/queries/index.ts
Normal file
19
frontend/src/hooks/queries/index.ts
Normal 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';
|
||||
49
frontend/src/hooks/queries/useAccessLogsQuery.ts
Normal file
49
frontend/src/hooks/queries/useAccessLogsQuery.ts
Normal 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;
|
||||
43
frontend/src/hooks/queries/useAssetVariantsQuery.ts
Normal file
43
frontend/src/hooks/queries/useAssetVariantsQuery.ts
Normal 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;
|
||||
109
frontend/src/hooks/queries/useAssetsQuery.ts
Normal file
109
frontend/src/hooks/queries/useAssetsQuery.ts
Normal 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;
|
||||
52
frontend/src/hooks/queries/useElementDefaultsQuery.ts
Normal file
52
frontend/src/hooks/queries/useElementDefaultsQuery.ts
Normal 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;
|
||||
118
frontend/src/hooks/queries/usePagesQuery.ts
Normal file
118
frontend/src/hooks/queries/usePagesQuery.ts
Normal 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;
|
||||
35
frontend/src/hooks/queries/usePermissionsQuery.ts
Normal file
35
frontend/src/hooks/queries/usePermissionsQuery.ts
Normal 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;
|
||||
102
frontend/src/hooks/queries/useProjectAudioTracksQuery.ts
Normal file
102
frontend/src/hooks/queries/useProjectAudioTracksQuery.ts
Normal 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;
|
||||
89
frontend/src/hooks/queries/useProjectMembershipsQuery.ts
Normal file
89
frontend/src/hooks/queries/useProjectMembershipsQuery.ts
Normal 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;
|
||||
87
frontend/src/hooks/queries/useProjectQuery.ts
Normal file
87
frontend/src/hooks/queries/useProjectQuery.ts
Normal 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;
|
||||
56
frontend/src/hooks/queries/usePublishEventsQuery.ts
Normal file
56
frontend/src/hooks/queries/usePublishEventsQuery.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user