improved project architecture (BE and FE)

This commit is contained in:
Dmitri 2026-03-30 12:51:55 +04:00
parent 25e6a1f5d2
commit b66cf94fb4
98 changed files with 4080 additions and 7530 deletions

View File

@ -119,7 +119,7 @@ Three-tier environment model with separate content per environment:
```
Dev Environment Stage Environment Production Environment
│ │ │
/constructor?projectId= /p/[slug]/stage /p/[slug]
/constructor?projectId= /p/[projectSlug]/stage /p/[projectSlug]
(editing mode) (preview) (public access)
│ │ │
└── Save to Stage ──────►└── Publish ─────────────►│
@ -225,7 +225,7 @@ EMAIL_PASS=...
### Frontend (`frontend/.env.local`)
```env
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_BACK_API=http://localhost:8080
```
## Common Commands

View File

@ -111,7 +111,9 @@ backend/src/
├── middlewares/
│ ├── check-permissions.js # RBAC permission checking
│ ├── runtime-context.js # Environment detection from headers
│ └── runtime-public.js # Public runtime access (no auth)
│ ├── runtime-public.js # Public runtime access (no auth)
│ ├── upload.js # File upload handling (multer)
│ └── rateLimiter.js # Rate limiting for API endpoints
├── factories/
│ ├── router.factory.js # Generate CRUD routes
@ -302,8 +304,13 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
| Role | Description |
|------|-------------|
| Administrator | Full access to all features |
| Analytics Viewer | Read-only access for analytics |
| Administrator | Full access to all features (user/role/permission management) |
| Platform Owner | Full project access, user management |
| Account Manager | Project and asset management |
| Tour Designer | Create and edit tours, assets, pages |
| Content Reviewer | Review and update content (read/update access) |
| Analytics Viewer | Read-only access for viewing data |
| Public | Minimal access for public users |
## Environment Detection

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Access_logsDBApi extends GenericDBApi {
static get MODEL() {
@ -53,6 +49,30 @@ class Access_logsDBApi extends GenericDBApi {
return [{ association: 'project' }, { association: 'user' }];
}
static get FIND_ALL_INCLUDES() {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
{
filterKey: 'user',
model: db.users,
as: 'user',
searchField: 'firstName',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -63,136 +83,6 @@ class Access_logsDBApi extends GenericDBApi {
accessed_at: data.accessed_at || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.users,
as: 'user',
where: filter.user
? {
[Op.or]: [
{
id: {
[Op.in]: filter.user
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
firstName: {
[Op.or]: filter.user
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Access_logsDBApi;

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Asset_variantsDBApi extends GenericDBApi {
static get MODEL() {
@ -50,6 +46,27 @@ class Asset_variantsDBApi extends GenericDBApi {
return [{ association: 'asset' }];
}
static get FIND_ALL_INCLUDES() {
return [
{
model: db.assets,
as: 'asset',
required: false,
},
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'asset',
model: db.assets,
as: 'asset',
searchField: 'name',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -60,112 +77,6 @@ class Asset_variantsDBApi extends GenericDBApi {
size_mb: data.size_mb || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.assets,
as: 'asset',
where: filter.asset
? {
[Op.or]: [
{
id: {
[Op.in]: filter.asset
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.asset
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Asset_variantsDBApi;

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class AssetsDBApi extends GenericDBApi {
static get MODEL() {
@ -55,6 +51,10 @@ class AssetsDBApi extends GenericDBApi {
];
}
static get FIND_ALL_INCLUDES() {
return [{ model: db.projects, as: 'project', required: false }];
}
static get RELATION_FILTERS() {
return [
{
@ -83,112 +83,6 @@ class AssetsDBApi extends GenericDBApi {
is_public: data.is_public || false,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = AssetsDBApi;

View File

@ -50,8 +50,83 @@ class GenericDBApi {
return [];
}
/**
* Fields that should be automatically JSON-stringified
* Override in subclass to specify fields.
* Example: return ['settings_json', 'metadata_json'];
*/
static get JSON_FIELDS() {
return [];
}
/**
* Custom field transformers for data mapping.
* Override in subclass to add custom transformations.
* Example:
* return {
* email: (value) => value?.toLowerCase().trim(),
* slug: (value) => value?.toLowerCase().replace(/\s+/g, '-'),
* };
*/
static get FIELD_TRANSFORMERS() {
return {};
}
/**
* Field mapping configuration for declarative field handling.
* Override in subclass to specify how fields should be mapped.
* Example:
* return {
* name: { default: null },
* sort_order: { default: 0 },
* is_active: { default: true },
* };
*/
static get FIELD_DEFAULTS() {
return {};
}
/**
* Transform input data for database operations.
* Template Method Pattern: Uses JSON_FIELDS, FIELD_TRANSFORMERS, and FIELD_DEFAULTS
* to declaratively transform data, reducing boilerplate in subclasses.
*
* Override this method for complex custom transformations that can't be
* expressed declaratively.
*
* @param {Object} data - Input data to transform
* @returns {Object} - Transformed data ready for database
*/
static getFieldMapping(data) {
return data;
if (!data) return data;
const mapped = { ...data };
// Apply field defaults
for (const [field, config] of Object.entries(this.FIELD_DEFAULTS)) {
if (mapped[field] === undefined) {
mapped[field] = config.default;
} else if (mapped[field] === null && config.nullDefault !== undefined) {
mapped[field] = config.nullDefault;
}
}
// Auto-stringify JSON fields
for (const field of this.JSON_FIELDS) {
if (mapped[field] !== undefined && mapped[field] !== null) {
if (typeof mapped[field] !== 'string') {
mapped[field] = JSON.stringify(mapped[field]);
}
}
}
// Apply custom transformers
for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) {
if (mapped[field] !== undefined) {
mapped[field] = transformer(mapped[field]);
}
}
return mapped;
}
static async create(data, options = {}) {

View File

@ -37,19 +37,29 @@ class Element_type_defaultsDBApi extends GenericDBApi {
return 'name';
}
static getFieldMapping(data) {
// Declarative field configuration using base class patterns
static get JSON_FIELDS() {
return ['default_settings_json'];
}
static get FIELD_DEFAULTS() {
return {
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
default_settings_json:
data.default_settings_json === null ||
data.default_settings_json === undefined
? null
: typeof data.default_settings_json === 'string'
? data.default_settings_json
: JSON.stringify(data.default_settings_json),
element_type: { default: null },
name: { default: null },
sort_order: { default: 0 },
};
}
static getFieldMapping(data) {
// Apply base class transformations (JSON fields, defaults, transformers)
const mapped = super.getFieldMapping(data);
return {
id: mapped.id || undefined,
element_type: mapped.element_type,
name: mapped.name,
sort_order: mapped.sort_order,
default_settings_json: mapped.default_settings_json,
};
}

View File

@ -1,6 +1,6 @@
const db = require('../models');
const assert = require('assert');
const services = require('../../services/file');
const services = require('../../services/file/');
module.exports = class FileDBApi {
static async replaceRelationFiles(relation, rawFiles, options) {

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Presigned_url_requestsDBApi extends GenericDBApi {
static get MODEL() {
@ -53,6 +49,30 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
return [{ association: 'project' }, { association: 'user' }];
}
static get FIND_ALL_INCLUDES() {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
{
filterKey: 'user',
model: db.users,
as: 'user',
searchField: 'firstName',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -65,136 +85,6 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
status: data.status || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.users,
as: 'user',
where: filter.user
? {
[Op.or]: [
{
id: {
[Op.in]: filter.user
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
firstName: {
[Op.or]: filter.user
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Presigned_url_requestsDBApi;

View File

@ -61,21 +61,39 @@ class Project_element_defaultsDBApi extends GenericDBApi {
return 'name';
}
static getFieldMapping(data) {
// Declarative field configuration using base class patterns
static get JSON_FIELDS() {
return ['settings_json'];
}
static get FIELD_DEFAULTS() {
return {
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
settings_json:
data.settings_json === null || data.settings_json === undefined
? null
: typeof data.settings_json === 'string'
? data.settings_json
: JSON.stringify(data.settings_json),
source_element_id: data.source_element_id ?? null,
snapshot_version: data.snapshot_version ?? 1,
projectId: data.projectId || data.project || undefined,
element_type: { default: null },
name: { default: null },
sort_order: { default: 0 },
source_element_id: { default: null },
snapshot_version: { default: 1 },
};
}
static getFieldMapping(data) {
// Apply base class transformations (JSON fields, defaults, transformers)
const mapped = super.getFieldMapping(data);
// Custom mapping for projectId field (accepts both projectId and project)
if (mapped.project && !mapped.projectId) {
mapped.projectId = mapped.project;
}
return {
id: mapped.id || undefined,
element_type: mapped.element_type,
name: mapped.name,
sort_order: mapped.sort_order,
settings_json: mapped.settings_json,
source_element_id: mapped.source_element_id,
snapshot_version: mapped.snapshot_version,
projectId: mapped.projectId,
};
}

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_membershipsDBApi extends GenericDBApi {
static get MODEL() {
@ -52,6 +48,30 @@ class Project_membershipsDBApi extends GenericDBApi {
return [{ association: 'project' }, { association: 'user' }];
}
static get FIND_ALL_INCLUDES() {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
{
filterKey: 'user',
model: db.users,
as: 'user',
searchField: 'firstName',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -61,130 +81,6 @@ class Project_membershipsDBApi extends GenericDBApi {
accepted_at: data.accepted_at || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.users,
as: 'user',
where: filter.user
? {
[Op.or]: [
{
id: {
[Op.in]: filter.user
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
firstName: {
[Op.or]: filter.user
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Project_membershipsDBApi;

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Publish_eventsDBApi extends GenericDBApi {
static get MODEL() {
@ -60,6 +56,30 @@ class Publish_eventsDBApi extends GenericDBApi {
return [{ association: 'project' }, { association: 'user' }];
}
static get FIND_ALL_INCLUDES() {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
{
filterKey: 'user',
model: db.users,
as: 'user',
searchField: 'firstName',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -76,136 +96,6 @@ class Publish_eventsDBApi extends GenericDBApi {
audios_copied: data.audios_copied || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.users,
as: 'user',
where: filter.user
? {
[Op.or]: [
{
id: {
[Op.in]: filter.user
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
firstName: {
[Op.or]: filter.user
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Publish_eventsDBApi;

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Pwa_cachesDBApi extends GenericDBApi {
static get MODEL() {
@ -49,6 +45,21 @@ class Pwa_cachesDBApi extends GenericDBApi {
return [{ association: 'project' }];
}
static get FIND_ALL_INCLUDES() {
return [{ model: db.projects, as: 'project', required: false }];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -60,112 +71,6 @@ class Pwa_cachesDBApi extends GenericDBApi {
is_active: data.is_active || false,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
{
id: {
[Op.in]: filter.project
.split('|')
.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: filter.project
.split('|')
.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Pwa_cachesDBApi;

View File

@ -1,9 +1,5 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class RolesDBApi extends GenericDBApi {
static get MODEL() {
@ -42,6 +38,27 @@ class RolesDBApi extends GenericDBApi {
return [{ association: 'users_app_role' }, { association: 'permissions' }];
}
static get FIND_ALL_INCLUDES() {
return [
{
model: db.permissions,
as: 'permissions',
required: false,
},
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'permissions',
model: db.permissions,
as: 'permissions_filter',
searchField: 'name',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
@ -49,105 +66,6 @@ class RolesDBApi extends GenericDBApi {
role_customization: data.role_customization || null,
};
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
let include = [
{
model: db.permissions,
as: 'permissions',
required: false,
},
];
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.permissions) {
const searchTerms = filter.permissions.split('|');
include = [
{
model: db.permissions,
as: 'permissions_filter',
required: searchTerms.length > 0,
where:
searchTerms.length > 0
? {
[Op.or]: [
{
id: {
[Op.in]: searchTerms.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: undefined,
},
...include,
];
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = RolesDBApi;

View File

@ -10,6 +10,12 @@ const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
const { logger, requestLogger } = require('./utils/logger');
const {
uploadLimiter,
downloadLimiter,
searchLimiter,
aiLimiter,
} = require('./middlewares/rateLimiter');
const authRoutes = require('./routes/auth');
const fileRoutes = require('./routes/file');
@ -125,8 +131,13 @@ app.use(requestLogger);
// Initialize passport JWT auth early (before file routes)
const jwtAuth = passport.authenticate('jwt', { session: false });
// Mount file upload routes BEFORE body-parser to avoid JSON parsing on binary uploads
// Mount file routes BEFORE body-parser to avoid JSON parsing on binary uploads
// These routes handle their own body parsing (JSON for init/finalize, raw streams for chunks)
// Use downloadLimiter for download/presign (high traffic), uploadLimiter for uploads (strict)
app.use('/api/file/download', downloadLimiter);
app.use('/api/file/presign', downloadLimiter);
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file/upload-sessions', uploadLimiter);
app.use('/api/file', fileRoutes);
// Body parser for all other routes
@ -227,10 +238,10 @@ app.use(
app.use('/api/publish', jwtAuth, publishRoutes);
app.use('/api/openai', jwtAuth, openaiRoutes);
app.use('/api/ai', jwtAuth, openaiRoutes);
app.use('/api/openai', jwtAuth, aiLimiter, openaiRoutes);
app.use('/api/ai', jwtAuth, aiLimiter, openaiRoutes);
app.use('/api/search', jwtAuth, searchRoutes);
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
app.use('/api/sql', jwtAuth, sqlRoutes);
const publicDir = path.join(__dirname, '../public');

View File

@ -0,0 +1,268 @@
/**
* Rate Limiter Middleware
*
* Provides centralized rate limiting for API endpoints using a configurable
* memory store with optional Redis support for horizontal scaling.
*
* Usage:
* const { authLimiter, apiLimiter, uploadLimiter } = require('./middlewares/rateLimiter');
* app.use('/api/auth', authLimiter);
* app.use('/api', apiLimiter);
*/
const { logger } = require('../utils/logger');
// In-memory store for rate limiting
// For horizontal scaling, replace with Redis store
const rateLimitStore = new Map();
// Cleanup interval for expired entries (every 5 minutes)
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// Periodic cleanup of expired entries
setInterval(() => {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.expiresAt <= now) {
rateLimitStore.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug({ cleaned }, 'Rate limit store cleanup');
}
}, CLEANUP_INTERVAL_MS);
/**
* Create a rate limiter middleware
*
* @param {Object} options - Configuration options
* @param {string} options.keyPrefix - Prefix for rate limit keys (e.g., 'auth', 'api')
* @param {number} options.windowMs - Time window in milliseconds (default: 15 minutes)
* @param {number} options.max - Maximum requests per window (default: 100)
* @param {string} [options.message] - Custom error message
* @param {boolean} [options.skipFailedRequests] - Don't count failed requests (status >= 400)
* @param {Function} [options.keyGenerator] - Custom key generator (req) => string
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
* @returns {Function} Express middleware
*/
const createRateLimiter = (options = {}) => {
const {
keyPrefix = 'rate-limit',
windowMs = 15 * 60 * 1000, // 15 minutes
max = 100,
message = 'Too many requests. Please try again later.',
skipFailedRequests = false,
keyGenerator = null,
skip = null,
} = options;
return (req, res, next) => {
// Allow skipping rate limiting for certain requests
if (skip && skip(req)) {
return next();
}
// Skip in development when accessing from localhost (optional)
if (
process.env.NODE_ENV === 'development' &&
(req.ip === '127.0.0.1' || req.ip === '::1')
) {
return next();
}
// Generate rate limit key
const clientKey = keyGenerator
? keyGenerator(req)
: req.ip || req.connection?.remoteAddress || 'unknown';
const key = `${keyPrefix}:${clientKey}`;
const now = Date.now();
// Get or create rate limit entry
let entry = rateLimitStore.get(key);
if (!entry || entry.expiresAt <= now) {
// Create new entry
entry = {
count: 0,
expiresAt: now + windowMs,
resetTime: new Date(now + windowMs).toISOString(),
};
}
// Add standard rate limit headers
const remaining = Math.max(0, max - entry.count - 1);
const retryAfter = Math.ceil((entry.expiresAt - now) / 1000);
res.setHeader('X-RateLimit-Limit', max);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', entry.resetTime);
// Check if rate limit exceeded
if (entry.count >= max) {
res.setHeader('Retry-After', retryAfter);
logger.warn(
{
ip: clientKey,
keyPrefix,
count: entry.count,
max,
retryAfter,
},
'Rate limit exceeded',
);
return res.status(429).json({
error: 'Too Many Requests',
message,
retryAfter,
});
}
// Increment count
entry.count += 1;
rateLimitStore.set(key, entry);
// If skipFailedRequests is enabled, decrement on failed response
if (skipFailedRequests) {
const originalSend = res.send.bind(res);
res.send = function (body) {
if (res.statusCode >= 400) {
const currentEntry = rateLimitStore.get(key);
if (currentEntry && currentEntry.count > 0) {
currentEntry.count -= 1;
rateLimitStore.set(key, currentEntry);
}
}
return originalSend(body);
};
}
next();
};
};
/**
* Create a rate limiter that uses both IP and user ID as key
* Useful for authenticated endpoints
*/
const createAuthenticatedRateLimiter = (options = {}) => {
return createRateLimiter({
...options,
keyGenerator: (req) => {
const userId = req.currentUser?.id || 'anonymous';
const ip = req.ip || 'unknown';
return `${ip}:${userId}`;
},
});
};
// Pre-configured limiters for common use cases
/**
* Auth limiter - Strict limits for authentication endpoints
* 5 requests per 15 minutes per IP
*/
const authLimiter = createRateLimiter({
keyPrefix: 'auth',
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 attempts per window
message: 'Too many authentication attempts. Please try again later.',
skipFailedRequests: false, // Count failed attempts
});
/**
* Signup limiter - Very strict limits for registration
* 5 signups per hour per IP
*/
const signupLimiter = createRateLimiter({
keyPrefix: 'signup',
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many signup attempts. Please try again later.',
});
/**
* Password reset limiter - Prevent password reset abuse
* 5 requests per hour per IP
*/
const passwordResetLimiter = createRateLimiter({
keyPrefix: 'password-reset',
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many password reset requests. Please try again later.',
});
/**
* General API limiter - Standard limits for API endpoints
* 100 requests per minute per IP
*/
const apiLimiter = createRateLimiter({
keyPrefix: 'api',
windowMs: 60 * 1000, // 1 minute
max: 100,
message: 'Too many requests. Please slow down.',
skipFailedRequests: true, // Don't penalize for errors
});
/**
* Upload limiter - Stricter limits for file uploads
* 10 uploads per minute per IP
*/
const uploadLimiter = createRateLimiter({
keyPrefix: 'upload',
windowMs: 60 * 1000, // 1 minute
max: 10,
message: 'Too many file uploads. Please wait before uploading more.',
});
/**
* Download limiter - More permissive limits for file downloads
* 200 requests per minute per IP (supports asset preloading)
*/
const downloadLimiter = createRateLimiter({
keyPrefix: 'download',
windowMs: 60 * 1000, // 1 minute
max: 200,
message: 'Too many download requests. Please slow down.',
skipFailedRequests: true, // Don't penalize for errors
});
/**
* Search limiter - Prevent search abuse
* 30 searches per minute per IP
*/
const searchLimiter = createRateLimiter({
keyPrefix: 'search',
windowMs: 60 * 1000, // 1 minute
max: 30,
message: 'Too many search requests. Please slow down.',
});
/**
* AI/OpenAI limiter - Strict limits for expensive AI operations
* 20 requests per minute per IP
*/
const aiLimiter = createRateLimiter({
keyPrefix: 'ai',
windowMs: 60 * 1000, // 1 minute
max: 20,
message: 'Too many AI requests. Please wait before making more.',
});
module.exports = {
createRateLimiter,
createAuthenticatedRateLimiter,
authLimiter,
signupLimiter,
passwordResetLimiter,
apiLimiter,
uploadLimiter,
downloadLimiter,
searchLimiter,
aiLimiter,
};

View File

@ -1,124 +0,0 @@
const { body, param, query, validationResult } = require('express-validator');
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array().map((err) => ({
field: err.path,
message: err.msg,
value: err.value,
})),
});
}
next();
};
const validators = {
uuid: (field, location = 'param') => {
const validator = location === 'param' ? param(field) : body(field);
return validator.isUUID().withMessage(`${field} must be a valid UUID`);
},
requiredString: (field, min = 1, max = 255) =>
body(field)
.trim()
.notEmpty()
.withMessage(`${field} is required`)
.isLength({ min, max })
.withMessage(`${field} must be ${min}-${max} characters`),
optionalString: (field, max = 255) =>
body(field)
.optional()
.trim()
.isLength({ max })
.withMessage(`${field} must be at most ${max} characters`),
slug: (field) =>
body(field)
.optional()
.trim()
.matches(/^[a-z0-9_-]+$/i)
.withMessage(
`${field} can only contain letters, numbers, dashes, underscores`,
),
email: (field) =>
body(field)
.trim()
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
optionalEmail: (field) =>
body(field)
.optional()
.trim()
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
enum: (field, values) =>
body(field)
.optional()
.isIn(values)
.withMessage(`${field} must be one of: ${values.join(', ')}`),
boolean: (field) =>
body(field)
.optional()
.isBoolean()
.withMessage(`${field} must be a boolean`),
integer: (field, min, max) => {
let validator = body(field).optional().isInt();
if (min !== undefined)
validator = validator
.custom((val) => val >= min)
.withMessage(`${field} must be at least ${min}`);
if (max !== undefined)
validator = validator
.custom((val) => val <= max)
.withMessage(`${field} must be at most ${max}`);
return validator;
},
pagination: () => [
query('page').optional().isInt({ min: 0 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
],
url: (field) =>
body(field)
.optional()
.trim()
.isURL()
.withMessage(`${field} must be a valid URL`),
};
function createEntityValidation(entityConfig = {}) {
const { fields = [], requiredFields = [] } = entityConfig;
const fieldValidators = fields.map((field) => {
if (requiredFields.includes(field.name)) {
return validators.requiredString(
`data.${field.name}`,
field.min || 1,
field.max || 255,
);
}
return validators.optionalString(`data.${field.name}`, field.max || 255);
});
return {
create: [...fieldValidators, handleValidationErrors],
update: [validators.uuid('id'), ...fieldValidators, handleValidationErrors],
delete: [validators.uuid('id'), handleValidationErrors],
get: [validators.uuid('id'), handleValidationErrors],
list: [...validators.pagination(), handleValidationErrors],
};
}
module.exports = { handleValidationErrors, validators, createEntityValidation };

View File

@ -6,50 +6,13 @@ const AuthService = require('../services/auth');
const ForbiddenError = require('../services/notifications/errors/forbidden');
const EmailSender = require('../services/email');
const wrapAsync = require('../helpers').wrapAsync;
const {
authLimiter: signinLimiter,
signupLimiter,
passwordResetLimiter,
} = require('../middlewares/rateLimiter');
const router = express.Router();
const authRateLimitStore = new Map();
const createMemoryRateLimiter =
({ keyPrefix, maxRequests, windowMs }) =>
(req, res, next) => {
const key = `${keyPrefix}:${req.ip || 'unknown'}`;
const now = Date.now();
const current = authRateLimitStore.get(key);
if (!current || current.expiresAt <= now) {
authRateLimitStore.set(key, { count: 1, expiresAt: now + windowMs });
return next();
}
if (current.count >= maxRequests) {
return res.status(429).send({
message: 'Too many requests. Please try again later.',
});
}
current.count += 1;
authRateLimitStore.set(key, current);
return next();
};
const signinLimiter = createMemoryRateLimiter({
keyPrefix: 'signin',
maxRequests: 10,
windowMs: 15 * 60 * 1000,
});
const signupLimiter = createMemoryRateLimiter({
keyPrefix: 'signup',
maxRequests: 5,
windowMs: 60 * 60 * 1000,
});
const passwordResetLimiter = createMemoryRateLimiter({
keyPrefix: 'password-reset',
maxRequests: 5,
windowMs: 60 * 60 * 1000,
});
function safeParseUrl(value) {
if (!value || typeof value !== 'string') {

View File

@ -1,7 +1,7 @@
const express = require('express');
const passport = require('passport');
const bodyParser = require('body-parser');
const services = require('../services/file');
const services = require('../services/file/');
const router = express.Router();
// JSON body parser that ONLY parses application/json content-type

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
/**
* BaseStorageProvider
*
* Abstract base class for storage providers (Strategy Pattern).
* Subclasses implement specific storage backends (S3, GCloud, Local).
*/
class BaseStorageProvider {
/**
* Provider name for identification
* @returns {string}
*/
static get providerName() {
throw new Error('providerName must be defined in subclass');
}
/**
* Upload a file to storage
* @param {string} _key - Storage key/path
* @param {Buffer|ReadableStream} _data - File data
* @param {Object} _options - Upload options
* @param {string} [_options.contentType] - MIME type
* @param {Object} [_options.metadata] - Additional metadata
* @returns {Promise<{ key: string, url?: string }>}
*/
async upload(_key, _data, _options) {
throw new Error('upload must be implemented in subclass');
}
/**
* Download a file from storage
* @param {string} _key - Storage key/path
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
*/
async download(_key) {
throw new Error('download must be implemented in subclass');
}
/**
* Delete a file from storage
* @param {string} _key - Storage key/path
* @returns {Promise<void>}
*/
async delete(_key) {
throw new Error('delete must be implemented in subclass');
}
/**
* Check if a file exists
* @param {string} _key - Storage key/path
* @returns {Promise<boolean>}
*/
async exists(_key) {
throw new Error('exists must be implemented in subclass');
}
/**
* List files with a given prefix
* @param {string} _prefix - Key prefix
* @returns {Promise<string[]>} Array of keys
*/
async list(_prefix) {
throw new Error('list must be implemented in subclass');
}
/**
* Get a signed URL for direct access (if supported)
* @param {string} _key - Storage key/path
* @param {number} _expiresIn - Expiration time in seconds
* @returns {Promise<string|null>} Signed URL or null if not supported
*/
async getSignedUrl(_key, _expiresIn) {
return null;
}
/**
* Delete multiple files
* @param {string[]} keys - Array of keys to delete
* @returns {Promise<void>}
*/
async deleteMany(keys) {
for (const key of keys) {
await this.delete(key);
}
}
}
module.exports = BaseStorageProvider;

View File

@ -0,0 +1,221 @@
/**
* LocalStorageProvider
*
* Local filesystem storage implementation following the Strategy Pattern.
* Implements BaseStorageProvider interface for local disk operations.
*/
const fs = require('fs');
const path = require('path');
const { pipeline } = require('stream/promises');
const BaseStorageProvider = require('./BaseStorageProvider');
/**
* Ensure directory exists for a file path
*/
const ensureDirectoryExistence = (filePath) => {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
};
class LocalStorageProvider extends BaseStorageProvider {
/**
* @param {Object} options
* @param {string} options.basePath - Base directory for file storage
*/
constructor(options = {}) {
super();
this.basePath = options.basePath || './uploads';
// Ensure base path exists
if (!fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, { recursive: true });
}
}
static get providerName() {
return 'local';
}
/**
* Build full file path
*/
buildPath(key) {
const cleanKey = (key || '').replace(/^\/+/, '');
return path.join(this.basePath, cleanKey);
}
/**
* Upload a file to local storage
* @param {string} key - Storage key/path
* @param {Buffer|ReadableStream} data - File data
* @param {Object} _options - Upload options (not used for local)
* @returns {Promise<{ key: string }>}
*/
async upload(key, data, _options = {}) {
const filePath = this.buildPath(key);
ensureDirectoryExistence(filePath);
if (Buffer.isBuffer(data)) {
fs.writeFileSync(filePath, data);
} else if (data && typeof data.pipe === 'function') {
// Handle stream
const writeStream = fs.createWriteStream(filePath);
await pipeline(data, writeStream);
} else {
throw new Error('Data must be a Buffer or ReadableStream');
}
return { key };
}
/**
* Download a file from local storage
* @param {string} key - Storage key/path
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
*/
async download(key) {
const filePath = this.buildPath(key);
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${key}`);
}
const body = fs.createReadStream(filePath);
// Try to determine content type from extension
const ext = path.extname(filePath).toLowerCase();
const contentType = this.getContentType(ext);
return { body, contentType };
}
/**
* Delete a file from local storage
* @param {string} key - Storage key/path
* @returns {Promise<void>}
*/
async delete(key) {
const filePath = this.buildPath(key);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
/**
* Delete multiple files from local storage
* @param {string[]} keys - Array of keys to delete
* @returns {Promise<void>}
*/
async deleteMany(keys) {
for (const key of keys) {
await this.delete(key);
}
}
/**
* Check if a file exists in local storage
* @param {string} key - Storage key/path
* @returns {Promise<boolean>}
*/
async exists(key) {
const filePath = this.buildPath(key);
return fs.existsSync(filePath);
}
/**
* List files with a given prefix
* @param {string} prefix - Key prefix (directory path)
* @returns {Promise<string[]>} Array of keys
*/
async list(prefix) {
const dirPath = this.buildPath(prefix);
if (!fs.existsSync(dirPath)) {
return [];
}
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
return [prefix];
}
const files = [];
const readDir = (dir, relativePath = '') => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(relativePath, entry.name);
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
readDir(fullPath, entryPath);
} else {
files.push(path.join(prefix, entryPath));
}
}
};
readDir(dirPath);
return files;
}
/**
* Get a signed URL for direct access (not supported for local storage)
* For local storage, return the file path that can be served by express.static
* @param {string} key - Storage key/path
* @param {number} _expiresIn - Expiration time (ignored for local)
* @returns {Promise<string|null>}
*/
async getSignedUrl(key, _expiresIn) {
// Local storage doesn't support signed URLs
// Return the relative path that can be served by a static file server
return `/uploads/${key}`;
}
/**
* Get base path
* @returns {string}
*/
getBasePath() {
return this.basePath;
}
/**
* Get content type from file extension
* @param {string} ext - File extension
* @returns {string}
*/
getContentType(ext) {
const types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.json': 'application/json',
'.txt': 'text/plain',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
};
return types[ext] || 'application/octet-stream';
}
}
module.exports = LocalStorageProvider;

View File

@ -0,0 +1,256 @@
/**
* S3StorageProvider
*
* AWS S3 storage implementation following the Strategy Pattern.
* Implements BaseStorageProvider interface for S3-specific operations.
*/
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
HeadObjectCommand,
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const BaseStorageProvider = require('./BaseStorageProvider');
class S3StorageProvider extends BaseStorageProvider {
/**
* @param {Object} options
* @param {string} options.bucket - S3 bucket name
* @param {string} options.region - AWS region
* @param {string} [options.accessKeyId] - AWS access key ID
* @param {string} [options.secretAccessKey] - AWS secret access key
* @param {string} [options.prefix] - Key prefix for all operations
*/
constructor(options = {}) {
super();
this.bucket = options.bucket;
this.prefix = options.prefix || '';
this.client = new S3Client({
region: options.region || 'us-east-1',
credentials:
options.accessKeyId && options.secretAccessKey
? {
accessKeyId: options.accessKeyId,
secretAccessKey: options.secretAccessKey,
}
: undefined,
});
}
static get providerName() {
return 's3';
}
/**
* Build full key with prefix
*/
buildKey(key) {
const cleanPrefix = (this.prefix || '').replace(/^\/+|\/+$/g, '');
const cleanKey = (key || '').replace(/^\/+/, '');
return cleanPrefix ? `${cleanPrefix}/${cleanKey}` : cleanKey;
}
/**
* Upload a file to S3
* @param {string} key - Storage key/path
* @param {Buffer|ReadableStream} data - File data
* @param {Object} options - Upload options
* @returns {Promise<{ key: string, url?: string }>}
*/
async upload(key, data, options = {}) {
const fullKey = this.buildKey(key);
const params = {
Bucket: this.bucket,
Key: fullKey,
Body: data,
};
if (options.contentType) {
params.ContentType = options.contentType;
}
if (options.metadata) {
params.Metadata = options.metadata;
}
await this.client.send(new PutObjectCommand(params));
return {
key: fullKey,
url: `https://${this.bucket}.s3.amazonaws.com/${fullKey}`,
};
}
/**
* Download a file from S3
* @param {string} key - Storage key/path
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
*/
async download(key) {
const fullKey = this.buildKey(key);
const output = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: fullKey,
}),
);
return {
body: output.Body,
contentType: output.ContentType,
};
}
/**
* Delete a file from S3
* @param {string} key - Storage key/path
* @returns {Promise<void>}
*/
async delete(key) {
const fullKey = this.buildKey(key);
await this.client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: fullKey,
}),
);
}
/**
* Delete multiple files from S3
* @param {string[]} keys - Array of keys to delete
* @returns {Promise<void>}
*/
async deleteMany(keys) {
if (!keys || keys.length === 0) {
return;
}
const objects = keys.map((key) => ({ Key: this.buildKey(key) }));
// S3 DeleteObjects supports max 1000 objects per request
const chunks = [];
for (let i = 0; i < objects.length; i += 1000) {
chunks.push(objects.slice(i, i + 1000));
}
for (const chunk of chunks) {
await this.client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: chunk },
}),
);
}
}
/**
* Check if a file exists in S3
* @param {string} key - Storage key/path
* @returns {Promise<boolean>}
*/
async exists(key) {
const fullKey = this.buildKey(key);
try {
await this.client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: fullKey,
}),
);
return true;
} catch (error) {
if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
return false;
}
throw error;
}
}
/**
* List files with a given prefix
* @param {string} prefix - Key prefix
* @returns {Promise<string[]>} Array of keys
*/
async list(prefix) {
const fullPrefix = this.buildKey(prefix);
const keys = [];
let continuationToken = null;
do {
const params = {
Bucket: this.bucket,
Prefix: fullPrefix,
};
if (continuationToken) {
params.ContinuationToken = continuationToken;
}
const result = await this.client.send(new ListObjectsV2Command(params));
if (result.Contents) {
keys.push(...result.Contents.map((obj) => obj.Key));
}
continuationToken = result.IsTruncated
? result.NextContinuationToken
: null;
} while (continuationToken);
return keys;
}
/**
* Get a signed URL for direct access
* @param {string} key - Storage key/path
* @param {number} expiresIn - Expiration time in seconds
* @returns {Promise<string>} Signed URL
*/
async getSignedUrl(key, expiresIn = 3600) {
const fullKey = this.buildKey(key);
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: fullKey,
});
return getSignedUrl(this.client, command, { expiresIn });
}
/**
* Get the underlying S3 client for advanced operations
* @returns {S3Client}
*/
getClient() {
return this.client;
}
/**
* Get bucket name
* @returns {string}
*/
getBucket() {
return this.bucket;
}
/**
* Get prefix
* @returns {string}
*/
getPrefix() {
return this.prefix;
}
}
module.exports = S3StorageProvider;

View File

@ -0,0 +1,252 @@
/**
* UploadSessionManager
*
* Manages chunked upload sessions for large file uploads.
* Handles session lifecycle, chunk tracking, and assembly.
*/
const fs = require('fs');
const path = require('path');
const { v4: uuid } = require('uuid');
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Ensure directory exists
*/
const ensureDirectoryExistence = (filePath) => {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) {
return true;
}
ensureDirectoryExistence(dirname);
fs.mkdirSync(dirname);
};
/**
* Append one file to another
*/
const streamAppendFile = async (targetPath, sourcePath) => {
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(targetPath, { flags: 'a' });
const readStream = fs.createReadStream(sourcePath);
writeStream.on('error', reject);
readStream.on('error', reject);
writeStream.on('finish', resolve);
readStream.pipe(writeStream, { end: true });
});
};
class UploadSessionManager {
/**
* @param {Object} options
* @param {string} options.sessionDir - Base directory for upload sessions
* @param {number} [options.ttlMs] - Session TTL in milliseconds
*/
constructor(options = {}) {
this.sessionDir = options.sessionDir;
this.ttlMs = options.ttlMs || DEFAULT_TTL_MS;
}
/**
* Get session directory path
*/
getSessionDir(sessionId) {
return path.join(this.sessionDir, sessionId);
}
/**
* Get session metadata path
*/
getMetaPath(sessionId) {
return path.join(this.getSessionDir(sessionId), 'meta.json');
}
/**
* Get session chunks directory
*/
getChunksDir(sessionId) {
return path.join(this.getSessionDir(sessionId), 'chunks');
}
/**
* Get chunk file path
*/
getChunkPath(sessionId, chunkIndex) {
return path.join(this.getChunksDir(sessionId), `${String(chunkIndex)}.part`);
}
/**
* Read session metadata
* @returns {Object|null}
*/
readMeta(sessionId) {
const metaPath = this.getMetaPath(sessionId);
if (!fs.existsSync(metaPath)) {
return null;
}
const raw = fs.readFileSync(metaPath, 'utf8');
return JSON.parse(raw);
}
/**
* Write session metadata
*/
writeMeta(sessionId, payload) {
const metaPath = this.getMetaPath(sessionId);
ensureDirectoryExistence(metaPath);
fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), 'utf8');
}
/**
* Create a new upload session
* @param {Object} options
* @param {string} options.filename - Original filename
* @param {string} options.folder - Target folder
* @param {number} options.totalChunks - Total number of chunks
* @param {number} [options.totalSize] - Total file size
* @param {string} [options.userId] - User ID
* @param {string} [options.contentType] - File content type
* @returns {string} Session ID
*/
createSession(options) {
const sessionId = uuid();
const chunksDir = this.getChunksDir(sessionId);
ensureDirectoryExistence(chunksDir);
fs.mkdirSync(chunksDir, { recursive: true });
const now = new Date().toISOString();
const meta = {
sessionId,
filename: options.filename,
folder: options.folder,
totalChunks: options.totalChunks,
totalSize: options.totalSize || 0,
userId: options.userId || null,
contentType: options.contentType || null,
uploadedChunks: {},
createdAt: now,
updatedAt: now,
};
this.writeMeta(sessionId, meta);
return sessionId;
}
/**
* Save a chunk
* @param {string} sessionId
* @param {number} chunkIndex
* @param {Buffer} data
*/
async saveChunk(sessionId, chunkIndex, data) {
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
ensureDirectoryExistence(chunkPath);
fs.writeFileSync(chunkPath, data);
const meta = this.readMeta(sessionId);
if (meta) {
meta.uploadedChunks[chunkIndex] = {
size: data.length,
uploadedAt: new Date().toISOString(),
};
meta.updatedAt = new Date().toISOString();
this.writeMeta(sessionId, meta);
}
}
/**
* Check if a chunk exists
*/
chunkExists(sessionId, chunkIndex) {
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
return fs.existsSync(chunkPath);
}
/**
* Check if session is complete
*/
isComplete(sessionId) {
const meta = this.readMeta(sessionId);
if (!meta) return false;
const uploadedCount = Object.keys(meta.uploadedChunks).length;
return uploadedCount >= meta.totalChunks;
}
/**
* Assemble chunks into final file
* @param {string} sessionId
* @param {string} targetPath - Path for assembled file
*/
async assembleChunks(sessionId, targetPath) {
const meta = this.readMeta(sessionId);
if (!meta) {
throw new Error('Session not found');
}
ensureDirectoryExistence(targetPath);
// Create empty target file
fs.writeFileSync(targetPath, '');
// Append chunks in order
for (let i = 0; i < meta.totalChunks; i++) {
const chunkPath = this.getChunkPath(sessionId, i);
if (!fs.existsSync(chunkPath)) {
throw new Error(`Missing chunk ${i}`);
}
await streamAppendFile(targetPath, chunkPath);
}
return targetPath;
}
/**
* Remove an upload session
*/
removeSession(sessionId) {
const sessionDir = this.getSessionDir(sessionId);
if (fs.existsSync(sessionDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
}
/**
* Cleanup expired sessions
*/
cleanupExpiredSessions() {
if (!fs.existsSync(this.sessionDir)) {
return;
}
const now = Date.now();
const sessionIds = fs.readdirSync(this.sessionDir);
sessionIds.forEach((sessionId) => {
try {
const meta = this.readMeta(sessionId);
if (!meta) {
this.removeSession(sessionId);
return;
}
const updatedAt = new Date(meta.updatedAt || meta.createdAt || 0).getTime();
if (!updatedAt || now - updatedAt > this.ttlMs) {
this.removeSession(sessionId);
}
} catch (error) {
this.removeSession(sessionId);
}
});
}
}
module.exports = UploadSessionManager;

View File

@ -0,0 +1,27 @@
/**
* File Service Module
*
* Modular file storage service with Strategy Pattern providers:
* - Local filesystem (LocalStorageProvider)
* - AWS S3 (S3StorageProvider)
* - Google Cloud Storage
*/
const BaseStorageProvider = require('./BaseStorageProvider');
const S3StorageProvider = require('./S3StorageProvider');
const LocalStorageProvider = require('./LocalStorageProvider');
const UploadSessionManager = require('./UploadSessionManager');
// Re-export the unified file service
const FileService = require('../file');
module.exports = {
// Unified service API
...FileService,
// Storage providers (for direct usage if needed)
BaseStorageProvider,
S3StorageProvider,
LocalStorageProvider,
UploadSessionManager,
};

View File

@ -1,95 +0,0 @@
const { logger } = require('./logger');
class CircuitBreaker {
constructor(options = {}) {
this.name = options.name || 'default';
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000;
this.failures = 0;
this.state = 'CLOSED';
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
logger.warn(
{ circuitBreaker: this.name, state: this.state },
'Circuit breaker is OPEN, rejecting request',
);
throw new Error(`Circuit breaker ${this.name} is OPEN`);
}
this.state = 'HALF-OPEN';
logger.info(
{ circuitBreaker: this.name },
'Circuit breaker moved to HALF-OPEN',
);
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure(error);
throw error;
}
}
onSuccess() {
if (this.state === 'HALF-OPEN') {
logger.info(
{ circuitBreaker: this.name },
'Circuit breaker recovered, moving to CLOSED',
);
}
this.failures = 0;
this.state = 'CLOSED';
}
onFailure(error) {
this.failures++;
logger.warn(
{
circuitBreaker: this.name,
failures: this.failures,
threshold: this.failureThreshold,
error: error.message,
},
'Circuit breaker recorded failure',
);
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
logger.error(
{
circuitBreaker: this.name,
resetAt: new Date(this.nextAttempt).toISOString(),
},
'Circuit breaker tripped to OPEN',
);
}
}
getState() {
return {
name: this.name,
state: this.state,
failures: this.failures,
nextAttempt:
this.state === 'OPEN' ? new Date(this.nextAttempt).toISOString() : null,
};
}
}
const circuitBreakers = new Map();
function getCircuitBreaker(name, options = {}) {
if (!circuitBreakers.has(name)) {
circuitBreakers.set(name, new CircuitBreaker({ name, ...options }));
}
return circuitBreakers.get(name);
}
module.exports = { CircuitBreaker, getCircuitBreaker };

View File

@ -1,74 +0,0 @@
const EventEmitter = require('events');
const { logger } = require('./logger');
class AppEventEmitter extends EventEmitter {
emit(event, data) {
logger.debug(
{ event, hasListeners: this.listenerCount(event) > 0 },
'Event emitted',
);
return super.emit(event, data);
}
async emitAsync(event, data) {
logger.debug(
{ event, listenerCount: this.listenerCount(event) },
'Async event emitted',
);
const results = await Promise.allSettled(
this.listeners(event).map((listener) => listener(data)),
);
const failures = results.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
logger.warn(
{ event, failures: failures.map((f) => f.reason?.message) },
'Some event listeners failed',
);
}
return results;
}
onAsync(event, listener) {
this.on(event, async (data) => {
try {
await listener(data);
} catch (error) {
logger.error(
{ event, error: error.message },
'Async event listener error',
);
}
});
}
}
const appEvents = new AppEventEmitter();
appEvents.on('user.created', (user) => {
logger.info(
{ userId: user.id, email: user.email },
'User created event received',
);
});
appEvents.on('project.created', (project) => {
logger.info(
{ projectId: project.id, name: project.name },
'Project created event received',
);
});
appEvents.on('project.published', (project) => {
logger.info(
{ projectId: project.id, slug: project.slug },
'Project published event received',
);
});
appEvents.on('error', (error) => {
logger.error({ error: error.message }, 'Application event error');
});
module.exports = { appEvents, AppEventEmitter };

View File

@ -1,7 +1,5 @@
module.exports = {
...require('./errors'),
...require('./logger'),
...require('./circuit-breaker'),
...require('./events'),
envValidation: require('./env-validation'),
};

View File

@ -59,9 +59,10 @@ frontend/src/
│ ├── register.tsx # Registration page
│ ├── dashboard.tsx # Main dashboard
│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements)
│ ├── runtime.tsx # Tour playback viewer
│ ├── search.tsx # Global search results
│ ├── p/[slug].tsx # Public tour pages (PWA-enabled)
│ ├── p/[projectSlug]/ # Public tour pages (PWA-enabled)
│ │ ├── index.tsx # Production presentation
│ │ └── stage.tsx # Stage preview
│ ├── projects/ # Project CRUD pages
│ ├── tour_pages/ # Tour page management
│ ├── assets/ # Asset library
@ -116,10 +117,10 @@ frontend/src/
│ ├── mediaDuration.ts # Video/audio duration
│ ├── assetUrl.ts # CDN URL resolution
│ ├── extractPageLinks.ts # Extract navigation links from pages
│ ├── StorageManager.ts # Cache API storage for assets
│ ├── parseJson.ts # Safe JSON parsing
│ ├── logger.ts # Client-side logging
│ ├── offline/ # Offline utilities
│ │ └── StorageManager.ts # Cache API storage for assets
│ └── offlineDb/ # IndexedDB (Dexie) setup
├── layouts/ # Page layouts
@ -154,9 +155,9 @@ Visual tour builder with:
- **Always shows `dev` environment content**
- "Save to Stage" button copies dev → stage
### Runtime (`/runtime`)
### Runtime Presentation (`RuntimePresentation.tsx`)
Tour playback viewer with:
The tour playback is handled by the `RuntimePresentation` component (used in public tour pages), featuring:
- Full-screen presentation mode
- Video transitions between pages
- Forward/reverse playback
@ -164,11 +165,11 @@ Tour playback viewer with:
- Keyboard/touch navigation
- Asset preloading with S3 presigned URLs
### Public Tours (`/p/[slug]`)
### Public Tours (`/p/[projectSlug]`)
PWA-enabled public tour access:
- **`/p/[slug]`** - Shows `production` environment (published content)
- **`/p/[slug]/stage`** - Shows `stage` environment (preview)
- **`/p/[projectSlug]`** - Shows `production` environment (published content)
- **`/p/[projectSlug]/stage`** - Shows `stage` environment (preview)
- Offline support via Service Worker
- Asset caching in Cache API and IndexedDB
- Direct S3 downloads via presigned URLs
@ -215,7 +216,9 @@ dispatch(create({ data: newProject }));
| `useOfflineMode` | Detect offline/online status |
| `usePWAPreload` | Preload assets for offline |
| `useStorageQuota` | Monitor IndexedDB usage |
| `useElementSettingsForm` | Element settings form state (~60 fields) |
**Component-specific hooks:**
- `useElementSettingsForm` (`components/ElementSettings/`) - Element settings form state (~60 fields)
## Element Types
@ -262,8 +265,8 @@ Tour pages have a three-tier environment model:
| Environment | Route | Description |
|-------------|-------|-------------|
| `dev` | `/constructor?projectId=` | Editing/draft content |
| `stage` | `/p/[slug]/stage` | Pre-production review |
| `production` | `/p/[slug]` | Published public content |
| `stage` | `/p/[projectSlug]/stage` | Pre-production review |
| `production` | `/p/[projectSlug]` | Published public content |
**Publishing flow:** `dev``stage``production`
@ -278,8 +281,8 @@ The `X-Runtime-Environment` header tells the backend which environment to query.
## Environment Variables
```env
# API URL (defaults to localhost:8080)
NEXT_PUBLIC_API_URL=http://localhost:8080
# Backend API URL (defaults to localhost:8080)
NEXT_PUBLIC_BACK_API=http://localhost:8080
# Frontend port (optional, default 3000)
FRONT_PORT=3000

View File

@ -21,10 +21,10 @@ const nextConfig = {
position: 'bottom-left',
},
typescript: {
ignoreBuildErrors: true,
ignoreBuildErrors: false,
},
eslint: {
ignoreDuringBuilds: true,
ignoreDuringBuilds: false,
},
images: {
unoptimized: true,

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,4 @@
/**
* Access Logs Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/access_logs/access_logsSlice';
import { loadColumns } from './configureAccess_logsCols';
import type { AccessLog } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableAccess_logsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableAccess_logs: React.FC<TableAccess_logsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<AccessLog>
entityName='access_logs'
sliceSelector={(state: RootState) => state.access_logs}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableAccess_logs = createTableComponent<AccessLog>({
entityName: 'access_logs',
sliceSelector: (state) => state.access_logs,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableAccess_logs;

View File

@ -1,159 +1,52 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const ACCESS_LOGS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'environment',
headerName: 'Environment',
type: 'text',
editable: true,
},
{
field: 'user',
headerName: 'User',
type: 'singleSelectRelation',
entityRef: 'users',
editable: true,
},
{ field: 'path', headerName: 'Path', type: 'text', editable: true },
{
field: 'ip_address',
headerName: 'IPaddress',
type: 'text',
editable: true,
},
{
field: 'user_agent',
headerName: 'Useragent',
type: 'text',
editable: true,
},
{
field: 'accessed_at',
headerName: 'Accessedat',
type: 'datetime',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ACCESS_LOGS');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: { id?: string }) => value?.id,
getOptionLabel: (value: { label?: string }) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string) =>
(typeof value === 'object' ? value?.id : value) ?? value,
},
{
field: 'environment',
headerName: 'Environment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'user',
headerName: 'User',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect',
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'path',
headerName: 'Path',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'ip_address',
headerName: 'IPaddress',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'user_agent',
headerName: 'Useragent',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'accessed_at',
headerName: 'Accessedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (_value, row) => new Date(row.accessed_at),
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/access_logs/access_logs-edit/?id=${params?.row?.id}`}
pathView={`/access_logs/access_logs-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'access_logs',
columns: ACCESS_LOGS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Asset Variants Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/asset_variants/asset_variantsSlice';
import { loadColumns } from './configureAsset_variantsCols';
import type { AssetVariant } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableAsset_variantsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableAsset_variants: React.FC<TableAsset_variantsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<AssetVariant>
entityName='asset_variants'
sliceSelector={(state: RootState) => state.asset_variants}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableAsset_variants = createTableComponent<AssetVariant>({
entityName: 'asset_variants',
sliceSelector: (state) => state.asset_variants,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableAsset_variants;

View File

@ -1,143 +1,40 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const ASSET_VARIANTS_COLUMNS: ColumnMetadata[] = [
{
field: 'asset',
headerName: 'Asset',
type: 'singleSelectRelation',
entityRef: 'assets',
editable: true,
},
{
field: 'variant_type',
headerName: 'Varianttype',
type: 'text',
editable: true,
},
{ field: 'cdn_url', headerName: 'CDNURL', type: 'text', editable: true },
{
field: 'width_px',
headerName: 'Width(px)',
type: 'number',
editable: true,
},
{
field: 'height_px',
headerName: 'Height(px)',
type: 'number',
editable: true,
},
{ field: 'size_mb', headerName: 'Size(MB)', type: 'number', editable: true },
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ASSET_VARIANTS');
return [
{
field: 'asset',
headerName: 'Asset',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('assets'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'variant_type',
headerName: 'Varianttype',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'cdn_url',
headerName: 'CDNURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'width_px',
headerName: 'Width(px)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'height_px',
headerName: 'Height(px)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'size_mb',
headerName: 'Size(MB)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/asset_variants/asset_variants-edit/?id=${params?.row?.id}`}
pathView={`/asset_variants/asset_variants-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'asset_variants',
columns: ASSET_VARIANTS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Assets Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/assets/assetsSlice';
import { loadColumns } from './configureAssetsCols';
import type { Asset } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableAssetsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableAssets: React.FC<TableAssetsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<Asset>
entityName='assets'
sliceSelector={(state: RootState) => state.assets}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableAssets = createTableComponent<Asset>({
entityName: 'assets',
sliceSelector: (state) => state.assets,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableAssets;

View File

@ -1,260 +1,74 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const ASSETS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{
field: 'asset_type',
headerName: 'Asset format',
type: 'text',
editable: true,
},
{ field: 'type', headerName: 'Type', type: 'text', editable: true },
{ field: 'cdn_url', headerName: 'CDNURL', type: 'text', editable: true },
{
field: 'storage_key',
headerName: 'Storagekey',
type: 'text',
editable: true,
},
{ field: 'mime_type', headerName: 'MIMEtype', type: 'text', editable: true },
{ field: 'size_mb', headerName: 'Size(MB)', type: 'number', editable: true },
{
field: 'width_px',
headerName: 'Width(px)',
type: 'number',
editable: true,
},
{
field: 'height_px',
headerName: 'Height(px)',
type: 'number',
editable: true,
},
{
field: 'duration_sec',
headerName: 'Duration(sec)',
type: 'number',
editable: true,
},
{ field: 'checksum', headerName: 'Checksum', type: 'text', editable: true },
{
field: 'is_public',
headerName: 'Ispublic',
type: 'boolean',
editable: true,
},
{
field: 'is_deleted',
headerName: 'Isdeleted',
type: 'boolean',
editable: true,
},
{
field: 'deleted_at_time',
headerName: 'Deletedat',
type: 'datetime',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ASSETS');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'asset_type',
headerName: 'Asset format',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'type',
headerName: 'Type',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'cdn_url',
headerName: 'CDNURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'storage_key',
headerName: 'Storagekey',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'mime_type',
headerName: 'MIMEtype',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'size_mb',
headerName: 'Size(MB)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'width_px',
headerName: 'Width(px)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'height_px',
headerName: 'Height(px)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'duration_sec',
headerName: 'Duration(sec)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'checksum',
headerName: 'Checksum',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'is_public',
headerName: 'Ispublic',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'is_deleted',
headerName: 'Isdeleted',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'deleted_at_time',
headerName: 'Deletedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.deleted_at_time),
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/assets/assets-edit/?id=${params?.row?.id}`}
pathView={`/assets/assets-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'assets',
columns: ASSETS_COLUMNS,
});

View File

@ -1,171 +0,0 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import {
Calendar,
Views,
momentLocalizer,
SlotInfo,
EventProps,
} from 'react-big-calendar';
import moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import ListActionsPopover from './ListActionsPopover';
import Link from 'next/link';
import { useAppSelector } from '../stores/hooks';
import { hasPermission } from '../helpers/userPermissions';
const localizer = momentLocalizer(moment);
type TEvent = {
id: string;
title: string;
start: Date;
end: Date;
};
type Props = {
events: any[];
handleDeleteAction: (id: string) => void;
handleCreateEventAction: (slotInfo: SlotInfo) => void;
onDateRangeChange: (range: { start: string; end: string }) => void;
entityName: string;
showField: string;
pathEdit?: string;
pathView?: string;
'start-data-key': string;
'end-data-key': string;
};
const BigCalendar = ({
events,
handleDeleteAction,
handleCreateEventAction,
onDateRangeChange,
entityName,
showField,
pathEdit,
pathView,
'start-data-key': startDataKey,
'end-data-key': endDataKey,
}: Props) => {
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
const prevRange = useRef<{ start: string; end: string } | null>(null);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission =
currentUser &&
hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
const hasCreatePermission =
currentUser &&
hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
const { defaultDate, scrollToTime } = useMemo(
() => ({
defaultDate: new Date(),
scrollToTime: new Date(1970, 1, 1, 6),
}),
[],
);
useEffect(() => {
if (!events || !Array.isArray(events) || !events?.length) return;
const formattedEvents = events.map((event) => ({
...event,
start: new Date(event[startDataKey]),
end: new Date(event[endDataKey]),
title: event[showField],
}));
setMyEvents(formattedEvents);
}, [endDataKey, events, startDataKey, showField]);
const onRangeChange = (range: Date[] | { start: Date; end: Date }) => {
const newRange = { start: '', end: '' };
const format = 'YYYY-MM-DDTHH:mm';
if (Array.isArray(range)) {
newRange.start = moment(range[0]).format(format);
newRange.end = moment(range[range.length - 1]).format(format);
} else {
newRange.start = moment(range.start).format(format);
newRange.end = moment(range.end).format(format);
}
if (newRange.start === newRange.end) {
newRange.end = moment(newRange.end).add(1, 'days').format(format);
}
// check if the range fits in the previous range
if (
prevRange.current &&
prevRange.current.start <= newRange.start &&
prevRange.current.end >= newRange.end
) {
return;
}
prevRange.current = { start: newRange.start, end: newRange.end };
onDateRangeChange(newRange);
};
return (
<div className='h-[600px] p-4'>
<Calendar
defaultDate={defaultDate}
defaultView={Views.MONTH}
events={myEvents}
localizer={localizer}
selectable={hasCreatePermission}
onSelectSlot={handleCreateEventAction}
onRangeChange={onRangeChange}
scrollToTime={scrollToTime}
components={{
event: (props) => (
<MyCustomEvent
{...props}
onDelete={handleDeleteAction}
hasUpdatePermission={hasUpdatePermission}
pathEdit={pathEdit}
pathView={pathView}
/>
),
}}
/>
</div>
);
};
const MyCustomEvent = (
props: {
onDelete: (id: string) => void;
hasUpdatePermission: boolean;
pathEdit?: string;
pathView?: string;
} & EventProps<TEvent>,
) => {
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } =
props;
return (
<div className={'flex items-center justify-between relative'}>
<Link
href={`${pathView}${event.id}`}
className={'text-ellipsis overflow-hidden grow'}
>
{title}
</Link>
<ListActionsPopover
className={'w-2 h-2 text-white'}
iconClassName={'text-white w-5'}
itemId={event.id}
onDelete={onDelete}
pathEdit={`${pathEdit}${event.id}`}
pathView={`${pathView}${event.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
);
};
export default BigCalendar;

View File

@ -1,54 +0,0 @@
export const chartColors = {
default: {
primary: '#00D1B2',
info: '#209CEE',
danger: '#FF3860',
},
};
const randomChartData = (n: number) => {
const data = [];
for (let i = 0; i < n; i++) {
data.push(Math.round(Math.random() * 200));
}
return data;
};
const datasetObject = (color: string, points: number) => {
return {
fill: false,
borderColor: chartColors.default[color],
borderWidth: 2,
borderDash: [],
borderDashOffset: 0.0,
pointBackgroundColor: chartColors.default[color],
pointBorderColor: 'rgba(255,255,255,0)',
pointHoverBackgroundColor: chartColors.default[color],
pointBorderWidth: 20,
pointHoverRadius: 4,
pointHoverBorderWidth: 15,
pointRadius: 4,
data: randomChartData(points),
tension: 0.5,
cubicInterpolationMode: 'default',
};
};
export const sampleChartData = (points = 9) => {
const labels = [];
for (let i = 1; i <= points; i++) {
labels.push(`0${i}`);
}
return {
labels,
datasets: [
datasetObject('primary', points),
datasetObject('info', points),
datasetObject('danger', points),
],
};
};

View File

@ -1,44 +0,0 @@
import React from 'react';
import {
Chart,
LineElement,
PointElement,
LineController,
LinearScale,
CategoryScale,
Tooltip,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
Chart.register(
LineElement,
PointElement,
LineController,
LinearScale,
CategoryScale,
Tooltip,
);
const options = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
display: false,
},
x: {
display: true,
},
},
plugins: {
legend: {
display: false,
},
},
};
const ChartLineSample = ({ data }) => {
return <Line options={options} data={data} className='h-96' />;
};
export default ChartLineSample;

View File

@ -0,0 +1,301 @@
import React from 'react';
import axios from 'axios';
import {
GridColDef,
GridRowParams,
GridRenderCellParams,
GridSingleSelectColDef,
} from '@mui/x-data-grid';
import dataFormatter from '../../helpers/dataFormatter';
import DataGridMultiSelect from '../DataGridMultiSelect';
import ListActionsPopover from '../ListActionsPopover';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
export interface ColumnMetadata {
field: string;
headerName: string;
type?:
| 'text'
| 'boolean'
| 'date'
| 'datetime'
| 'number'
| 'relation'
| 'relationMany'
| 'singleSelectRelation'
| 'image'
| 'actions';
editable?: boolean;
sortable?: boolean;
width?: number;
flex?: number;
minWidth?: number;
entityRef?: string;
displayField?: string;
renderCell?: (params: GridRenderCellParams) => React.ReactElement;
valueFormatter?: (value: unknown) => string;
}
export interface ColumnBuilderConfig {
entityName: string;
entityPath?: string;
columns: ColumnMetadata[];
updatePermission?: string;
}
const DEFAULT_COLUMN_PROPS = {
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
};
async function fetchRelationOptions(
entityRef: string,
user: unknown,
): Promise<Array<{ id: string; label: string }>> {
const permissionKey = `READ_${entityRef.toUpperCase()}`;
if (!hasPermission(user, permissionKey)) {
return [];
}
try {
const response = await axios(`/${entityRef}/autocomplete?limit=100`);
return response.data;
} catch (error) {
logger.error(
`Failed to fetch ${entityRef} options`,
error instanceof Error ? error : { error },
);
return [];
}
}
function getFormatter(
col: ColumnMetadata,
): ((params: { value: unknown }) => string) | undefined {
if (col.valueFormatter) {
const customFormatter = col.valueFormatter;
return ({ value }) => customFormatter(value);
}
switch (col.type) {
case 'boolean':
return ({ value }) => dataFormatter.booleanFormatter(value);
case 'date':
return ({ value }) => dataFormatter.dateFormatter(value);
case 'datetime':
return ({ value }) => dataFormatter.dateTimeFormatter(value);
case 'relation':
return ({ value }) => {
const formatter =
dataFormatter[
`${col.entityRef}OneListFormatter` as keyof typeof dataFormatter
];
return typeof formatter === 'function'
? formatter(value)
: String(value || '');
};
case 'relationMany':
return ({ value }) => {
const formatter =
dataFormatter[
`${col.entityRef}ManyListFormatter` as keyof typeof dataFormatter
];
if (typeof formatter === 'function') {
const result = formatter(value);
return Array.isArray(result)
? result.join(', ')
: String(result || '');
}
return Array.isArray(value)
? value.map((v: { name?: string }) => v?.name || '').join(', ')
: '';
};
default:
return undefined;
}
}
function buildColumn(
col: ColumnMetadata,
hasUpdatePermission: boolean,
_entityPath: string,
valueOptionsMap: Map<string, Array<{ id: string; label: string }>>,
): GridColDef {
const baseColumn: GridColDef = {
...DEFAULT_COLUMN_PROPS,
field: col.field,
headerName: col.headerName,
editable:
col.editable !== undefined ? col.editable && hasUpdatePermission : false,
sortable: col.sortable !== undefined ? col.sortable : true,
};
if (col.flex !== undefined) baseColumn.flex = col.flex;
if (col.width !== undefined) baseColumn.width = col.width;
if (col.minWidth !== undefined) baseColumn.minWidth = col.minWidth;
const formatter = getFormatter(col);
if (formatter) {
baseColumn.valueFormatter = formatter;
}
if (col.renderCell) {
baseColumn.renderCell = col.renderCell;
}
if (col.type === 'relation' || col.type === 'relationMany') {
baseColumn.type = 'singleSelect' as const;
baseColumn.sortable = false;
if (col.entityRef) {
const entityRef = col.entityRef;
baseColumn.renderEditCell = (params) => (
<DataGridMultiSelect {...params} entityName={entityRef} />
);
}
}
if (col.type === 'singleSelectRelation' && col.entityRef) {
const singleSelectColumn = baseColumn as GridSingleSelectColDef;
singleSelectColumn.type = 'singleSelect';
singleSelectColumn.sortable = false;
singleSelectColumn.getOptionValue = (value: { id?: string }) => value?.id;
singleSelectColumn.getOptionLabel = (value: { label?: string }) =>
value?.label;
singleSelectColumn.valueOptions = valueOptionsMap.get(col.entityRef) || [];
singleSelectColumn.valueGetter = (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value;
return singleSelectColumn;
}
if (col.type === 'datetime') {
baseColumn.type = 'dateTime' as const;
baseColumn.valueGetter = (_value, row) => new Date(row[col.field]);
}
if (col.type === 'number') {
baseColumn.type = 'number';
}
if (col.type === 'boolean') {
baseColumn.type = 'boolean';
}
if (col.type === 'image') {
baseColumn.sortable = false;
baseColumn.renderCell =
col.renderCell ||
((params: GridRenderCellParams) => {
const imageUrl =
params.value &&
Array.isArray(params.value) &&
params.value[0]?.publicUrl;
return imageUrl ? (
<span
role='img'
aria-label='thumbnail'
style={{
display: 'inline-block',
height: 40,
width: 40,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: 4,
}}
/>
) : null;
});
}
return baseColumn;
}
function buildActionsColumn(
onDelete: (id: string) => void,
entityPath: string,
hasUpdatePermission: boolean,
): GridColDef {
return {
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/${entityPath}/${entityPath}-edit/?id=${params?.row?.id}`}
pathView={`/${entityPath}/${entityPath}-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
],
};
}
export async function buildColumns(
config: ColumnBuilderConfig,
onDelete: (id: string) => void,
user: unknown,
): Promise<GridColDef[]> {
const entityPath = config.entityPath || config.entityName;
const updatePermission =
config.updatePermission || `UPDATE_${config.entityName.toUpperCase()}`;
const hasUpdatePermission = hasPermission(user, updatePermission);
// Collect all singleSelectRelation entityRefs and fetch their options
const singleSelectRelations = config.columns
.filter((col) => col.type === 'singleSelectRelation' && col.entityRef)
.map((col) => col.entityRef as string);
const uniqueEntityRefs = Array.from(new Set(singleSelectRelations));
const valueOptionsMap = new Map<
string,
Array<{ id: string; label: string }>
>();
await Promise.all(
uniqueEntityRefs.map(async (entityRef) => {
const options = await fetchRelationOptions(entityRef, user);
valueOptionsMap.set(entityRef, options);
}),
);
const dataColumns = config.columns
.filter((col) => col.type !== 'actions')
.map((col) =>
buildColumn(col, hasUpdatePermission, entityPath, valueOptionsMap),
);
const hasActionsColumn = config.columns.some((col) => col.type === 'actions');
if (hasActionsColumn) {
const actionsColumn = buildActionsColumn(
onDelete,
entityPath,
hasUpdatePermission,
);
return [...dataColumns, actionsColumn];
}
return dataColumns;
}
export function createColumnLoader(config: ColumnBuilderConfig) {
return async (
onDelete: (id: string) => void,
_entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
return buildColumns(config, onDelete, user);
};
}
export default buildColumns;

View File

@ -0,0 +1,118 @@
/**
* Table Component Factory
*
* Generates entity table components from configuration.
* Reduces boilerplate in entity-specific table components.
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
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 { 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
*/
export interface TableComponentConfig<T extends BaseEntity> {
/** Entity name (e.g., 'roles', 'users') */
entityName: string;
/** Redux slice selector */
sliceSelector: (state: RootState) => EntitySliceState<T>;
/** Fetch action thunk */
fetchAction: AsyncThunk<
T | { rows: T[]; count: number },
{ id?: string; query?: string },
object
>;
/** Update action thunk */
updateAction: AsyncThunk<T, { id: string; data: Partial<T> }, object>;
/** Delete single item action thunk */
deleteAction: AsyncThunk<void, string, object>;
/** Delete multiple items action thunk */
deleteByIdsAction: AsyncThunk<void, string[], object>;
/** Set refetch flag action */
setRefetchAction: (refetch: boolean) => { type: string; payload: boolean };
/** Column loader function */
loadColumnsFunction: (
onDelete: (id: string) => void,
entityName: string,
user: unknown,
) => Promise<GridColDef[]>;
}
/**
* Props for generated table components
*/
export interface TableComponentProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
/**
* Create a table component from configuration
*
* @example
* import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/roles/rolesSlice';
* import { loadColumns } from './configureRolesCols';
*
* const TableRoles = createTableComponent<Role>({
* entityName: 'roles',
* sliceSelector: (state) => state.roles,
* fetchAction: fetch,
* updateAction: update,
* deleteAction: deleteItem,
* deleteByIdsAction: deleteItemsByIds,
* setRefetchAction: setRefetch,
* loadColumnsFunction: loadColumns,
* });
*
* export default TableRoles;
*/
export function createTableComponent<T extends BaseEntity>(
config: TableComponentConfig<T>,
): React.FC<TableComponentProps> {
const TableComponent: React.FC<TableComponentProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<T>
entityName={config.entityName}
sliceSelector={config.sliceSelector}
fetchAction={config.fetchAction}
updateAction={config.updateAction}
deleteAction={config.deleteAction}
deleteByIdsAction={config.deleteByIdsAction}
setRefetchAction={config.setRefetchAction}
loadColumnsFunction={config.loadColumnsFunction}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
TableComponent.displayName = `Table${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`;
return TableComponent;
}
export default createTableComponent;

View File

@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { ColorButtonKey } from '../interfaces';
import BaseButton from './BaseButton';
import ImagesUploader from './Uploaders/ImagesUploader';
import FileUploader from './Uploaders/UploadService';
import { mdiReload } from '@mdi/js';
import { useAppSelector } from '../stores/hooks';

View File

@ -1,51 +0,0 @@
import React from 'react';
import KanbanColumn from './KanbanColumn';
import { AsyncThunk } from '@reduxjs/toolkit';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
type Props = {
columns: Array<{ id: string; label: string }>;
filtersQuery: string;
entityName: string;
columnFieldName: string;
showFieldName: string;
deleteThunk: AsyncThunk<any, any, any>;
updateThunk: AsyncThunk<any, any, any>;
};
const KanbanBoard = ({
columns,
entityName,
columnFieldName,
filtersQuery,
showFieldName,
deleteThunk,
updateThunk,
}: Props) => {
return (
<div
className={
'pb-2 flex-grow min-h-[400px] flex-1 grid grid-rows-1 auto-cols-min grid-flow-col gap-x-3 overflow-y-hidden overflow-x-auto'
}
>
<DndProvider backend={HTML5Backend}>
{columns.map((column) => (
<div key={column.id}>
<KanbanColumn
entityName={entityName}
columnFieldName={columnFieldName}
showFieldName={showFieldName}
column={column}
filtersQuery={filtersQuery}
deleteThunk={deleteThunk}
updateThunk={updateThunk}
/>
</div>
))}
</DndProvider>
</div>
);
};
export default KanbanBoard;

View File

@ -1,70 +0,0 @@
import React, { useRef, useEffect } from 'react';
import Link from 'next/link';
import moment from 'moment';
import ListActionsPopover from '../ListActionsPopover';
import { DragSourceMonitor, useDrag } from 'react-dnd';
type Props = {
item: any;
column: { id: string; label: string };
entityName: string;
showFieldName: string;
setItemIdToDelete: (id: string) => void;
};
const KanbanCard = ({
item,
entityName,
showFieldName,
setItemIdToDelete,
column,
}: Props) => {
const cardRef = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag(
() => ({
type: 'box',
item: { item, column },
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
}),
[item],
);
// Connect the drag ref to the DOM element
useEffect(() => {
if (cardRef.current) {
drag(cardRef.current);
}
}, [drag]);
return (
<div
ref={cardRef}
className={`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
>
<div className={'flex items-center justify-between'}>
<Link
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
className={'text-base font-semibold'}
>
{item[showFieldName] ?? 'No data'}
</Link>
</div>
<div className={'flex items-center justify-between'}>
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
<ListActionsPopover
itemId={item.id}
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
onDelete={(id) => setItemIdToDelete(id)}
hasUpdatePermission={true}
className={'w-2 h-2 text-white'}
iconClassName={'w-5'}
/>
</div>
</div>
);
};
export default KanbanCard;

View File

@ -1,218 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import Axios from 'axios';
import CardBox from '../CardBox';
import CardBoxModal from '../CardBoxModal';
import { AsyncThunk } from '@reduxjs/toolkit';
import { useDrop } from 'react-dnd';
import KanbanCard from './KanbanCard';
import { logger } from '../../lib/logger';
type Props = {
column: { id: string; label: string };
entityName: string;
columnFieldName: string;
showFieldName: string;
filtersQuery: any;
deleteThunk: AsyncThunk<any, any, any>;
updateThunk: AsyncThunk<any, any, any>;
};
type DropResult = {
sourceColumn: { id: string; label: string };
item: any;
};
const perPage = 10;
const KanbanColumn = ({
column,
entityName,
columnFieldName,
showFieldName,
filtersQuery,
deleteThunk,
updateThunk,
}: Props) => {
const [currentPage, setCurrentPage] = useState(0);
const [count, setCount] = useState(0);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [itemIdToDelete, setItemIdToDelete] = useState('');
const currentUser = useAppSelector((state) => state.auth.currentUser);
const listInnerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useAppDispatch();
const [{ dropResult }, drop] = useDrop<
{
item: any;
column: {
id: string;
label: string;
};
},
unknown,
{
dropResult: DropResult;
}
>(
() => ({
accept: 'box',
drop: ({
item,
column: sourceColumn,
}: {
item: any;
column: { id: string; label: string };
}) => {
if (sourceColumn.id === column.id) return;
dispatch(
updateThunk({
id: item.id,
data: {
[columnFieldName]: column.id,
},
}),
).then((res) => {
setData((prevState) => (prevState ? [...prevState, item] : [item]));
setCount((prevState) => prevState + 1);
});
return { sourceColumn, item };
},
collect: (monitor) => ({
dropResult: monitor.getDropResult(),
}),
}),
[],
);
const loadData = useCallback(
(page: number, filters = '') => {
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
setLoading(true);
Axios.get(`${entityName}${query}`)
.then((res) => {
setData((prevState) =>
page === 0 ? res.data.rows : [...prevState, ...res.data.rows],
);
setCount(res.data.count);
setCurrentPage(page);
})
.catch((err) => {
logger.error(
'Failed to load data:',
err instanceof Error ? err : { error: err },
);
})
.finally(() => {
setLoading(false);
});
},
[currentUser, column],
);
useEffect(() => {
if (!currentUser) return;
loadData(0, filtersQuery);
}, [currentUser, loadData, filtersQuery]);
useEffect(() => {
loadData(0, filtersQuery);
}, [loadData, filtersQuery]);
useEffect(() => {
if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) {
setData((prevState) =>
prevState.filter((item) => item.id !== dropResult.item.id),
);
setCount((prevState) => prevState - 1);
}
}, [dropResult]);
const onScroll = () => {
if (listInnerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
if (Math.floor(scrollTop + clientHeight) === scrollHeight) {
if (data.length < count && !loading) {
loadData(currentPage + 1, filtersQuery);
}
}
}
};
const onDeleteConfirm = () => {
if (!itemIdToDelete) return;
dispatch(deleteThunk(itemIdToDelete))
.then((res) => {
if (res.meta.requestStatus === 'fulfilled') {
setItemIdToDelete('');
loadData(0, filtersQuery);
}
})
.catch((err) => {
logger.error(
'Delete operation failed:',
err instanceof Error ? err : { error: err },
);
})
.finally(() => {
setItemIdToDelete('');
});
};
return (
<>
<CardBox
hasComponentLayout
className={
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
}
>
<div className={'flex items-center justify-between p-3'}>
<p className={'uppercase'}>{column.label}</p>
<p>{count}</p>
</div>
<div
ref={(node) => {
drop(node);
listInnerRef.current = node;
}}
className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'}
onScroll={onScroll}
>
{data?.map((item) => (
<div key={item.id}>
<KanbanCard
item={item}
column={column}
showFieldName={showFieldName}
entityName={entityName}
setItemIdToDelete={setItemIdToDelete}
/>
</div>
))}
{!data?.length && (
<p className={'text-center py-8 bg-gray-50 dark:bg-dark-800'}>
No data
</p>
)}
</div>
</CardBox>
<CardBoxModal
title='Please confirm'
buttonColor='info'
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={!!itemIdToDelete}
onConfirm={onDeleteConfirm}
onCancel={() => setItemIdToDelete('')}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
</>
);
};
export default KanbanColumn;

View File

@ -1,9 +1,4 @@
/**
* Permissions Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/permissions/permissionsSlice';
import { loadColumns } from './configurePermissionsCols';
import type { PermissionEntity } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePermissionsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePermissions: React.FC<TablePermissionsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<PermissionEntity>
entityName='permissions'
sliceSelector={(state: RootState) => state.permissions}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TablePermissions = createTableComponent<PermissionEntity>({
entityName: 'permissions',
sliceSelector: (state) => state.permissions,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TablePermissions;

View File

@ -1,68 +1,14 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PERMISSIONS_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PERMISSIONS');
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/permissions/permissions-edit/?id=${params?.row?.id}`}
pathView={`/permissions/permissions-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'permissions',
columns: PERMISSIONS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Presigned URL Requests Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,34 +8,16 @@ import {
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
import { loadColumns } from './configurePresigned_url_requestsCols';
import type { PresignedUrlRequest } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePresigned_url_requestsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePresigned_url_requests: React.FC<
TablePresigned_url_requestsProps
> = ({ filterItems, setFilterItems, filters }) => {
return (
<GenericTable<PresignedUrlRequest>
entityName='presigned_url_requests'
sliceSelector={(state: RootState) => state.presigned_url_requests}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TablePresigned_url_requests = createTableComponent<PresignedUrlRequest>({
entityName: 'presigned_url_requests',
sliceSelector: (state) => state.presigned_url_requests,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TablePresigned_url_requests;

View File

@ -1,190 +1,54 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PRESIGNED_URL_REQUESTS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'user',
headerName: 'User',
type: 'singleSelectRelation',
entityRef: 'users',
editable: true,
},
{ field: 'purpose', headerName: 'Purpose', type: 'text', editable: true },
{
field: 'asset_type',
headerName: 'Assettype',
type: 'text',
editable: true,
},
{
field: 'requested_key',
headerName: 'Requestedkey',
type: 'text',
editable: true,
},
{ field: 'mime_type', headerName: 'MIMEtype', type: 'text', editable: true },
{
field: 'requested_size_mb',
headerName: 'Requestedsize(MB)',
type: 'number',
editable: true,
},
{
field: 'expires_at',
headerName: 'Expiresat',
type: 'datetime',
editable: true,
},
{ field: 'status', headerName: 'Status', type: 'text', editable: true },
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(
user,
'UPDATE_PRESIGNED_URL_REQUESTS',
);
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'user',
headerName: 'User',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'purpose',
headerName: 'Purpose',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'asset_type',
headerName: 'Assettype',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'requested_key',
headerName: 'Requestedkey',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'mime_type',
headerName: 'MIMEtype',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'requested_size_mb',
headerName: 'Requestedsize(MB)',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'expires_at',
headerName: 'Expiresat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.expires_at),
},
{
field: 'status',
headerName: 'Status',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/presigned_url_requests/presigned_url_requests-edit/?id=${params?.row?.id}`}
pathView={`/presigned_url_requests/presigned_url_requests-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'presigned_url_requests',
columns: PRESIGNED_URL_REQUESTS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Project Audio Tracks Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
import { loadColumns } from './configureProject_audio_tracksCols';
import type { ProjectAudioTrack } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableProject_audio_tracksProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableProject_audio_tracks: React.FC<TableProject_audio_tracksProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<ProjectAudioTrack>
entityName='project_audio_tracks'
sliceSelector={(state: RootState) => state.project_audio_tracks}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableProject_audio_tracks = createTableComponent<ProjectAudioTrack>({
entityName: 'project_audio_tracks',
sliceSelector: (state) => state.project_audio_tracks,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableProject_audio_tracks;

View File

@ -1,196 +1,49 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PROJECT_AUDIO_TRACKS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'environment',
headerName: 'Environment',
type: 'text',
editable: true,
},
{
field: 'source_key',
headerName: 'Sourcekey',
type: 'text',
editable: true,
},
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
{ field: 'url', headerName: 'URL', type: 'text', editable: true },
{ field: 'loop', headerName: 'Loop', type: 'boolean', editable: true },
{ field: 'volume', headerName: 'Volume', type: 'number', editable: true },
{
field: 'sort_order',
headerName: 'Sortorder',
type: 'number',
editable: true,
},
{
field: 'is_enabled',
headerName: 'Isenabled',
type: 'boolean',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(
user,
'UPDATE_PROJECT_AUDIO_TRACKS',
);
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'environment',
headerName: 'Environment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'source_key',
headerName: 'Sourcekey',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'url',
headerName: 'URL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'loop',
headerName: 'Loop',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'volume',
headerName: 'Volume',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'sort_order',
headerName: 'Sortorder',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'is_enabled',
headerName: 'Isenabled',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/project_audio_tracks/project_audio_tracks-edit/?id=${params?.row?.id}`}
pathView={`/project_audio_tracks/project_audio_tracks-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'project_audio_tracks',
columns: PROJECT_AUDIO_TRACKS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Project Memberships Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/project_memberships/project_membershipsSlice';
import { loadColumns } from './configureProject_membershipsCols';
import type { ProjectMembership } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableProject_membershipsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableProject_memberships: React.FC<TableProject_membershipsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<ProjectMembership>
entityName='project_memberships'
sliceSelector={(state: RootState) => state.project_memberships}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableProject_memberships = createTableComponent<ProjectMembership>({
entityName: 'project_memberships',
sliceSelector: (state) => state.project_memberships,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableProject_memberships;

View File

@ -1,154 +1,51 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PROJECT_MEMBERSHIPS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'user',
headerName: 'User',
type: 'singleSelectRelation',
entityRef: 'users',
editable: true,
},
{
field: 'access_level',
headerName: 'Accesslevel',
type: 'text',
editable: true,
},
{
field: 'is_active',
headerName: 'Isactive',
type: 'boolean',
editable: true,
},
{
field: 'invited_at',
headerName: 'Invitedat',
type: 'datetime',
editable: true,
},
{
field: 'accepted_at',
headerName: 'Acceptedat',
type: 'datetime',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PROJECT_MEMBERSHIPS');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'user',
headerName: 'User',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'access_level',
headerName: 'Accesslevel',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'is_active',
headerName: 'Isactive',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'invited_at',
headerName: 'Invitedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.invited_at),
},
{
field: 'accepted_at',
headerName: 'Acceptedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.accepted_at),
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/project_memberships/project_memberships-edit/?id=${params?.row?.id}`}
pathView={`/project_memberships/project_memberships-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'project_memberships',
columns: PROJECT_MEMBERSHIPS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Projects Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/projects/projectsSlice';
import { loadColumns } from './configureProjectsCols';
import type { Project } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableProjectsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableProjects: React.FC<TableProjectsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<Project>
entityName='projects'
sliceSelector={(state: RootState) => state.projects}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableProjects = createTableComponent<Project>({
entityName: 'projects',
sliceSelector: (state) => state.projects,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableProjects;

View File

@ -1,193 +1,64 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PROJECTS_COLUMNS: ColumnMetadata[] = [
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
{
field: 'description',
headerName: 'Description',
type: 'text',
editable: true,
},
{ field: 'logo_url', headerName: 'LogoURL', type: 'text', editable: true },
{
field: 'favicon_url',
headerName: 'FaviconURL',
type: 'text',
editable: true,
},
{
field: 'og_image_url',
headerName: 'OGImageURL',
type: 'text',
editable: true,
},
{
field: 'theme_config_json',
headerName: 'ThemeconfigJSON',
type: 'text',
editable: true,
},
{
field: 'custom_css_json',
headerName: 'CustomCSSJSON',
type: 'text',
editable: true,
},
{
field: 'cdn_base_url',
headerName: 'CDNbaseURL',
type: 'text',
editable: true,
},
{
field: 'is_deleted',
headerName: 'Isdeleted',
type: 'boolean',
editable: true,
},
{
field: 'deleted_at_time',
headerName: 'Deletedat',
type: 'datetime',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PROJECTS');
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'logo_url',
headerName: 'LogoURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'favicon_url',
headerName: 'FaviconURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'og_image_url',
headerName: 'OGImageURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'theme_config_json',
headerName: 'ThemeconfigJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'custom_css_json',
headerName: 'CustomCSSJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'cdn_base_url',
headerName: 'CDNbaseURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'is_deleted',
headerName: 'Isdeleted',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'deleted_at_time',
headerName: 'Deletedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.deleted_at_time),
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/projects/projects-edit/?id=${params?.row?.id}`}
pathView={`/projects/projects-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'projects',
columns: PROJECTS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Publish Events Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/publish_events/publish_eventsSlice';
import { loadColumns } from './configurePublish_eventsCols';
import type { PublishEvent } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePublish_eventsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePublish_events: React.FC<TablePublish_eventsProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<PublishEvent>
entityName='publish_events'
sliceSelector={(state: RootState) => state.publish_events}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TablePublish_events = createTableComponent<PublishEvent>({
entityName: 'publish_events',
sliceSelector: (state) => state.publish_events,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TablePublish_events;

View File

@ -1,242 +1,90 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PUBLISH_EVENTS_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'user',
headerName: 'User',
type: 'singleSelectRelation',
entityRef: 'users',
editable: true,
},
{
field: 'from_environment',
headerName: 'Fromenvironment',
type: 'text',
editable: true,
},
{
field: 'to_environment',
headerName: 'Toenvironment',
type: 'text',
editable: true,
},
{
field: 'started_at',
headerName: 'Startedat',
type: 'datetime',
editable: true,
},
{
field: 'finished_at',
headerName: 'Finishedat',
type: 'datetime',
editable: true,
},
{
field: 'title',
headerName: 'Title',
type: 'text',
editable: true,
minWidth: 160,
},
{
field: 'description',
headerName: 'Description',
type: 'text',
editable: true,
minWidth: 220,
},
{ field: 'status', headerName: 'Status', type: 'text', editable: true },
{
field: 'error_message',
headerName: 'Errormessage',
type: 'text',
editable: true,
},
{
field: 'pages_copied',
headerName: 'Pagescopied',
type: 'number',
editable: true,
},
{
field: 'transitions_copied',
headerName: 'Transitionscopied',
type: 'number',
editable: true,
},
{
field: 'audios_copied',
headerName: 'Audioscopied',
type: 'number',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PUBLISH_EVENTS');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'user',
headerName: 'User',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('users'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'from_environment',
headerName: 'Fromenvironment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'to_environment',
headerName: 'Toenvironment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'started_at',
headerName: 'Startedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.started_at),
},
{
field: 'finished_at',
headerName: 'Finishedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.finished_at),
},
{
field: 'title',
headerName: 'Title',
flex: 1,
minWidth: 160,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'status',
headerName: 'Status',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'error_message',
headerName: 'Errormessage',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'pages_copied',
headerName: 'Pagescopied',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'transitions_copied',
headerName: 'Transitionscopied',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'audios_copied',
headerName: 'Audioscopied',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/publish_events/publish_events-edit/?id=${params?.row?.id}`}
pathView={`/publish_events/publish_events-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'publish_events',
columns: PUBLISH_EVENTS_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* PWA Caches Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/pwa_caches/pwa_cachesSlice';
import { loadColumns } from './configurePwa_cachesCols';
import type { PwaCache } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TablePwa_cachesProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePwa_caches: React.FC<TablePwa_cachesProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<PwaCache>
entityName='pwa_caches'
sliceSelector={(state: RootState) => state.pwa_caches}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TablePwa_caches = createTableComponent<PwaCache>({
entityName: 'pwa_caches',
sliceSelector: (state) => state.pwa_caches,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TablePwa_caches;

View File

@ -1,154 +1,56 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const PWA_CACHES_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'environment',
headerName: 'Environment',
type: 'text',
editable: true,
},
{
field: 'cache_version',
headerName: 'Cacheversion',
type: 'text',
editable: true,
},
{
field: 'manifest_json',
headerName: 'ManifestJSON',
type: 'text',
editable: true,
},
{
field: 'asset_list_json',
headerName: 'AssetlistJSON',
type: 'text',
editable: true,
},
{
field: 'generated_at',
headerName: 'Generatedat',
type: 'datetime',
editable: true,
},
{
field: 'is_active',
headerName: 'Isactive',
type: 'boolean',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_PWA_CACHES');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'environment',
headerName: 'Environment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'cache_version',
headerName: 'Cacheversion',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'manifest_json',
headerName: 'ManifestJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'asset_list_json',
headerName: 'AssetlistJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'generated_at',
headerName: 'Generatedat',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime' as const,
valueGetter: (_value, row) => new Date(row.generated_at),
},
{
field: 'is_active',
headerName: 'Isactive',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/pwa_caches/pwa_caches-edit/?id=${params?.row?.id}`}
pathView={`/pwa_caches/pwa_caches-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'pwa_caches',
columns: PWA_CACHES_COLUMNS,
});

View File

@ -1,9 +1,4 @@
/**
* Roles Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/roles/rolesSlice';
import { loadColumns } from './configureRolesCols';
import type { Role } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableRolesProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableRoles: React.FC<TableRolesProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<Role>
entityName='roles'
sliceSelector={(state: RootState) => state.roles}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableRoles = createTableComponent<Role>({
entityName: 'roles',
sliceSelector: (state) => state.roles,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableRoles;

View File

@ -1,89 +1,30 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import dataFormatter from '../../helpers/dataFormatter';
import DataGridMultiSelect from '../DataGridMultiSelect';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const ROLES_COLUMNS: ColumnMetadata[] = [
{
field: 'name',
headerName: 'Name',
type: 'text',
editable: true,
},
{
field: 'permissions',
headerName: 'Permissions',
type: 'relationMany',
entityRef: 'permissions',
editable: false,
},
{
field: 'actions',
headerName: '',
type: 'actions',
},
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ROLES');
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'permissions',
headerName: 'Permissions',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect' as const,
valueFormatter: ({ value }) =>
dataFormatter.permissionsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'permissions'} />
),
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/roles/roles-edit/?id=${params?.row?.id}`}
pathView={`/roles/roles-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'roles',
columns: ROLES_COLUMNS,
});

View File

@ -1,149 +0,0 @@
import { mdiEye, mdiTrashCan } from '@mdi/js';
import React, { useState } from 'react';
import { useSampleClients } from '../hooks/sampleData';
import { Client } from '../interfaces';
import BaseButton from './BaseButton';
import BaseButtons from './BaseButtons';
import CardBoxModal from './CardBoxModal';
import UserAvatar from './UserAvatar';
const TableSampleClients = () => {
const { clients } = useSampleClients();
const perPage = 5;
const [currentPage, setCurrentPage] = useState(0);
const clientsPaginated = clients.slice(
perPage * currentPage,
perPage * (currentPage + 1),
);
const numPages = clients.length / perPage;
const pagesList = [];
for (let i = 0; i < numPages; i++) {
pagesList.push(i);
}
const [isModalInfoActive, setIsModalInfoActive] = useState(false);
const [isModalTrashActive, setIsModalTrashActive] = useState(false);
const handleModalAction = () => {
setIsModalInfoActive(false);
setIsModalTrashActive(false);
};
return (
<>
<CardBoxModal
title='Sample modal'
buttonColor='info'
buttonLabel='Done'
isActive={isModalInfoActive}
onConfirm={handleModalAction}
onCancel={handleModalAction}
>
<p>
Lorem ipsum dolor sit amet <b>adipiscing elit</b>
</p>
<p>This is sample modal</p>
</CardBoxModal>
<CardBoxModal
title='Please confirm'
buttonColor='danger'
buttonLabel='Confirm'
isActive={isModalTrashActive}
onConfirm={handleModalAction}
onCancel={handleModalAction}
>
<p>
Lorem ipsum dolor sit amet <b>adipiscing elit</b>
</p>
<p>This is sample modal</p>
</CardBoxModal>
<table>
<thead>
<tr>
<th />
<th>Name</th>
<th>Company</th>
<th>City</th>
<th>Progress</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{clientsPaginated.map((client: Client) => (
<tr key={client.id}>
<td className='border-b-0 lg:w-6 before:hidden'>
<UserAvatar
username={client.name}
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
/>
</td>
<td data-label='Name'>{client.name}</td>
<td data-label='Company'>{client.company}</td>
<td data-label='City'>{client.city}</td>
<td data-label='Progress' className='lg:w-32'>
<progress
className='flex w-2/5 self-center lg:w-full'
max='100'
value={client.progress}
>
{client.progress}
</progress>
</td>
<td data-label='Created' className='lg:w-1 whitespace-nowrap'>
<small className='text-gray-500 dark:text-slate-400'>
{client.created}
</small>
</td>
<td className='before:hidden lg:w-1 whitespace-nowrap'>
<BaseButtons type='justify-start lg:justify-end' noWrap>
<BaseButton
color='info'
icon={mdiEye}
onClick={() => setIsModalInfoActive(true)}
small
/>
<BaseButton
color='danger'
icon={mdiTrashCan}
onClick={() => setIsModalTrashActive(true)}
small
/>
</BaseButtons>
</td>
</tr>
))}
</tbody>
</table>
<div className='p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800'>
<div className='flex flex-col md:flex-row items-center justify-between py-3 md:py-0'>
<BaseButtons>
{pagesList.map((page) => (
<BaseButton
key={page}
active={page === currentPage}
label={page + 1}
color={page === currentPage ? 'lightDark' : 'whiteDark'}
small
onClick={() => setCurrentPage(page)}
/>
))}
</BaseButtons>
<small className='mt-6 md:mt-0'>
Page {currentPage + 1} of {numPages}
</small>
</div>
</div>
</>
);
};
export default TableSampleClients;

View File

@ -19,6 +19,13 @@ import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import { useAppSelector } from '../stores/hooks';
import { logger } from '../lib/logger';
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
import {
toRoutePath,
compareRoutes,
getProjectId,
getRows,
} from '../lib/tourFlowHelpers';
type TourPage = {
id: string;
@ -54,79 +61,6 @@ type ListEntry = {
parentPageId: string;
};
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
const sanitizeSlug = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const buildUniqueSlug = (baseValue: string, usedSlugs: Set<string>) => {
const baseSlug = sanitizeSlug(baseValue) || 'page';
if (!usedSlugs.has(baseSlug)) return baseSlug;
let suffix = 2;
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
suffix += 1;
}
return `${baseSlug}-${suffix}`;
};
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const toRoutePath = (value?: string) => {
const raw = String(value || '').trim();
if (!raw || raw === '/') return '/';
const normalized = raw
.replace(/^[^/]+:\/\//, '')
.replace(/^\/*/, '')
.replace(/\/{2,}/g, '/');
const withSlash = `/${normalized}`;
return withSlash.length > 1 ? withSlash.replace(/\/$/, '') : withSlash;
};
const routeParts = (value?: string) =>
toRoutePath(value).split('/').filter(Boolean);
const compareRoutes = (a?: string, b?: string) => {
const aPath = toRoutePath(a);
const bPath = toRoutePath(b);
if (aPath === bPath) return 0;
const aParts = routeParts(aPath);
const bParts = routeParts(bPath);
const maxLen = Math.max(aParts.length, bParts.length);
for (let index = 0; index < maxLen; index += 1) {
const aPart = aParts[index];
const bPart = bParts[index];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const compare = aPart.localeCompare(bPart);
if (compare !== 0) return compare;
}
return aParts.length - bParts.length;
};
const getProjectId = (item: {
projectId?: string;
project?: { id?: string } | string;
}) => {
if (item.projectId) return item.projectId;
if (typeof item.project === 'string') return item.project;
if (item.project?.id) return item.project.id;
return '';
};
const TourFlowManager = () => {
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);

View File

@ -1,9 +1,4 @@
/**
* Tour Pages Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/tour_pages/tour_pagesSlice';
import { loadColumns } from './configureTour_pagesCols';
import type { TourPage } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableTour_pagesProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableTour_pages: React.FC<TableTour_pagesProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<TourPage>
entityName='tour_pages'
sliceSelector={(state: RootState) => state.tour_pages}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableTour_pages = createTableComponent<TourPage>({
entityName: 'tour_pages',
sliceSelector: (state) => state.tour_pages,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableTour_pages;

View File

@ -1,215 +1,76 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const TOUR_PAGES_COLUMNS: ColumnMetadata[] = [
{
field: 'project',
headerName: 'Project',
type: 'singleSelectRelation',
entityRef: 'projects',
editable: true,
},
{
field: 'environment',
headerName: 'Environment',
type: 'text',
editable: true,
},
{
field: 'source_key',
headerName: 'Sourcekey',
type: 'text',
editable: true,
},
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
{
field: 'sort_order',
headerName: 'Sortorder',
type: 'number',
editable: true,
},
{
field: 'background_image_url',
headerName: 'BackgroundimageURL',
type: 'text',
editable: true,
},
{
field: 'background_video_url',
headerName: 'BackgroundvideoURL',
type: 'text',
editable: true,
},
{
field: 'background_audio_url',
headerName: 'BackgroundaudioURL',
type: 'text',
editable: true,
},
{
field: 'background_loop',
headerName: 'Backgroundloop',
type: 'boolean',
editable: true,
},
{
field: 'requires_auth',
headerName: 'Requiresauth',
type: 'boolean',
editable: true,
},
{
field: 'ui_schema_json',
headerName: 'UIschemaJSON',
type: 'text',
editable: true,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_TOUR_PAGES');
return [
{
field: 'project',
headerName: 'Project',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('projects'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'environment',
headerName: 'Environment',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'source_key',
headerName: 'Sourcekey',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'sort_order',
headerName: 'Sortorder',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'number',
},
{
field: 'background_image_url',
headerName: 'BackgroundimageURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'background_video_url',
headerName: 'BackgroundvideoURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'background_audio_url',
headerName: 'BackgroundaudioURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'background_loop',
headerName: 'Backgroundloop',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'requires_auth',
headerName: 'Requiresauth',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'ui_schema_json',
headerName: 'UIschemaJSON',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/tour_pages/tour_pages-edit/?id=${params?.row?.id}`}
pathView={`/tour_pages/tour_pages-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'tour_pages',
columns: TOUR_PAGES_COLUMNS,
});

View File

@ -1,133 +0,0 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import FileUploader from 'components/FormItems/uploaders/UploadService';
import Errors from '../../../components/FormItems/error/errors';
const FilesUploader = (props) => {
const { value, onChange, schema, path, max, readonly } = props;
const [loading, setLoading] = useState(false);
const inputElement = useRef(null);
const valuesArr = () => {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
};
const fileList = () => {
return valuesArr().map((item) => ({
uid: item.id || undefined,
name: item.name,
status: 'done',
url: item.publicUrl,
}));
};
const handleRemove = (id) => {
onChange(valuesArr().filter((item) => item.id !== id));
};
const handleChange = async (event) => {
try {
const files = event.target.files;
if (!files || !files.length) {
return;
}
let file = files[0];
FileUploader.validate(file, schema);
setLoading(true);
file = await FileUploader.upload(path, file, schema);
inputElement.current.value = '';
setLoading(false);
onChange([...valuesArr(), file]);
} catch (error) {
inputElement.current.value = '';
console.log('error', error);
setLoading(false);
Errors.showMessage(error);
}
};
const formats = () => {
if (schema && schema.formats) {
return schema.formats.map((format) => `.${format}`).join(',');
}
return undefined;
};
const uploadButton = (
<label
className='btn btn-outline-secondary px-4 mb-2'
style={{ cursor: 'pointer' }}
>
{'Upload a file'}
<input
style={{ display: 'none' }}
disabled={loading || readonly}
accept={formats()}
type='file'
onChange={handleChange}
ref={inputElement}
/>
</label>
);
return (
<div>
{readonly || (max && fileList().length >= max) ? null : uploadButton}
{valuesArr() && valuesArr().length ? (
<div>
{valuesArr().map((item) => {
return (
<div key={item.id}>
<i className='la la-link text-muted mr-2'></i>
<a
href={item.publicUrl}
target='_blank'
rel='noopener noreferrer'
download
>
{item.name}
</a>
{!readonly && (
<button
className='btn btn-link'
type='button'
onClick={() => handleRemove(item.id)}
>
<i className='la la-times'></i>
</button>
)}
</div>
);
})}
</div>
) : null}
</div>
);
};
FilesUploader.propTypes = {
readonly: PropTypes.bool,
path: PropTypes.string,
max: PropTypes.number,
schema: PropTypes.shape({
image: PropTypes.bool,
size: PropTypes.number,
formats: PropTypes.arrayOf(PropTypes.string),
}),
value: PropTypes.any,
onChange: PropTypes.func,
};
export default FilesUploader;

View File

@ -1,227 +0,0 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import Button from '@mui/material/Button';
import CloseIcon from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import FileUploader from 'components/FormItems/uploaders/UploadService';
import Errors from '../../../components/FormItems/error/errors';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles({
actionButtonsWrapper: {
position: 'relative',
},
previewContent: {
padding: '0px !important',
},
imageItem: {
'&.MuiGrid-root': {
margin: 10,
boxShadow: '2px 2px 8px 0 rgb(0 0 0 / 40%)',
borderRadius: 10,
},
height: '100px',
},
actionButtons: {
position: 'absolute',
bottom: 5,
right: 4,
},
previewContainer: {
'& button': {
position: 'absolute',
top: 10,
right: 10,
'& svg': {
height: 50,
width: 50,
fill: '#FFF',
stroke: '#909090',
strokeWidth: 0.5,
},
},
},
button: {
padding: '0px !important',
minWidth: '45px !important',
'& svg': {
height: 36,
width: 36,
fill: '#FFF',
stroke: '#909090',
strokeWidth: 0.5,
},
},
});
const ImagesUploader = (props) => {
const classes = useStyles();
const { value, onChange, schema, path, max, readonly, name } = props;
const [loading, setLoading] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [imageMeta, setImageMeta] = useState({
imageSrc: null,
imageAlt: null,
});
const inputElement = useRef(null);
const valuesArr = () => {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
};
const fileList = () => {
return valuesArr().map((item) => ({
uid: item.id || undefined,
name: item.name,
status: 'done',
url: item.publicUrl,
}));
};
const handleRemove = (id) => {
onChange(valuesArr().filter((item) => item.id !== id));
};
const handleChange = async (event) => {
try {
const files = event.target.files;
if (!files || !files.length) {
return;
}
let file = files[0];
FileUploader.validate(file, schema);
setLoading(true);
file = await FileUploader.upload(path, file, schema);
inputElement.current.value = '';
setLoading(false);
onChange([...valuesArr(), file]);
} catch (error) {
inputElement.current.value = '';
console.log('error', error);
setLoading(false);
Errors.showMessage(error);
}
};
const doPreviewImage = (image) => {
setImageMeta({
imageSrc: image.publicUrl,
imageAlt: image.name,
});
setShowPreview(true);
};
const doCloseImageModal = () => {
setImageMeta({
imageSrc: null,
imageAlt: null,
});
setShowPreview(false);
};
const uploadButton = (
<Box>
<label htmlFor={'button-file-' + name} style={{ cursor: 'pointer' }}>
<input
id={'button-file-' + name}
style={{ display: 'none' }}
disabled={loading || readonly}
accept='image/*'
type='file'
onChange={handleChange}
ref={inputElement}
/>
<Button variant='contained' component='span'>
Upload an Image
</Button>{' '}
</label>
</Box>
);
return (
<Box>
{readonly || (max && fileList().length >= max) ? null : uploadButton}
{valuesArr() && valuesArr().length ? (
<Grid container>
{valuesArr().map((item) => {
return (
<Grid item className={classes.imageItem} key={item.id}>
<img
alt={item.name}
src={item.publicUrl}
style={{
width: '100px',
height: '100px',
objectFit: 'cover',
borderRadius: 10,
}}
/>
<div className={classes.actionButtonsWrapper}>
<div className={classes.actionButtons}>
<Button
classes={{ root: classes.button }}
variant='text'
onClick={() => doPreviewImage(item)}
>
<SearchIcon />
</Button>
{!readonly && (
<Button
classes={{ root: classes.button }}
variant='text'
onClick={() => handleRemove(item.id)}
>
<CloseIcon />
</Button>
)}
</div>
</div>
</Grid>
);
})}
</Grid>
) : null}
<Dialog
open={showPreview}
onClose={doCloseImageModal}
classes={{ root: classes.previewContainer }}
>
<Button variant='text' onClick={() => doCloseImageModal()}>
<CloseIcon />
</Button>
<img src={imageMeta.imageSrc} alt={imageMeta.imageAlt} />
</Dialog>
</Box>
);
};
ImagesUploader.propTypes = {
readonly: PropTypes.bool,
path: PropTypes.string,
max: PropTypes.number,
schema: PropTypes.shape({
image: PropTypes.bool,
size: PropTypes.number,
formats: PropTypes.arrayOf(PropTypes.string),
}),
value: PropTypes.any,
onChange: PropTypes.func,
name: PropTypes.string,
};
export default ImagesUploader;

View File

@ -199,11 +199,7 @@ export default class FileUploader {
const privateUrl = `${path}/${filename}`;
console.log(
'process.env.NODE_ENV in uploadToServer function',
process.env.NODE_ENV,
);
console.log('baseURLApi in uploadToServer function', baseURLApi);
// Debug logging removed for production builds
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
}

View File

@ -1,9 +1,4 @@
/**
* Users Table Component
*/
import React from 'react';
import GenericTable from '../Generic/GenericTable';
import { createTableComponent } from '../Factory/createTableComponent';
import {
fetch,
update,
@ -13,36 +8,16 @@ import {
} from '../../stores/users/usersSlice';
import { loadColumns } from './configureUsersCols';
import type { User } from '../../types/entities';
import type { RootState } from '../../stores/store';
import type { Filter, FilterItem } from '../../types/filters';
interface TableUsersProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TableUsers: React.FC<TableUsersProps> = ({
filterItems,
setFilterItems,
filters,
}) => {
return (
<GenericTable<User>
entityName='users'
sliceSelector={(state: RootState) => state.users}
fetchAction={fetch}
updateAction={update}
deleteAction={deleteItem}
deleteByIdsAction={deleteItemsByIds}
setRefetchAction={setRefetch}
loadColumnsFunction={loadColumns}
filters={filters}
filterItems={filterItems}
setFilterItems={setFilterItems}
/>
);
};
const TableUsers = createTableComponent<User>({
entityName: 'users',
sliceSelector: (state) => state.users,
fetchAction: fetch,
updateAction: update,
deleteAction: deleteItem,
deleteByIdsAction: deleteItemsByIds,
setRefetchAction: setRefetch,
loadColumnsFunction: loadColumns,
});
export default TableUsers;

View File

@ -1,181 +1,63 @@
import React from 'react';
import axios from 'axios';
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
import { GridRenderCellParams } from '@mui/x-data-grid';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import DataGridMultiSelect from '../DataGridMultiSelect';
import ListActionsPopover from '../ListActionsPopover';
import {
createColumnLoader,
ColumnMetadata,
} from '../DataGrid/configBuilderFactory';
import { hasPermission } from '../../helpers/userPermissions';
import { logger } from '../../lib/logger';
const USERS_COLUMNS: ColumnMetadata[] = [
{
field: 'firstName',
headerName: 'First Name',
type: 'text',
editable: true,
},
{ field: 'lastName', headerName: 'Last Name', type: 'text', editable: true },
{
field: 'phoneNumber',
headerName: 'Phone Number',
type: 'text',
editable: true,
},
{ field: 'email', headerName: 'E-Mail', type: 'text', editable: true },
{
field: 'disabled',
headerName: 'Disabled',
type: 'boolean',
editable: true,
},
{
field: 'avatar',
headerName: 'Avatar',
type: 'image',
editable: false,
renderCell: (params: GridRenderCellParams) => (
<ImageField
name='Avatar'
image={params?.row?.avatar}
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
/>
),
},
{
field: 'app_role',
headerName: 'App Role',
type: 'singleSelectRelation',
entityRef: 'roles',
editable: true,
},
{
field: 'custom_permissions',
headerName: 'Custom Permissions',
type: 'relationMany',
entityRef: 'permissions',
editable: false,
},
{ field: 'actions', headerName: '', type: 'actions' },
];
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user: unknown,
): Promise<GridColDef[]> => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
logger.error(
'Failed to fetch options',
error instanceof Error ? error : { error },
);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS');
return [
{
field: 'firstName',
headerName: 'First Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'lastName',
headerName: 'Last Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'phoneNumber',
headerName: 'Phone Number',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'email',
headerName: 'E-Mail',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'disabled',
headerName: 'Disabled',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'boolean',
},
{
field: 'avatar',
headerName: 'Avatar',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
renderCell: (params) => (
<ImageField
name={'Avatar'}
image={params?.row?.avatar}
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
/>
),
},
{
field: 'app_role',
headerName: 'App Role',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
sortable: false,
type: 'singleSelect' as const,
getOptionValue: (value: any) => value?.id,
getOptionLabel: (value: any) => value?.label,
valueOptions: await callOptionsApi('roles'),
valueGetter: (value: { id?: string } | string | null) =>
(typeof value === 'object' && value !== null ? value?.id : value) ??
value,
},
{
field: 'custom_permissions',
headerName: 'Custom Permissions',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect' as const,
valueFormatter: ({ value }) =>
dataFormatter.permissionsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'permissions'} />
),
},
{
field: 'actions',
type: 'actions' as const,
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/users/users-edit/?id=${params?.row?.id}`}
pathView={`/users/users-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
];
},
},
];
};
export const loadColumns = createColumnLoader({
entityName: 'users',
columns: USERS_COLUMNS,
});

View File

@ -32,9 +32,11 @@ export type FormFieldType =
| 'textarea'
| 'select'
| 'selectMany'
| 'enumSelect'
| 'switch'
| 'image'
| 'date'
| 'datetime'
| 'password'
| 'custom';
@ -256,6 +258,17 @@ function renderField<T>(
/>
);
case 'enumSelect':
return (
<Field name={name} id={name} component='select'>
{options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Field>
);
case 'selectMany':
return (
<Field
@ -293,6 +306,15 @@ function renderField<T>(
/>
);
case 'datetime':
return (
<Field
name={name}
type='datetime-local'
placeholder={placeholder || field.label}
/>
);
case 'custom':
if (CustomComponent) {
return (

View File

@ -1,7 +1,7 @@
import dayjs from 'dayjs';
import _ from 'lodash';
export default {
const dataFormatter = {
filesFormatter(arr) {
if (!arr || !arr.length) return [];
return arr.map((item) => item);
@ -172,3 +172,5 @@ export default {
return { label: val.name, id: val.id };
},
};
export default dataFormatter;

View File

@ -0,0 +1,53 @@
/**
* Text Formatting Helpers
*
* Common text transformation utilities.
*/
/**
* Convert a plural title to singular by removing the last character.
* Simple approach for entity names like "roles" -> "role".
*
* @example
* singularize('View roles') // 'View role'
* singularize('assets') // 'asset'
*/
export function singularize(pluralTitle: string): string {
return pluralTitle.slice(0, -1);
}
/**
* Capitalize the first letter of a string.
*
* @example
* capitalize('hello') // 'Hello'
*/
export function capitalize(str: string): string {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Convert snake_case to Title Case.
*
* @example
* snakeToTitle('tour_pages') // 'Tour Pages'
*/
export function snakeToTitle(str: string): string {
return str.split('_').map(capitalize).join(' ');
}
/**
* Convert camelCase to Title Case.
*
* @example
* camelToTitle('tourPages') // 'Tour Pages'
*/
export function camelToTitle(str: string): string {
return str
.replace(/([A-Z])/g, ' $1')
.trim()
.split(' ')
.map(capitalize)
.join(' ');
}

View File

@ -1,87 +0,0 @@
/**
* Zod Adapter - Converts Zod schemas to Formik validation functions
* Provides type-safe form validation with Zod v4
*/
import * as z from 'zod';
/**
* Converts a Zod schema to a Formik validate function
*
* @param schema - Zod schema to use for validation
* @returns Formik-compatible validate function
*
* @example
* ```typescript
* const userSchema = z.object({
* email: z.string().email('Invalid email'),
* firstName: z.string().min(1, 'Required'),
* })
*
* <Formik
* validate={toFormikValidate(userSchema)}
* initialValues={{ email: '', firstName: '' }}
* >
* ```
*/
export function toFormikValidate<T extends z.ZodType>(
schema: T,
): (values: z.infer<T>) => Record<string, string> {
return (values: z.infer<T>) => {
const result = z.safeParse(schema, values);
if (result.success) {
return {};
}
const errors: Record<string, string> = {};
if (result.error && 'issues' in result.error) {
result.error.issues.forEach((issue) => {
// Join path for nested fields (e.g., 'address.street')
const path = issue.path.map(String).join('.');
// Only set the first error for each field
if (!errors[path]) {
errors[path] = issue.message;
}
});
}
return errors;
};
}
/**
* Async version of toFormikValidate for schemas with async refinements
*
* @param schema - Zod schema to use for validation
* @returns Formik-compatible async validate function
*/
export function toFormikValidateAsync<T extends z.ZodType>(
schema: T,
): (values: z.infer<T>) => Promise<Record<string, string>> {
return async (values: z.infer<T>) => {
const result = await z.safeParseAsync(schema, values);
if (result.success) {
return {};
}
const errors: Record<string, string> = {};
if (result.error && 'issues' in result.error) {
result.error.issues.forEach((issue) => {
const path = issue.path.map(String).join('.');
if (!errors[path]) {
errors[path] = issue.message;
}
});
}
return errors;
};
}
export default toFormikValidate;

View File

@ -1,22 +0,0 @@
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export const useSampleClients = () => {
const { data, error } = useSWR('/data-sources/clients.json', fetcher);
return {
clients: data?.data ?? [],
isLoading: !error && !data,
isError: error,
};
};
export const useSampleTransactions = () => {
const { data, error } = useSWR('/data-sources/history.json', fetcher);
return {
transactions: data?.data ?? [],
isLoading: !error && !data,
isError: error,
};
};

View File

@ -0,0 +1,308 @@
/**
* useDashboardCounts Hook
*
* Provides a centralized way to fetch entity counts for the dashboard
* with permission-based filtering and proper error handling.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { hasPermission } from '../helpers/userPermissions';
// User type from auth state (currentUser)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type User = any;
/**
* Entity configuration for dashboard cards
*/
export interface EntityConfig {
key: string;
label: string;
endpoint: string;
permission: string;
href: string;
icon: string;
}
/**
* Dashboard entity configurations
*/
export const DASHBOARD_ENTITIES: EntityConfig[] = [
{
key: 'users',
label: 'Users',
endpoint: '/users/count',
permission: 'READ_USERS',
href: '/users/users-list',
icon: 'mdiAccountGroup',
},
{
key: 'roles',
label: 'Roles',
endpoint: '/roles/count',
permission: 'READ_ROLES',
href: '/roles/roles-list',
icon: 'mdiShieldAccountVariantOutline',
},
{
key: 'permissions',
label: 'Permissions',
endpoint: '/permissions/count',
permission: 'READ_PERMISSIONS',
href: '/permissions/permissions-list',
icon: 'mdiShieldAccountOutline',
},
{
key: 'projects',
label: 'Projects',
endpoint: '/projects/count',
permission: 'READ_PROJECTS',
href: '/projects/projects-list',
icon: 'mdiOfficeBuilding',
},
{
key: 'project_memberships',
label: 'Project memberships',
endpoint: '/project_memberships/count',
permission: 'READ_PROJECT_MEMBERSHIPS',
href: '/project_memberships/project_memberships-list',
icon: 'mdiAccountKey',
},
{
key: 'assets',
label: 'Assets',
endpoint: '/assets/count',
permission: 'READ_ASSETS',
href: '/assets/assets-list',
icon: 'mdiFolderMultipleImage',
},
{
key: 'asset_variants',
label: 'Asset variants',
endpoint: '/asset_variants/count',
permission: 'READ_ASSET_VARIANTS',
href: '/asset_variants/asset_variants-list',
icon: 'mdiImageMultiple',
},
{
key: 'presigned_url_requests',
label: 'Presigned url requests',
endpoint: '/presigned_url_requests/count',
permission: 'READ_PRESIGNED_URL_REQUESTS',
href: '/presigned_url_requests/presigned_url_requests-list',
icon: 'mdiLinkLock',
},
{
key: 'tour_pages',
label: 'Tour pages',
endpoint: '/tour_pages/count',
permission: 'READ_TOUR_PAGES',
href: '/tour_pages/tour_pages-list',
icon: 'mdiFileDocumentMultiple',
},
{
key: 'project_audio_tracks',
label: 'Project audio tracks',
endpoint: '/project_audio_tracks/count',
permission: 'READ_PROJECT_AUDIO_TRACKS',
href: '/project_audio_tracks/project_audio_tracks-list',
icon: 'mdiMusicNote',
},
{
key: 'publish_events',
label: 'Publish events',
endpoint: '/publish_events/count',
permission: 'READ_PUBLISH_EVENTS',
href: '/publish_events/publish_events-list',
icon: 'mdiPublish',
},
{
key: 'pwa_caches',
label: 'Pwa caches',
endpoint: '/pwa_caches/count',
permission: 'READ_PWA_CACHES',
href: '/pwa_caches/pwa_caches-list',
icon: 'mdiCellphoneLink',
},
{
key: 'access_logs',
label: 'Access logs',
endpoint: '/access_logs/count',
permission: 'READ_ACCESS_LOGS',
href: '/access_logs/access_logs-list',
icon: 'mdiClipboardTextOutline',
},
];
export type EntityCountValue = number | string | null;
export interface DashboardCountsState {
counts: Record<string, EntityCountValue>;
loading: boolean;
error: Error | null;
}
export interface UseDashboardCountsReturn extends DashboardCountsState {
refetch: () => Promise<void>;
getCount: (key: string) => EntityCountValue;
getVisibleEntities: () => EntityConfig[];
}
const LOADING_MESSAGE = 'Loading...';
/**
* Hook for fetching dashboard entity counts with permission-based filtering
*
* @param currentUser - The current authenticated user
* @returns Dashboard counts state and helper functions
*
* @example
* ```typescript
* const { counts, loading, getVisibleEntities, getCount } = useDashboardCounts(currentUser);
*
* // Get visible entities for the user's permissions
* const entities = getVisibleEntities();
*
* // Get count for a specific entity
* const userCount = getCount('users'); // number | 'Loading...' | null
* ```
*/
export function useDashboardCounts(
currentUser: User | null,
): UseDashboardCountsReturn {
const [state, setState] = useState<DashboardCountsState>({
counts: {},
loading: false,
error: null,
});
// Track if component is mounted
const mountedRef = useRef(true);
// Initialize counts with loading message for permitted entities
const initializeCounts = useCallback(
(user: User | null): Record<string, EntityCountValue> => {
const initial: Record<string, EntityCountValue> = {};
for (const entity of DASHBOARD_ENTITIES) {
if (hasPermission(user, entity.permission)) {
initial[entity.key] = LOADING_MESSAGE;
} else {
initial[entity.key] = null;
}
}
return initial;
},
[],
);
// Fetch counts from API
const fetchCounts = useCallback(async () => {
if (!currentUser) {
setState({
counts: initializeCounts(null),
loading: false,
error: null,
});
return;
}
setState((prev) => ({
...prev,
counts: initializeCounts(currentUser),
loading: true,
error: null,
}));
try {
// Build array of fetch promises for permitted entities
const fetchPromises = DASHBOARD_ENTITIES.map(async (entity) => {
if (!hasPermission(currentUser, entity.permission)) {
return { key: entity.key, count: null, error: null };
}
try {
const response = await axios.get(entity.endpoint);
return {
key: entity.key,
count: response.data.count as number,
error: null,
};
} catch (err) {
return {
key: entity.key,
count: null,
error: err instanceof Error ? err.message : 'Failed to fetch',
};
}
});
// Use Promise.allSettled for resilience - don't fail if some counts fail
const results = await Promise.allSettled(fetchPromises);
if (!mountedRef.current) return;
const newCounts: Record<string, EntityCountValue> = {};
results.forEach((result) => {
if (result.status === 'fulfilled') {
const { key, count, error } = result.value;
newCounts[key] = error ? error : count;
} else {
// This shouldn't happen since we catch errors inside the promise
// but handle it for completeness - silently ignore
}
});
setState({
counts: newCounts,
loading: false,
error: null,
});
} catch (err) {
if (!mountedRef.current) return;
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err : new Error('Failed to fetch counts'),
}));
}
}, [currentUser, initializeCounts]);
// Fetch counts when user changes
useEffect(() => {
mountedRef.current = true;
fetchCounts();
return () => {
mountedRef.current = false;
};
}, [fetchCounts]);
// Get count for a specific entity
const getCount = useCallback(
(key: string): EntityCountValue => {
return state.counts[key] ?? null;
},
[state.counts],
);
// Get entities visible to the current user
const getVisibleEntities = useCallback((): EntityConfig[] => {
return DASHBOARD_ENTITIES.filter((entity) =>
hasPermission(currentUser, entity.permission),
);
}, [currentUser]);
return {
counts: state.counts,
loading: state.loading,
error: state.error,
refetch: fetchCounts,
getCount,
getVisibleEntities,
};
}
export default useDashboardCounts;

View File

@ -0,0 +1,166 @@
/**
* useEditPageSync Hook
*
* Handles the common pattern in edit pages:
* 1. Fetch entity when ID is available
* 2. Sync form values when entity data changes
*
* Reduces ~50 lines of duplicated code across edit pages.
*/
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/router';
import { AsyncThunk } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import type { RootState } from '../stores/store';
interface UseEditPageSyncOptions<T extends Record<string, unknown>> {
/** Redux selector to get the entity from state (can return single entity, array, or any slice state value) */
entitySelector: (state: RootState) => unknown;
/** Redux async thunk to fetch the entity */
fetchAction: AsyncThunk<unknown, { id?: string; query?: string }, object>;
/** Initial form values */
initialValues: T;
/** Optional post-processing of entity data before setting form values */
postProcess?: (entity: T, initial: T) => T;
/** Optional ID override (defaults to router.query.id) */
idOverride?: string;
}
interface UseEditPageSyncReturn<T> {
/** Current form values */
values: T;
/** Set form values directly */
setValues: React.Dispatch<React.SetStateAction<T>>;
/** Entity ID from router */
id: string | null;
/** Whether initial fetch is loading */
isLoading: boolean;
/** Whether entity was found */
isFound: boolean;
}
/**
* Hook for syncing edit page forms with Redux entity state
*
* @example
* const initVals = { name: '', permissions: [] };
*
* const EditRolesPage = () => {
* const { values, id, isLoading } = useEditPageSync({
* entitySelector: (state) => state.roles.roles,
* fetchAction: fetch,
* initialValues: initVals,
* });
*
* const handleSubmit = async (data) => {
* await dispatch(update({ id, data }));
* router.push('/roles/roles-list');
* };
*
* return (
* <Formik enableReinitialize initialValues={values} onSubmit={handleSubmit}>
* ...
* </Formik>
* );
* };
*/
export function useEditPageSync<T extends Record<string, unknown>>(
options: UseEditPageSyncOptions<T>,
): UseEditPageSyncReturn<T> {
const {
entitySelector,
fetchAction,
initialValues,
postProcess,
idOverride,
} = options;
const router = useRouter();
const dispatch = useAppDispatch();
// Get ID from router or override
const routerId = router.query.id;
const id =
idOverride ?? (Array.isArray(routerId) ? routerId[0] : routerId) ?? null;
// Local state for form values
const [values, setValues] = useState<T>(initialValues);
const [isLoading, setIsLoading] = useState(false);
const [isFound, setIsFound] = useState(false);
// Get entity from Redux store
const rawEntity = useAppSelector(entitySelector);
// Handle both array and single entity - when single entity, it's stored directly
// When array, it means we fetched a list (shouldn't happen in edit pages)
// Filter out primitive types (number, boolean, string) from the slice state
const entity = (() => {
if (rawEntity === null || rawEntity === undefined) return null;
if (typeof rawEntity !== 'object') return null; // Filter out primitives
if (Array.isArray(rawEntity)) {
return rawEntity.length === 1 ? (rawEntity[0] as T) : null;
}
return rawEntity as T;
})();
// Fetch entity when ID changes
useEffect(() => {
if (id) {
setIsLoading(true);
dispatch(fetchAction({ id }))
.then(() => setIsLoading(false))
.catch(() => setIsLoading(false));
}
}, [id, dispatch, fetchAction]);
// Sync form values when entity changes
useEffect(() => {
if (entity && typeof entity === 'object' && !Array.isArray(entity)) {
// Build new values by copying from entity to initial structure
const newValues = { ...initialValues };
Object.keys(initialValues).forEach((key) => {
if (key in entity) {
(newValues as Record<string, unknown>)[key] = entity[key as keyof T];
}
});
// Apply post-processing if provided
const finalValues = postProcess
? postProcess(newValues, initialValues)
: newValues;
setValues(finalValues);
setIsFound(true);
}
}, [entity, initialValues, postProcess]);
return {
values,
setValues,
id,
isLoading,
isFound,
};
}
/**
* Simplified version that returns just values tuple
* for drop-in replacement in existing code
*
* @example
* const [initialValues, setInitialValues] = useEditPageSyncSimple({
* entitySelector: (state) => state.roles.roles,
* fetchAction: fetch,
* initialValues: initVals,
* });
*/
export function useEditPageSyncSimple<T extends Record<string, unknown>>(
options: UseEditPageSyncOptions<T>,
): [T, React.Dispatch<React.SetStateAction<T>>] {
const { values, setValues } = useEditPageSync(options);
return [values, setValues];
}
export default useEditPageSync;

View File

@ -0,0 +1,88 @@
/**
* Slug Helpers
*
* Utilities for generating and validating URL slugs
*/
/**
* Regex pattern for valid slugs
* Matches: lowercase alphanumeric with hyphens between segments
* Examples: "my-page", "page-2", "about"
*/
export const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Sanitize a string into a valid URL slug
*
* @param value - Raw string to sanitize
* @returns Sanitized slug string
*
* @example
* sanitizeSlug('My Page Title') // 'my-page-title'
* sanitizeSlug('Hello World!') // 'hello-world'
* sanitizeSlug('--test--') // 'test'
*/
export function sanitizeSlug(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Build a unique slug from a base value, avoiding collisions with existing slugs
*
* @param baseValue - The desired slug base
* @param usedSlugs - Set of already-used slugs
* @returns A unique slug string
*
* @example
* const used = new Set(['my-page', 'my-page-2']);
* buildUniqueSlug('my-page', used) // 'my-page-3'
*
* @example
* const used = new Set(['about']);
* buildUniqueSlug('About', used) // 'about-2'
*/
export function buildUniqueSlug(
baseValue: string,
usedSlugs: Set<string>,
): string {
const baseSlug = sanitizeSlug(baseValue) || 'page';
if (!usedSlugs.has(baseSlug)) return baseSlug;
let suffix = 2;
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
suffix += 1;
}
return `${baseSlug}-${suffix}`;
}
/**
* Build a unique slug from an array of existing slugs
*
* @param baseValue - The desired slug base
* @param existingSlugs - Array of already-used slugs
* @returns A unique slug string
*
* @example
* buildUniqueSlugFromArray('my-page', ['my-page', 'my-page-2']) // 'my-page-3'
*/
export function buildUniqueSlugFromArray(
baseValue: string,
existingSlugs: string[],
): string {
return buildUniqueSlug(baseValue, new Set(existingSlugs));
}
/**
* Validate if a string is a valid slug
*
* @param value - String to validate
* @returns true if valid slug format
*/
export function isValidSlug(value: string): boolean {
return slugPattern.test(value);
}

View File

@ -0,0 +1,118 @@
/**
* Tour Flow Helpers
*
* Utilities for tour page routing and project handling
*/
/**
* Normalize a value to a valid route path
*
* @param value - Raw path value
* @returns Normalized path starting with /
*
* @example
* toRoutePath('my-page') // '/my-page'
* toRoutePath('//double//slash') // '/double/slash'
* toRoutePath('http://example.com/page') // '/page'
* toRoutePath('') // '/'
*/
export function toRoutePath(value?: string): string {
const raw = String(value || '').trim();
if (!raw || raw === '/') return '/';
const normalized = raw
.replace(/^[^/]+:\/\//, '') // Remove protocol
.replace(/^\/*/, '') // Remove leading slashes
.replace(/\/{2,}/g, '/'); // Collapse multiple slashes
const withSlash = `/${normalized}`;
return withSlash.length > 1 ? withSlash.replace(/\/$/, '') : withSlash;
}
/**
* Split a route path into segments
*
* @param value - Route path
* @returns Array of path segments
*
* @example
* routeParts('/my/page/path') // ['my', 'page', 'path']
* routeParts('/') // []
*/
export function routeParts(value?: string): string[] {
return toRoutePath(value).split('/').filter(Boolean);
}
/**
* Compare two route paths for sorting
*
* @param a - First route path
* @param b - Second route path
* @returns Negative if a < b, positive if a > b, 0 if equal
*
* @example
* compareRoutes('/a', '/b') // negative
* compareRoutes('/z', '/a') // positive
* compareRoutes('/page', '/page') // 0
*/
export function compareRoutes(a?: string, b?: string): number {
const aPath = toRoutePath(a);
const bPath = toRoutePath(b);
if (aPath === bPath) return 0;
const aParts = routeParts(aPath);
const bParts = routeParts(bPath);
const maxLen = Math.max(aParts.length, bParts.length);
for (let index = 0; index < maxLen; index += 1) {
const aPart = aParts[index];
const bPart = bParts[index];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const compare = aPart.localeCompare(bPart);
if (compare !== 0) return compare;
}
return aParts.length - bParts.length;
}
/**
* Item that may contain a projectId
*/
interface ProjectIdHolder {
projectId?: string;
project?: { id?: string } | string;
}
/**
* Extract project ID from an item that may have it in different formats
*
* @param item - Object that may contain projectId in various formats
* @returns The project ID string or empty string if not found
*
* @example
* getProjectId({ projectId: '123' }) // '123'
* getProjectId({ project: '456' }) // '456'
* getProjectId({ project: { id: '789' } }) // '789'
* getProjectId({}) // ''
*/
export function getProjectId(item: ProjectIdHolder): string {
if (item.projectId) return item.projectId;
if (typeof item.project === 'string') return item.project;
if (item.project?.id) return item.project.id;
return '';
}
/**
* Get rows from API response with safety check
*
* @param response - API response object
* @returns Array of rows or empty array
*/
export function getRows<T = unknown>(
response: { data?: { rows?: T[] } } | null | undefined,
): T[] {
return Array.isArray(response?.data?.rows) ? response.data.rows : [];
}

View File

@ -1,11 +1,10 @@
/**
* Edit Access Logs Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
import { SelectField } from '../../components/SelectField';
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { AccessLog } from '../../types/entities';
const initVals = {
@ -41,36 +41,24 @@ const initVals = {
const EditAccess_logsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { access_logs } = useAppSelector((state) => state.access_logs);
const { id } = router.query;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof access_logs === 'object' && access_logs !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in access_logs) {
newInitialVal[key] = access_logs[key];
}
});
setInitialValues(newInitialVal);
}
}, [access_logs]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) => state.access_logs.access_logs,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
await dispatch(
update({ id: id as string, data: data as unknown as Partial<AccessLog> }),
);
await router.push('/access_logs/access_logs-list');
if (id) {
await dispatch(
update({ id, data: data as unknown as Partial<AccessLog> }),
);
await router.push('/access_logs/access_logs-list');
}
};
return (
@ -153,7 +141,7 @@ const EditAccess_logsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
accessed_at: date || new Date(),
})

View File

@ -1,11 +1,6 @@
/**
* Edit Asset Variants Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -21,8 +16,9 @@ import BaseButton from '../../components/BaseButton';
import { SelectField } from '../../components/SelectField';
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { AssetVariant } from '../../types/entities';
const initVals = {
@ -37,46 +33,17 @@ const initVals = {
const EditAsset_variantsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const assetVariantsState = useAppSelector((state) => state.asset_variants);
const asset_variants = assetVariantsState.asset_variants as
| AssetVariant
| AssetVariant[]
| undefined;
const assetVariant = Array.isArray(asset_variants)
? asset_variants[0]
: asset_variants;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch entity data
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (assetVariant && typeof assetVariant === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in assetVariant) {
(newInitialVal as Record<string, unknown>)[key] = (
assetVariant as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [assetVariant]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.asset_variants.asset_variants,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
if (id) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<AssetVariant> }),
update({ id, data: data as unknown as Partial<AssetVariant> }),
);
await router.push('/asset_variants/asset_variants-list');
}

View File

@ -1,6 +1,6 @@
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -20,8 +20,9 @@ import { SelectField } from '../../components/SelectField';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/assets/assetsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { Asset } from '../../types/entities';
const initVals = {
@ -45,42 +46,20 @@ const initVals = {
const EditAssetsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const assetsState = useAppSelector((state) => state.assets);
const assets = assetsState.assets as Asset | Asset[] | undefined;
const asset = Array.isArray(assets) ? assets[0] : assets;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch asset data
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (asset && typeof asset === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => {
if (el in asset) {
(newInitialVal as Record<string, unknown>)[el] = (
asset as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [asset]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) => state.assets.assets,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<Asset> }),
);
if (id) {
await dispatch(update({ id, data: data as unknown as Partial<Asset> }));
await router.push('/assets/assets-list');
}
};
@ -213,7 +192,7 @@ const EditAssetsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
deleted_at_time: date,
})

View File

@ -1,7 +1,6 @@
import * as icon from '@mdi/js';
import Head from 'next/head';
import React from 'react';
import axios from 'axios';
import type { ReactElement } from 'react';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
@ -14,102 +13,92 @@ import { hasPermission } from '../helpers/userPermissions';
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import {
useDashboardCounts,
EntityCountValue,
} from '../hooks/useDashboardCounts';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
/**
* Dashboard card component for entity counts
*/
interface DashboardCardProps {
href: string;
label: string;
count: EntityCountValue;
iconKey: string;
corners: string;
cardsStyle: string;
iconsColor: string;
}
const DashboardCard = ({
href,
label,
count,
iconKey,
corners,
cardsStyle,
iconsColor,
}: DashboardCardProps) => {
const iconPath =
(icon as Record<string, string>)[iconKey] ??
(icon as Record<string, string>)['mdiFormatListBulleted'];
return (
<Link href={href}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
{label}
</div>
<div className='text-3xl leading-tight font-semibold'>{count}</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={iconPath}
/>
</div>
</div>
</div>
</Link>
);
};
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const loadingMessage = 'Loading...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [projects, setProjects] = React.useState(loadingMessage);
const [project_memberships, setProject_memberships] =
React.useState(loadingMessage);
const [assets, setAssets] = React.useState(loadingMessage);
const [asset_variants, setAsset_variants] = React.useState(loadingMessage);
const [presigned_url_requests, setPresigned_url_requests] =
React.useState(loadingMessage);
const [tour_pages, setTour_pages] = React.useState(loadingMessage);
const [project_audio_tracks, setProject_audio_tracks] =
React.useState(loadingMessage);
const [publish_events, setPublish_events] = React.useState(loadingMessage);
const [pwa_caches, setPwa_caches] = React.useState(loadingMessage);
const [access_logs, setAccess_logs] = React.useState(loadingMessage);
// Use the centralized dashboard counts hook
const { getCount, getVisibleEntities } = useDashboardCounts(currentUser);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
loading: boolean;
};
async function loadData() {
const entities = [
'users',
'roles',
'permissions',
'projects',
'project_memberships',
'assets',
'asset_variants',
'presigned_url_requests',
'tour_pages',
'project_audio_tracks',
'publish_events',
'pwa_caches',
'access_logs',
];
const fns = [
setUsers,
setRoles,
setPermissions,
setProjects,
setProject_memberships,
setAssets,
setAsset_variants,
setPresigned_url_requests,
setTour_pages,
setProject_audio_tracks,
setPublish_events,
setPwa_caches,
setAccess_logs,
];
const requests = entities.map((entity, index) => {
if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({ data: { count: null } });
}
});
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
}
async function getWidgets(roleId) {
async function getWidgets(roleId: string) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({
role: {
value: currentUser?.app_role?.id,
@ -123,6 +112,9 @@ const Dashboard = () => {
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
// Get entities visible to current user
const visibleEntities = getVisibleEntities();
return (
<>
<Head>
@ -147,7 +139,7 @@ const Dashboard = () => {
)}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
<p className='text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
@ -155,7 +147,7 @@ const Dashboard = () => {
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div
className={` ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}
className={` ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}
>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
@ -180,450 +172,24 @@ const Dashboard = () => {
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
{!!rolesWidgets.length && <hr className='my-6' />}
<div
id='dashboard'
className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'
>
{hasPermission(currentUser, 'READ_USERS') && (
<Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Users
</div>
<div className='text-3xl leading-tight font-semibold'>
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiAccountGroup'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_ROLES') && (
<Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Roles
</div>
<div className='text-3xl leading-tight font-semibold'>
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiShieldAccountVariantOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PERMISSIONS') && (
<Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Permissions
</div>
<div className='text-3xl leading-tight font-semibold'>
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiShieldAccountOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PROJECTS') && (
<Link href={'/projects/projects-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Projects
</div>
<div className='text-3xl leading-tight font-semibold'>
{projects}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiOfficeBuilding'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PROJECT_MEMBERSHIPS') && (
<Link href={'/project_memberships/project_memberships-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Project memberships
</div>
<div className='text-3xl leading-tight font-semibold'>
{project_memberships}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiAccountKey'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_ASSETS') && (
<Link href={'/assets/assets-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Assets
</div>
<div className='text-3xl leading-tight font-semibold'>
{assets}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiFolderMultipleImage'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_ASSET_VARIANTS') && (
<Link href={'/asset_variants/asset_variants-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Asset variants
</div>
<div className='text-3xl leading-tight font-semibold'>
{asset_variants}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiImageMultiple'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PRESIGNED_URL_REQUESTS') && (
<Link href={'/presigned_url_requests/presigned_url_requests-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Presigned url requests
</div>
<div className='text-3xl leading-tight font-semibold'>
{presigned_url_requests}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiLinkLock'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_TOUR_PAGES') && (
<Link href={'/tour_pages/tour_pages-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Tour pages
</div>
<div className='text-3xl leading-tight font-semibold'>
{tour_pages}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiFileDocumentMultiple'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PROJECT_AUDIO_TRACKS') && (
<Link href={'/project_audio_tracks/project_audio_tracks-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Project audio tracks
</div>
<div className='text-3xl leading-tight font-semibold'>
{project_audio_tracks}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiMusicNote'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PUBLISH_EVENTS') && (
<Link href={'/publish_events/publish_events-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Publish events
</div>
<div className='text-3xl leading-tight font-semibold'>
{publish_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiPublish'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_PWA_CACHES') && (
<Link href={'/pwa_caches/pwa_caches-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Pwa caches
</div>
<div className='text-3xl leading-tight font-semibold'>
{pwa_caches}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)['mdiCellphoneLink'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_ACCESS_LOGS') && (
<Link href={'/access_logs/access_logs-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Access logs
</div>
<div className='text-3xl leading-tight font-semibold'>
{access_logs}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={
(icon as Record<string, string>)[
'mdiClipboardTextOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
</div>
</Link>
)}
{visibleEntities.map((entity) => (
<DashboardCard
key={entity.key}
href={entity.href}
label={entity.label}
count={getCount(entity.key)}
iconKey={entity.icon}
corners={corners}
cardsStyle={cardsStyle}
iconsColor={iconsColor}
/>
))}
</div>
</SectionMain>
</>

View File

@ -1,162 +0,0 @@
import {
mdiAccount,
mdiBallotOutline,
mdiGithub,
mdiMail,
mdiUpload,
} from '@mdi/js';
import { Field, Form, Formik } from 'formik';
import Head from 'next/head';
import { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import FormCheckRadio from '../components/FormCheckRadio';
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
import FormField from '../components/FormField';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitle from '../components/SectionTitle';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
const FormsPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Forms')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiBallotOutline}
title='Formik forms example'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={{
fullname: 'John Doe',
email: 'john.doe@example.com',
phone: '',
color: 'green',
textarea: 'Hello',
}}
onSubmit={(values) => alert(JSON.stringify(values, null, 2))}
>
<Form>
<FormField
label='Grouped with icons'
icons={[mdiAccount, mdiMail]}
>
<Field name='fullname' placeholder='Full name' />
<Field type='email' name='email' placeholder='Email' />
</FormField>
<FormField
label='With help line and labelFor'
labelFor='phone'
help='Help line comes here'
>
<Field name='phone' placeholder='Phone' id='phone' />
</FormField>
<FormField label='Dropdown' labelFor='color'>
<Field name='color' id='color' component='select'>
<option value='red'>Red</option>
<option value='green'>Green</option>
<option value='blue'>Blue</option>
</Field>
</FormField>
<BaseDivider />
<FormField label='Textarea' hasTextareaHeight>
<Field
name='textarea'
as='textarea'
placeholder='Your text here'
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
<SectionTitle>Custom elements</SectionTitle>
<SectionMain>
<CardBox>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
<FormField label='Checkbox'>
<FormCheckRadioGroup>
<FormCheckRadio type='checkbox' label='Lorem'>
<Field type='checkbox' name='checkboxes' value='lorem' />
</FormCheckRadio>
<FormCheckRadio type='checkbox' label='Ipsum'>
<Field type='checkbox' name='checkboxes' value='ipsum' />
</FormCheckRadio>
<FormCheckRadio type='checkbox' label='Dolore'>
<Field type='checkbox' name='checkboxes' value='dolore' />
</FormCheckRadio>
</FormCheckRadioGroup>
</FormField>
<BaseDivider />
<FormField label='Radio'>
<FormCheckRadioGroup>
<FormCheckRadio type='radio' label='Lorem'>
<Field type='radio' name='radio' value='lorem' />
</FormCheckRadio>
<FormCheckRadio type='radio' label='Ipsum'>
<Field type='radio' name='radio' value='ipsum' />
</FormCheckRadio>
</FormCheckRadioGroup>
</FormField>
<BaseDivider />
<FormField label='Switch'>
<FormCheckRadioGroup>
<FormCheckRadio type='switch' label='Lorem'>
<Field type='checkbox' name='switches' value='lorem' />
</FormCheckRadio>
<FormCheckRadio type='switch' label='Ipsum'>
<Field type='checkbox' name='switches' value='ipsum' />
</FormCheckRadio>
</FormCheckRadioGroup>
</FormField>
</Form>
</Formik>
<BaseDivider />
</CardBox>
</SectionMain>
</>
);
};
FormsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default FormsPage;

View File

@ -1,11 +1,6 @@
/**
* Edit Permissions Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -20,8 +15,9 @@ import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton';
import { update, fetch } from '../../stores/permissions/permissionsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
const initVals = {
name: '',
@ -30,34 +26,18 @@ const initVals = {
const EditPermissionsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { permissions } = useAppSelector((state) => state.permissions);
const { id } = router.query;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof permissions === 'object' && permissions !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in permissions) {
newInitialVal[key] = permissions[key];
}
});
setInitialValues(newInitialVal);
}
}, [permissions]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.permissions.permissions,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/permissions/permissions-list');
if (id) {
await dispatch(update({ id, data }));
await router.push('/permissions/permissions-list');
}
};
return (

View File

@ -1,11 +1,10 @@
/**
* Edit Presigned URL Requests Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -27,8 +26,9 @@ import {
update,
fetch,
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { PresignedUrlRequest } from '../../types/entities';
const initVals = {
@ -46,42 +46,17 @@ const initVals = {
const EditPresigned_url_requestsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const presignedState = useAppSelector(
(state) => state.presigned_url_requests,
);
const presigned_url_requests = presignedState.presigned_url_requests as
| PresignedUrlRequest
| PresignedUrlRequest[]
| undefined;
const presignedRequest = Array.isArray(presigned_url_requests)
? presigned_url_requests[0]
: presigned_url_requests;
const { id } = router.query as { id?: string };
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof presignedRequest === 'object' && presignedRequest !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in presignedRequest) {
(newInitialVal as Record<string, unknown>)[key] = (
presignedRequest as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [presignedRequest]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) =>
state.presigned_url_requests.presigned_url_requests,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (id) {
@ -183,7 +158,7 @@ const EditPresigned_url_requestsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
expires_at: date || new Date(),
})

View File

@ -1,11 +1,6 @@
/**
* Edit Project Audio Tracks Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -25,8 +20,9 @@ import {
update,
fetch,
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { ProjectAudioTrack } from '../../types/entities';
const initVals = {
@ -45,43 +41,12 @@ const initVals = {
const EditProject_audio_tracksPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const project_audio_tracksState = useAppSelector(
(state) => state.project_audio_tracks,
);
const project_audio_tracks =
project_audio_tracksState.project_audio_tracks as
| ProjectAudioTrack
| ProjectAudioTrack[]
| undefined;
const item = Array.isArray(project_audio_tracks)
? project_audio_tracks[0]
: project_audio_tracks;
const { id } = router.query as { id?: string };
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (item && typeof item === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in item) {
(newInitialVal as Record<string, unknown>)[key] = (
item as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [item]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.project_audio_tracks.project_audio_tracks,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (id) {

View File

@ -1,11 +1,10 @@
/**
* Edit Project Memberships Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -28,8 +27,9 @@ import {
update,
fetch,
} from '../../stores/project_memberships/project_membershipsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { ProjectMembership } from '../../types/entities';
const initVals = {
@ -44,42 +44,16 @@ const initVals = {
const EditProject_membershipsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const project_membershipsState = useAppSelector(
(state) => state.project_memberships,
);
const project_memberships = project_membershipsState.project_memberships as
| ProjectMembership
| ProjectMembership[]
| undefined;
const item = Array.isArray(project_memberships)
? project_memberships[0]
: project_memberships;
const { id } = router.query as { id?: string };
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (item && typeof item === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in item) {
(newInitialVal as Record<string, unknown>)[key] = (
item as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [item]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) => state.project_memberships.project_memberships,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (id) {
@ -163,7 +137,7 @@ const EditProject_membershipsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
invited_at: date,
})
@ -185,7 +159,7 @@ const EditProject_membershipsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
accepted_at: date,
})

View File

@ -1,11 +1,10 @@
/**
* Edit Publish Events Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
import { SelectField } from '../../components/SelectField';
import { update, fetch } from '../../stores/publish_events/publish_eventsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { PublishEvent } from '../../types/entities';
const initVals = {
@ -45,40 +45,16 @@ const initVals = {
const EditPublish_eventsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const publish_eventsState = useAppSelector((state) => state.publish_events);
const publish_events = publish_eventsState.publish_events as
| PublishEvent
| PublishEvent[]
| undefined;
const item = Array.isArray(publish_events)
? publish_events[0]
: publish_events;
const { id } = router.query as { id?: string };
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (item && typeof item === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in item) {
(newInitialVal as Record<string, unknown>)[key] = (
item as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [item]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) => state.publish_events.publish_events,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (id) {
@ -169,7 +145,7 @@ const EditPublish_eventsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
started_at: date,
})
@ -191,7 +167,7 @@ const EditPublish_eventsPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
finished_at: date,
})

View File

@ -1,11 +1,10 @@
/**
* Edit PWA Caches Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
@ -25,8 +24,9 @@ import { SelectField } from '../../components/SelectField';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/pwa_caches/pwa_cachesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
const initVals = {
project: null,
@ -41,34 +41,22 @@ const initVals = {
const EditPwa_cachesPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { pwa_caches } = useAppSelector((state) => state.pwa_caches);
const { id } = router.query;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof pwa_caches === 'object' && pwa_caches !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in pwa_caches) {
newInitialVal[key] = pwa_caches[key];
}
});
setInitialValues(newInitialVal);
}
}, [pwa_caches]);
const {
values: initialValues,
setValues,
id,
} = useEditPageSync({
entitySelector: (state) => state.pwa_caches.pwa_caches,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/pwa_caches/pwa_caches-list');
if (id) {
await dispatch(update({ id, data }));
await router.push('/pwa_caches/pwa_caches-list');
}
};
return (
@ -143,7 +131,7 @@ const EditPwa_cachesPage = () => {
: null
}
onChange={(date: Date | null) =>
setInitialValues({
setValues({
...initialValues,
generated_at: date || new Date(),
})

View File

@ -1,11 +1,6 @@
/**
* Edit Roles Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -23,43 +18,28 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
import { update, fetch } from '../../stores/roles/rolesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
const initVals = {
name: '',
permissions: [],
permissions: [] as Array<{ id: string; name: string }>,
};
const EditRolesPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { roles } = useAppSelector((state) => state.roles);
const { id } = router.query;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof roles === 'object' && roles !== null) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in roles) {
newInitialVal[key] = roles[key];
}
});
setInitialValues(newInitialVal);
}
}, [roles]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.roles.roles,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/roles/roles-list');
if (id) {
await dispatch(update({ id, data }));
await router.push('/roles/roles-list');
}
};
return (

View File

@ -1,37 +0,0 @@
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import TableSampleClients from '../components/TableSampleClients';
import { getPageTitle } from '../config';
const TablesPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Tables')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Table'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' hasTable>
<TableSampleClients />
</CardBox>
</SectionMain>
</>
);
};
TablesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default TablesPage;

View File

@ -1,11 +1,6 @@
/**
* Edit Tour Pages Page
* Cleaned up version with consolidated useEffect hooks
*/
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -22,8 +17,9 @@ import { SelectField } from '../../components/SelectField';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/tour_pages/tour_pagesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
import type { TourPage } from '../../types/entities';
const initVals = {
@ -44,44 +40,17 @@ const initVals = {
const EditTour_pagesPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const tourPagesState = useAppSelector((state) => state.tour_pages);
const tour_pages = tourPagesState.tour_pages as
| TourPage
| TourPage[]
| undefined;
const tourPage = Array.isArray(tour_pages) ? tour_pages[0] : tour_pages;
const { id } = router.query as { id?: string };
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch entity data
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (tourPage && typeof tourPage === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in tourPage) {
(newInitialVal as Record<string, unknown>)[key] = (
tourPage as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [tourPage]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.tour_pages.tour_pages,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
if (id) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<TourPage> }),
update({ id, data: data as unknown as Partial<TourPage> }),
);
await router.push('/tour_pages/tour_pages-list');
}

View File

@ -1,6 +1,6 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
@ -19,8 +19,9 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/users/usersSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync';
const initVals = {
firstName: '',
@ -28,46 +29,27 @@ const initVals = {
phoneNumber: '',
email: '',
disabled: false,
avatar: [],
app_role: null,
custom_permissions: [],
avatar: [] as Array<{ id: string; publicUrl: string }>,
app_role: null as { id: string; name: string } | null,
custom_permissions: [] as Array<{ id: string; name: string }>,
password: '',
};
const EditUsersPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { users } = useAppSelector((state) => state.users);
const { id } = router.query;
// Fetch user data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
}
}, [id, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof users === 'object' && users && !Array.isArray(users)) {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => {
if (el in users) {
(newInitialVal as Record<string, unknown>)[el] = (
users as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [users]);
const { values: initialValues, id } = useEditPageSync({
entitySelector: (state) => state.users.users,
fetchAction: fetch,
initialValues: initVals,
});
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/users/users-list');
if (id) {
await dispatch(update({ id, data }));
await router.push('/users/users-list');
}
};
return (

View File

@ -242,3 +242,133 @@ export function buildElementDefaultsMap(
return map;
}
// ============================================================================
// Grouped Props for ElementEditorPanel
// ============================================================================
/**
* Editor layout props: panel position and collapse state
*/
export interface EditorLayoutProps {
elementEditorRef: React.RefObject<HTMLDivElement | null>;
position: { x: number; y: number };
isCollapsed: boolean;
onToggleCollapse: () => void;
onDragStart: (event: React.MouseEvent) => void;
}
/**
* Editor state props: tabs and title
*/
export interface EditorStateProps {
title: string;
activeTab: 'general' | 'css' | 'effects';
onTabChange: (tab: 'general' | 'css' | 'effects') => void;
}
/**
* Selected element props
*/
export interface EditorElementProps {
selectedElement: CanvasElement | null;
selectedMenuItem:
| 'none'
| 'background_image'
| 'background_video'
| 'background_audio'
| 'create_transition';
onRemoveElement: () => void;
onUpdateElement: (patch: Partial<CanvasElement>) => void;
}
/**
* Background settings props
*/
export interface EditorBackgroundProps {
backgroundImageUrl: string;
backgroundVideoUrl: string;
backgroundAudioUrl: string;
onBackgroundImageChange: (value: string) => void;
onBackgroundVideoChange: (value: string) => void;
onBackgroundAudioChange: (value: string) => void;
}
/**
* Transition creation props
*/
export interface EditorTransitionProps {
newTransitionName: string;
newTransitionVideoUrl: string;
newTransitionSupportsReverse: boolean;
isCreatingTransition: boolean;
onNewTransitionNameChange: (value: string) => void;
onNewTransitionVideoUrlChange: (value: string) => void;
onNewTransitionSupportsReverseChange: (value: boolean) => void;
onCreateTransition: () => void;
}
/**
* Duration notes props
*/
export interface EditorDurationNotesProps {
backgroundVideoDurationNote: string;
backgroundAudioDurationNote: string;
newTransitionDurationNote: string;
selectedMediaDurationNote: string;
selectedTransitionDurationNote: string;
}
/**
* Asset options for dropdowns
*/
export interface EditorAssetOptionsProps {
backgroundImageAssetOptions: AssetOption[];
videoAssetOptions: AssetOption[];
audioAssetOptions: AssetOption[];
transitionVideoAssetOptions: AssetOption[];
iconAssetOptions: AssetOption[];
imageAssetOptions: AssetOption[];
}
/**
* Navigation settings props
*/
export interface EditorNavigationProps {
allowedNavigationTypes: Array<'navigation_next' | 'navigation_prev'>;
pages: Array<{
id: string;
name?: string;
slug?: string;
sort_order?: number;
}>;
activePageId: string;
onPreviewTransition: (direction: 'forward' | 'back') => void;
normalizeNavigationType: (
element: CanvasElement,
nextType: 'navigation_next' | 'navigation_prev',
) => CanvasElement;
}
/**
* Gallery/Carousel operations props
*/
export interface EditorCollectionOpsProps {
galleryCards: {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
};
carouselSlides: {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
}
/**
* Media utilities props
*/
export interface EditorMediaUtilsProps {
getDuration: (url: string) => number | undefined;
}