From b66cf94fb420e734c09169ece9c2037633cf3f31 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 30 Mar 2026 12:51:55 +0400 Subject: [PATCH] improved project architecture (BE and FE) --- README.md | 4 +- backend/README.md | 13 +- backend/src/db/api/access_logs.js | 158 +- backend/src/db/api/asset_variants.js | 131 +- backend/src/db/api/assets.js | 114 +- backend/src/db/api/base.api.js | 77 +- backend/src/db/api/element_type_defaults.js | 34 +- backend/src/db/api/file.js | 2 +- backend/src/db/api/presigned_url_requests.js | 158 +- .../src/db/api/project_element_defaults.js | 46 +- backend/src/db/api/project_memberships.js | 152 +- backend/src/db/api/publish_events.js | 158 +- backend/src/db/api/pwa_caches.js | 125 +- backend/src/db/api/roles.js | 124 +- backend/src/index.js | 19 +- backend/src/middlewares/rateLimiter.js | 268 ++++ backend/src/middlewares/validate.js | 124 -- backend/src/routes/auth.js | 47 +- backend/src/routes/contactForm.js | 0 backend/src/routes/file.js | 2 +- backend/src/routes/organizationLogin.js | 0 backend/src/services/file.js | 1279 ++++------------- .../src/services/file/BaseStorageProvider.js | 88 ++ .../src/services/file/LocalStorageProvider.js | 221 +++ .../src/services/file/S3StorageProvider.js | 256 ++++ .../src/services/file/UploadSessionManager.js | 252 ++++ backend/src/services/file/index.js | 27 + backend/src/utils/circuit-breaker.js | 95 -- backend/src/utils/events.js | 74 - backend/src/utils/index.js | 2 - frontend/README.md | 29 +- frontend/next.config.mjs | 4 +- frontend/public/sw.js | 2 +- .../Access_logs/TableAccess_logs.tsx | 47 +- .../Access_logs/configureAccess_logsCols.tsx | 207 +-- .../Asset_variants/TableAsset_variants.tsx | 47 +- .../configureAsset_variantsCols.tsx | 179 +-- .../src/components/Assets/TableAssets.tsx | 47 +- .../components/Assets/configureAssetsCols.tsx | 330 +---- frontend/src/components/BigCalendar.tsx | 171 --- .../src/components/ChartLineSample/config.ts | 54 - .../src/components/ChartLineSample/index.tsx | 44 - .../DataGrid/configBuilderFactory.tsx | 301 ++++ .../Factory/createTableComponent.tsx | 118 ++ frontend/src/components/FormImagePicker.tsx | 1 - .../components/KanbanBoard/KanbanBoard.tsx | 51 - .../src/components/KanbanBoard/KanbanCard.tsx | 70 - .../components/KanbanBoard/KanbanColumn.tsx | 218 --- .../Permissions/TablePermissions.tsx | 47 +- .../Permissions/configurePermissionsCols.tsx | 78 +- .../TablePresigned_url_requests.tsx | 45 +- .../configurePresigned_url_requestsCols.tsx | 240 +--- .../TableProject_audio_tracks.tsx | 47 +- .../configureProject_audio_tracksCols.tsx | 241 +--- .../TableProject_memberships.tsx | 47 +- .../configureProject_membershipsCols.tsx | 201 +-- .../src/components/Projects/TableProjects.tsx | 47 +- .../Projects/configureProjectsCols.tsx | 253 +--- .../Publish_events/TablePublish_events.tsx | 47 +- .../configurePublish_eventsCols.tsx | 328 ++--- .../components/Pwa_caches/TablePwa_caches.tsx | 47 +- .../Pwa_caches/configurePwa_cachesCols.tsx | 206 +-- frontend/src/components/Roles/TableRoles.tsx | 47 +- .../components/Roles/configureRolesCols.tsx | 115 +- .../src/components/TableSampleClients.tsx | 149 -- frontend/src/components/TourFlowManager.tsx | 80 +- .../components/Tour_pages/TableTour_pages.tsx | 47 +- .../Tour_pages/configureTour_pagesCols.tsx | 287 +--- .../src/components/Uploaders/FilesUploader.js | 133 -- .../components/Uploaders/ImagesUploader.js | 227 --- .../src/components/Uploaders/UploadService.js | 6 +- frontend/src/components/Users/TableUsers.tsx | 47 +- .../components/Users/configureUsersCols.tsx | 236 +-- frontend/src/factories/createFormPage.tsx | 22 + frontend/src/helpers/dataFormatter.js | 4 +- frontend/src/helpers/textFormatters.ts | 53 + frontend/src/helpers/zodAdapter.ts | 87 -- frontend/src/hooks/sampleData.ts | 22 - frontend/src/hooks/useDashboardCounts.ts | 308 ++++ frontend/src/hooks/useEditPageSync.ts | 166 +++ frontend/src/lib/slugHelpers.ts | 88 ++ frontend/src/lib/tourFlowHelpers.ts | 118 ++ .../pages/access_logs/access_logs-edit.tsx | 50 +- .../asset_variants/asset_variants-edit.tsx | 53 +- frontend/src/pages/assets/assets-edit.tsx | 51 +- frontend/src/pages/dashboard.tsx | 598 ++------ frontend/src/pages/forms.tsx | 162 --- .../pages/permissions/permissions-edit.tsx | 44 +- .../presigned_url_requests-edit.tsx | 53 +- .../project_audio_tracks-edit.tsx | 51 +- .../project_memberships-edit.tsx | 54 +- .../publish_events/publish_events-edit.tsx | 52 +- .../src/pages/pwa_caches/pwa_caches-edit.tsx | 46 +- frontend/src/pages/roles/roles-edit.tsx | 44 +- frontend/src/pages/tables.tsx | 37 - .../src/pages/tour_pages/tour_pages-edit.tsx | 51 +- frontend/src/pages/users/users-edit.tsx | 48 +- frontend/src/types/constructor.ts | 130 ++ 98 files changed, 4080 insertions(+), 7530 deletions(-) create mode 100644 backend/src/middlewares/rateLimiter.js delete mode 100644 backend/src/middlewares/validate.js delete mode 100644 backend/src/routes/contactForm.js delete mode 100644 backend/src/routes/organizationLogin.js create mode 100644 backend/src/services/file/BaseStorageProvider.js create mode 100644 backend/src/services/file/LocalStorageProvider.js create mode 100644 backend/src/services/file/S3StorageProvider.js create mode 100644 backend/src/services/file/UploadSessionManager.js create mode 100644 backend/src/services/file/index.js delete mode 100644 backend/src/utils/circuit-breaker.js delete mode 100644 backend/src/utils/events.js delete mode 100644 frontend/src/components/BigCalendar.tsx delete mode 100644 frontend/src/components/ChartLineSample/config.ts delete mode 100644 frontend/src/components/ChartLineSample/index.tsx create mode 100644 frontend/src/components/DataGrid/configBuilderFactory.tsx create mode 100644 frontend/src/components/Factory/createTableComponent.tsx delete mode 100644 frontend/src/components/KanbanBoard/KanbanBoard.tsx delete mode 100644 frontend/src/components/KanbanBoard/KanbanCard.tsx delete mode 100644 frontend/src/components/KanbanBoard/KanbanColumn.tsx delete mode 100644 frontend/src/components/TableSampleClients.tsx delete mode 100644 frontend/src/components/Uploaders/FilesUploader.js delete mode 100644 frontend/src/components/Uploaders/ImagesUploader.js create mode 100644 frontend/src/helpers/textFormatters.ts delete mode 100644 frontend/src/helpers/zodAdapter.ts delete mode 100644 frontend/src/hooks/sampleData.ts create mode 100644 frontend/src/hooks/useDashboardCounts.ts create mode 100644 frontend/src/hooks/useEditPageSync.ts create mode 100644 frontend/src/lib/slugHelpers.ts create mode 100644 frontend/src/lib/tourFlowHelpers.ts delete mode 100644 frontend/src/pages/forms.tsx delete mode 100644 frontend/src/pages/tables.tsx diff --git a/README.md b/README.md index 17d9437..2573ea0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 7714caf..cb68330 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/src/db/api/access_logs.js b/backend/src/db/api/access_logs.js index 7854a73..c97ba25 100644 --- a/backend/src/db/api/access_logs.js +++ b/backend/src/db/api/access_logs.js @@ -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; diff --git a/backend/src/db/api/asset_variants.js b/backend/src/db/api/asset_variants.js index c6bfc85..5cbffdc 100644 --- a/backend/src/db/api/asset_variants.js +++ b/backend/src/db/api/asset_variants.js @@ -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; diff --git a/backend/src/db/api/assets.js b/backend/src/db/api/assets.js index 42b34e4..be21d27 100644 --- a/backend/src/db/api/assets.js +++ b/backend/src/db/api/assets.js @@ -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; diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index a81bfdd..d81adff 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -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 = {}) { diff --git a/backend/src/db/api/element_type_defaults.js b/backend/src/db/api/element_type_defaults.js index d71dc88..f32ab9d 100644 --- a/backend/src/db/api/element_type_defaults.js +++ b/backend/src/db/api/element_type_defaults.js @@ -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, }; } diff --git a/backend/src/db/api/file.js b/backend/src/db/api/file.js index ce0a33e..1efb039 100644 --- a/backend/src/db/api/file.js +++ b/backend/src/db/api/file.js @@ -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) { diff --git a/backend/src/db/api/presigned_url_requests.js b/backend/src/db/api/presigned_url_requests.js index 2e0324c..d180e42 100644 --- a/backend/src/db/api/presigned_url_requests.js +++ b/backend/src/db/api/presigned_url_requests.js @@ -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; diff --git a/backend/src/db/api/project_element_defaults.js b/backend/src/db/api/project_element_defaults.js index a65bff2..b56c316 100644 --- a/backend/src/db/api/project_element_defaults.js +++ b/backend/src/db/api/project_element_defaults.js @@ -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, }; } diff --git a/backend/src/db/api/project_memberships.js b/backend/src/db/api/project_memberships.js index 8284d7e..067af1e 100644 --- a/backend/src/db/api/project_memberships.js +++ b/backend/src/db/api/project_memberships.js @@ -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; diff --git a/backend/src/db/api/publish_events.js b/backend/src/db/api/publish_events.js index 4348df9..be57143 100644 --- a/backend/src/db/api/publish_events.js +++ b/backend/src/db/api/publish_events.js @@ -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; diff --git a/backend/src/db/api/pwa_caches.js b/backend/src/db/api/pwa_caches.js index 03e30ec..c953c73 100644 --- a/backend/src/db/api/pwa_caches.js +++ b/backend/src/db/api/pwa_caches.js @@ -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; diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js index 43f4daf..6b99d16 100644 --- a/backend/src/db/api/roles.js +++ b/backend/src/db/api/roles.js @@ -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; diff --git a/backend/src/index.js b/backend/src/index.js index fbc8a2a..93d5a1a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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'); diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js new file mode 100644 index 0000000..dceb09b --- /dev/null +++ b/backend/src/middlewares/rateLimiter.js @@ -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, +}; diff --git a/backend/src/middlewares/validate.js b/backend/src/middlewares/validate.js deleted file mode 100644 index a6148f1..0000000 --- a/backend/src/middlewares/validate.js +++ /dev/null @@ -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 }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 0533b08..4ffee9a 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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') { diff --git a/backend/src/routes/contactForm.js b/backend/src/routes/contactForm.js deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index e19c35d..cf52d71 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -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 diff --git a/backend/src/routes/organizationLogin.js b/backend/src/routes/organizationLogin.js deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/services/file.js b/backend/src/services/file.js index ed28eae..af59570 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -1,731 +1,229 @@ -const formidable = require('formidable'); +/** + * File Service + * + * Unified file storage service using Strategy Pattern providers. + * Supports S3, GCloud, and Local storage backends. + */ + const fs = require('fs'); -const config = require('../config'); const path = require('path'); const { pipeline } = require('stream/promises'); -const { v4: uuid } = require('uuid'); const { format } = require('util'); -const { - S3Client, - PutObjectCommand, - GetObjectCommand, - DeleteObjectCommand, - ListObjectsV2Command, - DeleteObjectsCommand, -} = require('@aws-sdk/client-s3'); -const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); -const ensureDirectoryExistence = (filePath) => { - const dirname = path.dirname(filePath); +const config = require('../config'); +const S3StorageProvider = require('./file/S3StorageProvider'); +const LocalStorageProvider = require('./file/LocalStorageProvider'); +const UploadSessionManager = require('./file/UploadSessionManager'); - if (fs.existsSync(dirname)) { - return true; - } +// ============================================================================ +// Provider Initialization (Singleton) +// ============================================================================ - ensureDirectoryExistence(dirname); - fs.mkdirSync(dirname); -}; - -const UPLOAD_SESSIONS_DIR = path.join(config.uploadDir, 'upload_sessions'); -const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; - -const sanitizeFolder = (folder) => { - const value = String(folder || '') - .trim() - .replace(/^\/+|\/+$/g, ''); - - if (!value || value.includes('..')) { - return null; - } - - return value; -}; - -const sanitizeFilename = (filename) => { - const value = path.basename(String(filename || '').trim()); - - if (!value || value === '.' || value === '..') { - return null; - } - - return value; -}; - -const getSessionDir = (sessionId) => path.join(UPLOAD_SESSIONS_DIR, sessionId); -const getSessionMetaPath = (sessionId) => - path.join(getSessionDir(sessionId), 'meta.json'); -const getSessionChunksDir = (sessionId) => - path.join(getSessionDir(sessionId), 'chunks'); -const getSessionChunkPath = (sessionId, chunkIndex) => - path.join(getSessionChunksDir(sessionId), `${String(chunkIndex)}.part`); - -const readSessionMeta = (sessionId) => { - const metaPath = getSessionMetaPath(sessionId); - - if (!fs.existsSync(metaPath)) { - return null; - } - - const raw = fs.readFileSync(metaPath, 'utf8'); - return JSON.parse(raw); -}; - -const writeSessionMeta = (sessionId, payload) => { - const metaPath = getSessionMetaPath(sessionId); - ensureDirectoryExistence(metaPath); - fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), 'utf8'); -}; - -const removeUploadSession = (sessionId) => { - const sessionDir = getSessionDir(sessionId); - - if (fs.existsSync(sessionDir)) { - fs.rmSync(sessionDir, { recursive: true, force: true }); - } -}; - -const cleanupExpiredUploadSessions = () => { - if (!fs.existsSync(UPLOAD_SESSIONS_DIR)) { - return; - } - - const now = Date.now(); - const sessionIds = fs.readdirSync(UPLOAD_SESSIONS_DIR); - - sessionIds.forEach((sessionId) => { - try { - const meta = readSessionMeta(sessionId); - if (!meta) { - removeUploadSession(sessionId); - return; - } - - const updatedAt = new Date( - meta.updatedAt || meta.createdAt || 0, - ).getTime(); - if (!updatedAt || now - updatedAt > UPLOAD_SESSION_TTL_MS) { - removeUploadSession(sessionId); - } - } catch (error) { - console.error(`Failed to cleanup upload session ${sessionId}`, error); - removeUploadSession(sessionId); - } - }); -}; - -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 }); - }); -}; - -// S3 session storage helpers -const S3_UPLOAD_SESSIONS_PREFIX = '_upload_sessions'; - -const getS3SessionMetaKey = (prefix, sessionId) => { - const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, ''); - const sessionPath = `${S3_UPLOAD_SESSIONS_PREFIX}/${sessionId}/meta.json`; - return cleanPrefix ? `${cleanPrefix}/${sessionPath}` : sessionPath; -}; - -const getS3SessionChunkKey = (prefix, sessionId, chunkIndex) => { - const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, ''); - const chunkPath = `${S3_UPLOAD_SESSIONS_PREFIX}/${sessionId}/chunks/${chunkIndex}.part`; - return cleanPrefix ? `${cleanPrefix}/${chunkPath}` : chunkPath; -}; - -const getS3SessionPrefix = (prefix, sessionId) => { - const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, ''); - const sessionPath = `${S3_UPLOAD_SESSIONS_PREFIX}/${sessionId}/`; - return cleanPrefix ? `${cleanPrefix}/${sessionPath}` : sessionPath; -}; - -const readS3SessionMeta = async (client, bucket, prefix, sessionId) => { - try { - const key = getS3SessionMetaKey(prefix, sessionId); - const output = await client.send( - new GetObjectCommand({ Bucket: bucket, Key: key }), - ); - - if (!output || !output.Body) { - return null; - } - - const bodyStr = await output.Body.transformToString(); - return JSON.parse(bodyStr); - } catch (error) { - if (error.name === 'NoSuchKey') { - return null; - } - throw error; - } -}; - -const writeS3SessionMeta = async ( - client, - bucket, - prefix, - sessionId, - payload, -) => { - const key = getS3SessionMetaKey(prefix, sessionId); - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: JSON.stringify(payload, null, 2), - ContentType: 'application/json', - }), - ); -}; - -const uploadS3Chunk = async ( - client, - bucket, - prefix, - sessionId, - chunkIndex, - body, -) => { - const key = getS3SessionChunkKey(prefix, sessionId, chunkIndex); - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: body, - }), - ); -}; - -const downloadS3Chunk = async ( - client, - bucket, - prefix, - sessionId, - chunkIndex, -) => { - const key = getS3SessionChunkKey(prefix, sessionId, chunkIndex); - try { - const output = await client.send( - new GetObjectCommand({ Bucket: bucket, Key: key }), - ); - - if (!output || !output.Body) { - return null; - } - - return output.Body; - } catch (error) { - if (error.name === 'NoSuchKey') { - return null; - } - throw error; - } -}; - -const s3ChunkExists = async (client, bucket, prefix, sessionId, chunkIndex) => { - const key = getS3SessionChunkKey(prefix, sessionId, chunkIndex); - try { - await client.send( - new GetObjectCommand({ Bucket: bucket, Key: key, Range: 'bytes=0-0' }), - ); - return true; - } catch (error) { - if (error.name === 'NoSuchKey') { - return false; - } - throw error; - } -}; - -const removeS3UploadSession = async (client, bucket, prefix, sessionId) => { - const sessionPrefix = getS3SessionPrefix(prefix, sessionId); - - try { - const listResult = await client.send( - new ListObjectsV2Command({ - Bucket: bucket, - Prefix: sessionPrefix, - }), - ); - - if (!listResult.Contents || listResult.Contents.length === 0) { - return; - } - - const objectsToDelete = listResult.Contents.map((obj) => ({ - Key: obj.Key, - })); - - await client.send( - new DeleteObjectsCommand({ - Bucket: bucket, - Delete: { Objects: objectsToDelete }, - }), - ); - } catch (error) { - console.error(`Failed to remove S3 upload session ${sessionId}`, error); - } -}; - -const cleanupExpiredS3UploadSessions = async () => { - const provider = getFileStorageProvider(); - if (provider !== 's3') { - return; - } - - try { - const { client, bucket, prefix } = initS3(); - const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, ''); - const sessionsPrefix = cleanPrefix - ? `${cleanPrefix}/${S3_UPLOAD_SESSIONS_PREFIX}/` - : `${S3_UPLOAD_SESSIONS_PREFIX}/`; - - const listResult = await client.send( - new ListObjectsV2Command({ - Bucket: bucket, - Prefix: sessionsPrefix, - Delimiter: '/', - }), - ); - - if (!listResult.CommonPrefixes || listResult.CommonPrefixes.length === 0) { - return; - } - - const now = Date.now(); - - for (const prefixObj of listResult.CommonPrefixes) { - const sessionPrefix = prefixObj.Prefix; - const sessionId = sessionPrefix - .replace(sessionsPrefix, '') - .replace(/\/$/, ''); - - if (!sessionId) continue; - - try { - const meta = await readS3SessionMeta(client, bucket, prefix, sessionId); - if (!meta) { - await removeS3UploadSession(client, bucket, prefix, sessionId); - continue; - } - - const updatedAt = new Date( - meta.updatedAt || meta.createdAt || 0, - ).getTime(); - if (!updatedAt || now - updatedAt > UPLOAD_SESSION_TTL_MS) { - await removeS3UploadSession(client, bucket, prefix, sessionId); - } - } catch (error) { - console.error( - `Failed to cleanup S3 upload session ${sessionId}`, - error, - ); - await removeS3UploadSession(client, bucket, prefix, sessionId); - } - } - } catch (error) { - console.error('Failed to cleanup expired S3 upload sessions', error); - } -}; - -const uploadLocal = ( - folder, - validations = { - entity: null, - folderIncludesAuthenticationUid: false, - }, -) => { - return (req, res) => { - if (!req.currentUser) { - res.sendStatus(403); - return; - } - - if (validations.entity) { - res.sendStatus(403); - return; - } - - if (validations.folderIncludesAuthenticationUid) { - folder = folder.replace(':userId', req.currentUser.authenticationUid); - if ( - !req.currentUser.authenticationUid || - !folder.includes(req.currentUser.authenticationUid) - ) { - res.sendStatus(403); - return; - } - } - - const form = new formidable.IncomingForm(); - form.uploadDir = config.uploadDir; - - form.parse(req, function (err, fields, files) { - const filename = String(fields.filename); - const fileTempUrl = files.file.path; - - if (!filename) { - fs.unlinkSync(fileTempUrl); - res.sendStatus(500); - return; - } - - const privateUrl = path.join(form.uploadDir, folder, filename); - ensureDirectoryExistence(privateUrl); - fs.renameSync(fileTempUrl, privateUrl); - res.sendStatus(200); - }); - - form.on('error', function (err) { - res.status(500).send(err); - }); - }; -}; - -const downloadLocal = async (req, res) => { - const privateUrl = req.query.privateUrl; - if (!privateUrl) { - return res.sendStatus(404); - } - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - res.download(path.join(config.uploadDir, privateUrl)); -}; - -const deleteLocal = async (privateUrl) => { - try { - if (!privateUrl) { - return; - } - - const filePath = path.join(config.uploadDir, privateUrl); - - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } catch (error) { - console.error(`Cannot delete local file ${privateUrl}`, error); - throw error; - } -}; - -const initGCloud = () => { - const processFile = require('../middlewares/upload'); - const { Storage } = require('@google-cloud/storage'); - - const hash = config.gcloud.hash; - - const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n'); - - const storage = new Storage({ - projectId: process.env.GC_PROJECT_ID, - credentials: { - client_email: process.env.GC_CLIENT_EMAIL, - private_key: privateKey, - }, - }); - - const bucket = storage.bucket(config.gcloud.bucket); - return { hash, bucket, processFile }; -}; +let s3Provider = null; +let localProvider = null; +let gcloudBucket = null; +let gcloudHash = null; +let uploadSessionManager = null; const getFileStorageProvider = () => { - const provider = (process.env.FILE_STORAGE_PROVIDER || '') - .trim() - .toLowerCase(); + const provider = (process.env.FILE_STORAGE_PROVIDER || '').trim().toLowerCase(); + if (provider) return provider; - if (provider) { - return provider; - } - - const hasS3Credentials = Boolean( - config.s3.bucket && - config.s3.region && - config.s3.accessKeyId && - config.s3.secretAccessKey, + const hasS3 = Boolean( + config.s3.bucket && config.s3.region && + config.s3.accessKeyId && config.s3.secretAccessKey ); + if (hasS3) return 's3'; - if (hasS3Credentials) { - return 's3'; - } - - const hasGCloudCredentials = Boolean( - process.env.GC_PROJECT_ID && - process.env.GC_CLIENT_EMAIL && - process.env.GC_PRIVATE_KEY && - config.gcloud.bucket && - config.gcloud.hash, + const hasGCloud = Boolean( + process.env.GC_PROJECT_ID && process.env.GC_CLIENT_EMAIL && + process.env.GC_PRIVATE_KEY && config.gcloud.bucket && config.gcloud.hash ); - - if (hasGCloudCredentials) { - return 'gcloud'; - } + if (hasGCloud) return 'gcloud'; return 'local'; }; -const initS3 = () => { - const processFile = require('../middlewares/upload'); - const client = new S3Client({ - region: config.s3.region, - credentials: { +const getS3Provider = () => { + if (!s3Provider) { + s3Provider = new S3StorageProvider({ + bucket: config.s3.bucket, + region: config.s3.region, accessKeyId: config.s3.accessKeyId, secretAccessKey: config.s3.secretAccessKey, - }, - }); - - return { - client, - bucket: config.s3.bucket, - region: config.s3.region, - prefix: config.s3.prefix, - processFile, - }; -}; - -const buildStoragePath = (prefix, privateUrl) => { - const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, ''); - const cleanPrivateUrl = String(privateUrl || '').replace(/^\/+/, ''); - - if (!cleanPrefix) { - return cleanPrivateUrl; - } - - return `${cleanPrefix}/${cleanPrivateUrl}`; -}; - -const uploadGCloud = async (folder, req, res) => { - try { - const { hash, bucket, processFile } = initGCloud(); - await processFile(req, res); - let buffer = await req.file.buffer; - let filename = await req.body.filename; - - if (!req.file) { - return res.status(400).send({ message: 'Please upload a file!' }); - } - - let path = `${hash}/${folder}/${filename}`; - let blob = bucket.file(path); - - console.log(path); - - const blobStream = blob.createWriteStream({ - resumable: false, - }); - - blobStream.on('error', (err) => { - console.log('Upload error'); - console.log(err.message); - res.status(500).send({ message: err.message }); - }); - - console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); - - blobStream.on('finish', async () => { - const publicUrl = format( - `https://storage.googleapis.com/${bucket.name}/${blob.name}`, - ); - - res.status(200).send({ - message: 'Uploaded the file successfully: ' + path, - url: publicUrl, - }); - }); - - blobStream.end(buffer); - } catch (err) { - console.log(err); - - res.status(500).send({ - message: `Could not upload the file. ${err}`, + prefix: config.s3.prefix, }); } + return s3Provider; }; -const downloadGCloud = async (req, res) => { - try { - const { hash, bucket } = initGCloud(); +const getLocalProvider = () => { + if (!localProvider) { + localProvider = new LocalStorageProvider({ basePath: config.uploadDir }); + } + return localProvider; +}; - const privateUrl = await req.query.privateUrl; - const filePath = `${hash}/${privateUrl}`; - const file = bucket.file(filePath); - const fileExists = await file.exists(); +const getGCloudBucket = () => { + if (!gcloudBucket) { + const { Storage } = require('@google-cloud/storage'); + const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n'); + const storage = new Storage({ + projectId: process.env.GC_PROJECT_ID, + credentials: { + client_email: process.env.GC_CLIENT_EMAIL, + private_key: privateKey, + }, + }); + gcloudBucket = storage.bucket(config.gcloud.bucket); + gcloudHash = config.gcloud.hash; + } + return { bucket: gcloudBucket, hash: gcloudHash }; +}; - if (fileExists[0]) { - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - const stream = file.createReadStream(); - stream.pipe(res); - } else { - res.status(404).send({ - message: 'Could not download the file.', - }); - } - } catch (err) { - res.status(404).send({ - message: 'Could not download the file. ' + err, +const getUploadSessionManager = () => { + if (!uploadSessionManager) { + uploadSessionManager = new UploadSessionManager({ + sessionDir: path.join(config.uploadDir, 'upload_sessions'), + ttlMs: 24 * 60 * 60 * 1000, }); } + return uploadSessionManager; }; -const uploadS3 = async (folder, req, res) => { +// ============================================================================ +// Unified Upload/Download/Delete Interface +// ============================================================================ + +const uploadFile = async (folder, req, res) => { + const provider = getFileStorageProvider(); + try { - const { client, bucket, region, prefix, processFile } = initS3(); + const processFile = require('../middlewares/upload'); await processFile(req, res); - if (!req.file) { - return res.status(400).send({ message: 'Please upload a file!' }); - } + if (!req.file) return res.status(400).send({ message: 'Please upload a file!' }); const filename = req.body.filename; - - if (!filename) { - return res.status(400).send({ message: 'Missing filename' }); - } + if (!filename) return res.status(400).send({ message: 'Missing filename' }); const privateUrl = `${folder}/${filename}`; - const key = buildStoragePath(prefix, privateUrl); + let publicUrl = ''; - await client.send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: req.file.buffer, - ContentType: req.file.mimetype, - }), - ); + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.upload(privateUrl, req.file.buffer, { contentType: req.file.mimetype }); + publicUrl = result.url; + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const filePath = `${hash}/${privateUrl}`; + const blob = bucket.file(filePath); + await new Promise((resolve, reject) => { + const blobStream = blob.createWriteStream({ resumable: false }); + blobStream.on('error', reject); + blobStream.on('finish', resolve); + blobStream.end(req.file.buffer); + }); + publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + } else { + const local = getLocalProvider(); + await local.upload(privateUrl, req.file.buffer); + publicUrl = `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`; + } return res.status(200).send({ message: `Uploaded the file successfully: ${privateUrl}`, - url: `https://${bucket}.s3.${region}.amazonaws.com/${key}`, + url: publicUrl, }); } catch (error) { - console.error('S3 upload error', error); - return res.status(500).send({ - message: `Could not upload the file. ${error.message || error}`, - }); + console.error('Upload error', error); + return res.status(500).send({ message: `Could not upload the file. ${error.message || error}` }); } }; -const downloadS3 = async (req, res) => { +const downloadFile = async (req, res) => { + const provider = getFileStorageProvider(); + const privateUrl = req.query.privateUrl; + + if (!privateUrl) return res.status(404).send({ message: 'Missing privateUrl' }); + + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + try { - const privateUrl = req.query.privateUrl; + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.download(privateUrl); + if (result.contentType) res.setHeader('Content-Type', result.contentType); - if (!privateUrl) { - return res.status(404).send({ message: 'Missing privateUrl' }); + if (typeof result.body.pipe === 'function') { + result.body.pipe(res); + } else if (typeof result.body.transformToByteArray === 'function') { + const bytes = await result.body.transformToByteArray(); + res.send(Buffer.from(bytes)); + } else { + res.send(result.body); + } + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const file = bucket.file(`${hash}/${privateUrl}`); + const [exists] = await file.exists(); + if (exists) { + file.createReadStream().pipe(res); + } else { + res.status(404).send({ message: 'File not found' }); + } + } else { + res.download(path.join(config.uploadDir, privateUrl)); } - - const { client, bucket, prefix } = initS3(); - const key = buildStoragePath(prefix, privateUrl); - const output = await client.send( - new GetObjectCommand({ - Bucket: bucket, - Key: key, - }), - ); - - if (!output || !output.Body) { - return res.status(404).send({ - message: 'Could not download the file.', - }); - } - - if (output.ContentType) { - res.setHeader('Content-Type', output.ContentType); - } - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - - if (typeof output.Body.pipe === 'function') { - output.Body.pipe(res); - return; - } - - if (typeof output.Body.transformToByteArray === 'function') { - const bytes = await output.Body.transformToByteArray(); - res.send(Buffer.from(bytes)); - return; - } - - return res.send(output.Body); } catch (error) { - const statusCode = error && error.name === 'NoSuchKey' ? 404 : 500; - return res.status(statusCode).send({ - message: `Could not download the file. ${error.message || error}`, - }); + const statusCode = error?.name === 'NoSuchKey' ? 404 : 500; + return res.status(statusCode).send({ message: `Could not download the file. ${error.message || error}` }); } }; -const deleteGCloud = async (privateUrl) => { +const deleteFile = async (privateUrl) => { + if (!privateUrl) return; + + const provider = getFileStorageProvider(); + try { - const { hash, bucket } = initGCloud(); - const filePath = `${hash}/${privateUrl}`; - - const file = bucket.file(filePath); - const fileExists = await file.exists(); - - if (fileExists[0]) { - file.delete(); + if (provider === 's3') { + const s3 = getS3Provider(); + await s3.delete(privateUrl); + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const file = bucket.file(`${hash}/${privateUrl}`); + const [exists] = await file.exists(); + if (exists) await file.delete(); + } else { + const local = getLocalProvider(); + await local.delete(privateUrl); } - } catch (err) { - console.log(`Cannot find the file ${privateUrl}`); - } -}; - -const deleteS3 = async (privateUrl) => { - try { - if (!privateUrl) { - return; - } - - const { client, bucket, prefix } = initS3(); - const key = buildStoragePath(prefix, privateUrl); - await client.send( - new DeleteObjectCommand({ - Bucket: bucket, - Key: key, - }), - ); } catch (error) { - console.error(`Cannot delete S3 file ${privateUrl}`, error); - throw error; + console.error(`Failed to delete file ${privateUrl}`, error); } }; -const uploadStreamToGCloud = async (privateUrl, sourcePath) => { - const { hash, bucket } = initGCloud(); - const fullPath = `${hash}/${privateUrl}`; - const blob = bucket.file(fullPath); +// ============================================================================ +// Chunked Upload Session Management +// ============================================================================ - await pipeline( - fs.createReadStream(sourcePath), - blob.createWriteStream({ resumable: false }), - ); +const sanitizeFolder = (folder) => { + const value = String(folder || '').trim().replace(/^\/+|\/+$/g, ''); + return (!value || value.includes('..')) ? null : value; +}; - return format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); +const sanitizeFilename = (filename) => { + const value = path.basename(String(filename || '').trim()); + return (!value || value === '.' || value === '..') ? null : value; }; const initUploadSession = async (req, res) => { try { - if (!req.currentUser || !req.currentUser.id) { - return res.sendStatus(403); - } + if (!req.currentUser?.id) return res.sendStatus(403); - const provider = getFileStorageProvider(); - - // Cleanup expired sessions (async for S3, sync for local) - if (provider === 's3') { - cleanupExpiredS3UploadSessions().catch((err) => - console.error('S3 session cleanup failed', err), - ); - } else { - cleanupExpiredUploadSessions(); - } + const sessionManager = getUploadSessionManager(); + sessionManager.cleanupExpiredSessions(); const folder = sanitizeFolder(req.body?.folder); const filename = sanitizeFilename(req.body?.filename); @@ -733,42 +231,18 @@ const initUploadSession = async (req, res) => { const size = Number(req.body?.size); const contentType = String(req.body?.contentType || '').trim(); - if (!folder || !filename) { - return res.status(400).send({ message: 'Invalid folder or filename' }); - } + if (!folder || !filename) return res.status(400).send({ message: 'Invalid folder or filename' }); + if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send({ message: 'Invalid totalChunks' }); + if (!Number.isFinite(size) || size < 0) return res.status(400).send({ message: 'Invalid file size' }); - if (!Number.isInteger(totalChunks) || totalChunks <= 0) { - return res.status(400).send({ message: 'Invalid totalChunks' }); - } - - if (!Number.isFinite(size) || size < 0) { - return res.status(400).send({ message: 'Invalid file size' }); - } - - const sessionId = uuid(); - const now = new Date().toISOString(); - const session = { - id: sessionId, + const sessionId = sessionManager.createSession({ userId: req.currentUser.id, folder, filename, totalChunks, - size, + totalSize: size, contentType, - uploadedChunks: [], - status: 'active', - createdAt: now, - updatedAt: now, - }; - - if (provider === 's3') { - const { client, bucket, prefix } = initS3(); - await writeS3SessionMeta(client, bucket, prefix, sessionId, session); - } else { - const chunksDir = getSessionChunksDir(sessionId); - fs.mkdirSync(chunksDir, { recursive: true }); - writeSessionMeta(sessionId, session); - } + }); return res.status(200).send({ sessionId, @@ -777,42 +251,26 @@ const initUploadSession = async (req, res) => { }); } catch (error) { console.error('Failed to initialize upload session', error); - return res - .status(500) - .send({ message: 'Failed to initialize upload session' }); + return res.status(500).send({ message: 'Failed to initialize upload session' }); } }; const getUploadSession = async (req, res) => { try { - if (!req.currentUser || !req.currentUser.id) { - return res.sendStatus(403); - } + if (!req.currentUser?.id) return res.sendStatus(403); const sessionId = String(req.params.sessionId || ''); - const provider = getFileStorageProvider(); - let session; + const sessionManager = getUploadSessionManager(); + const session = sessionManager.readMeta(sessionId); - if (provider === 's3') { - const { client, bucket, prefix } = initS3(); - session = await readS3SessionMeta(client, bucket, prefix, sessionId); - } else { - session = readSessionMeta(sessionId); - } - - if (!session) { - return res.status(404).send({ message: 'Upload session not found' }); - } - - if (session.userId !== req.currentUser.id) { - return res.sendStatus(403); - } + if (!session) return res.status(404).send({ message: 'Upload session not found' }); + if (session.userId !== req.currentUser.id) return res.sendStatus(403); return res.status(200).send({ - sessionId: session.id, + sessionId: session.sessionId, totalChunks: session.totalChunks, - uploadedChunks: session.uploadedChunks || [], - status: session.status, + uploadedChunks: Object.keys(session.uploadedChunks || {}).map(Number), + status: sessionManager.isComplete(sessionId) ? 'complete' : 'uploading', }); } catch (error) { console.error('Failed to get upload session', error); @@ -822,9 +280,7 @@ const getUploadSession = async (req, res) => { const uploadChunk = async (req, res) => { try { - if (!req.currentUser || !req.currentUser.id) { - return res.sendStatus(403); - } + if (!req.currentUser?.id) return res.sendStatus(403); const sessionId = String(req.params.sessionId || ''); const chunkIndex = Number(req.params.chunkIndex); @@ -833,93 +289,25 @@ const uploadChunk = async (req, res) => { return res.status(400).send({ message: 'Invalid chunk index' }); } - const provider = getFileStorageProvider(); - let session; - let s3Client, s3Bucket, s3Prefix; + const sessionManager = getUploadSessionManager(); + const session = sessionManager.readMeta(sessionId); - if (provider === 's3') { - const s3 = initS3(); - s3Client = s3.client; - s3Bucket = s3.bucket; - s3Prefix = s3.prefix; - session = await readS3SessionMeta( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - ); - } else { - session = readSessionMeta(sessionId); - } + if (!session) return res.status(404).send({ message: 'Upload session not found' }); + if (session.userId !== req.currentUser.id) return res.sendStatus(403); + if (chunkIndex >= session.totalChunks) return res.status(400).send({ message: 'Chunk index is out of range' }); - if (!session) { - return res.status(404).send({ message: 'Upload session not found' }); - } + // Collect chunk data + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const chunkBuffer = Buffer.concat(chunks); - if (session.userId !== req.currentUser.id) { - return res.sendStatus(403); - } - - if (chunkIndex >= Number(session.totalChunks)) { - return res.status(400).send({ message: 'Chunk index is out of range' }); - } - - if (provider === 's3') { - // Collect chunk data from request stream - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - const chunkBuffer = Buffer.concat(chunks); - - // Upload chunk directly to S3 - await uploadS3Chunk( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - chunkIndex, - chunkBuffer, - ); - } else { - // Local storage - write to temp file then rename - const chunkDir = getSessionChunksDir(sessionId); - fs.mkdirSync(chunkDir, { recursive: true }); - - const chunkPath = getSessionChunkPath(sessionId, chunkIndex); - const tempChunkPath = `${chunkPath}.tmp`; - - if (fs.existsSync(tempChunkPath)) { - fs.unlinkSync(tempChunkPath); - } - - await pipeline(req, fs.createWriteStream(tempChunkPath)); - fs.renameSync(tempChunkPath, chunkPath); - } - - const uploadedChunks = Array.from( - new Set([...(session.uploadedChunks || []), chunkIndex]), - ).sort((a, b) => a - b); - - session.uploadedChunks = uploadedChunks; - session.updatedAt = new Date().toISOString(); - - if (provider === 's3') { - await writeS3SessionMeta( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - session, - ); - } else { - writeSessionMeta(sessionId, session); - } + await sessionManager.saveChunk(sessionId, chunkIndex, chunkBuffer); + const updatedSession = sessionManager.readMeta(sessionId); return res.status(200).send({ sessionId, chunkIndex, - uploadedChunks: uploadedChunks.length, + uploadedChunks: Object.keys(updatedSession.uploadedChunks || {}).length, totalChunks: session.totalChunks, }); } catch (error) { @@ -930,175 +318,54 @@ const uploadChunk = async (req, res) => { const finalizeUploadSession = async (req, res) => { try { - if (!req.currentUser || !req.currentUser.id) { - return res.sendStatus(403); - } + if (!req.currentUser?.id) return res.sendStatus(403); const sessionId = String(req.params.sessionId || ''); - const provider = getFileStorageProvider(); - let session; - let s3Client, s3Bucket, s3Prefix, s3Region; + const sessionManager = getUploadSessionManager(); + const session = sessionManager.readMeta(sessionId); - if (provider === 's3') { - const s3 = initS3(); - s3Client = s3.client; - s3Bucket = s3.bucket; - s3Prefix = s3.prefix; - s3Region = s3.region; - session = await readS3SessionMeta( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - ); - } else { - session = readSessionMeta(sessionId); - } - - if (!session) { - return res.status(404).send({ message: 'Upload session not found' }); - } - - if (session.userId !== req.currentUser.id) { - return res.sendStatus(403); - } - - const totalChunks = Number(session.totalChunks); + if (!session) return res.status(404).send({ message: 'Upload session not found' }); + if (session.userId !== req.currentUser.id) return res.sendStatus(403); // Verify all chunks exist - if (provider === 's3') { - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { - const exists = await s3ChunkExists( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - chunkIndex, - ); - if (!exists) { - return res.status(400).send({ - message: `Missing chunk ${chunkIndex}`, - missingChunk: chunkIndex, - }); - } - } - } else { - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { - const chunkPath = getSessionChunkPath(sessionId, chunkIndex); - if (!fs.existsSync(chunkPath)) { - return res.status(400).send({ - message: `Missing chunk ${chunkIndex}`, - missingChunk: chunkIndex, - }); - } + for (let i = 0; i < session.totalChunks; i++) { + if (!sessionManager.chunkExists(sessionId, i)) { + return res.status(400).send({ message: `Missing chunk ${i}`, missingChunk: i }); } } - // Create temp directory for assembly - const tempDir = path.join(config.uploadDir, '_temp_assembly'); - fs.mkdirSync(tempDir, { recursive: true }); - const assembledPath = path.join(tempDir, `${sessionId}.bin`); - - if (fs.existsSync(assembledPath)) { - fs.unlinkSync(assembledPath); - } - - // Download and assemble chunks - if (provider === 's3') { - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { - const chunkStream = await downloadS3Chunk( - s3Client, - s3Bucket, - s3Prefix, - sessionId, - chunkIndex, - ); - if (!chunkStream) { - // Cleanup and return error - if (fs.existsSync(assembledPath)) fs.unlinkSync(assembledPath); - return res.status(400).send({ - message: `Failed to download chunk ${chunkIndex}`, - missingChunk: chunkIndex, - }); - } - - // Write chunk to assembled file - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(assembledPath, { - flags: 'a', - }); - writeStream.on('error', reject); - writeStream.on('finish', resolve); - - if (typeof chunkStream.pipe === 'function') { - chunkStream.on('error', reject); - chunkStream.pipe(writeStream, { end: true }); - } else if (typeof chunkStream.transformToByteArray === 'function') { - chunkStream - .transformToByteArray() - .then((bytes) => { - writeStream.write(Buffer.from(bytes)); - writeStream.end(); - }) - .catch(reject); - } else { - writeStream.write(chunkStream); - writeStream.end(); - } - }); - } - } else { - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { - const chunkPath = getSessionChunkPath(sessionId, chunkIndex); - await streamAppendFile(assembledPath, chunkPath); - } - } - - const assembledStats = fs.statSync(assembledPath); - if ( - Number.isFinite(Number(session.size)) && - Number(session.size) !== assembledStats.size - ) { - // Cleanup - if (fs.existsSync(assembledPath)) fs.unlinkSync(assembledPath); - return res.status(400).send({ - message: 'Assembled file size mismatch', - }); - } + // Assemble file to temp location + const assembledPath = path.join(config.uploadDir, `assembled_${sessionId}_${Date.now()}`); + await sessionManager.assembleChunks(sessionId, assembledPath); const privateUrl = `${session.folder}/${session.filename}`; let publicUrl = ''; + const provider = getFileStorageProvider(); - if (provider === 's3') { - // Upload assembled file to final S3 location - const key = buildStoragePath(s3Prefix, privateUrl); - await s3Client.send( - new PutObjectCommand({ - Bucket: s3Bucket, - Key: key, - Body: fs.createReadStream(assembledPath), - ContentType: session.contentType || undefined, - }), - ); - publicUrl = `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${key}`; - - // Cleanup S3 session - await removeS3UploadSession(s3Client, s3Bucket, s3Prefix, sessionId); - } else if (provider === 'gcloud') { - publicUrl = await uploadStreamToGCloud(privateUrl, assembledPath); - removeUploadSession(sessionId); - } else { - const destinationPath = path.join(config.uploadDir, privateUrl); - ensureDirectoryExistence(destinationPath); - fs.renameSync(assembledPath, destinationPath); - publicUrl = `/file/download?privateUrl=${encodeURIComponent(privateUrl)}`; - removeUploadSession(sessionId); + try { + if (provider === 's3') { + const s3 = getS3Provider(); + const data = fs.readFileSync(assembledPath); + const result = await s3.upload(privateUrl, data, { contentType: session.contentType }); + publicUrl = result.url; + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const blob = bucket.file(`${hash}/${privateUrl}`); + await pipeline(fs.createReadStream(assembledPath), blob.createWriteStream({ resumable: false })); + publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + } else { + const local = getLocalProvider(); + const data = fs.readFileSync(assembledPath); + await local.upload(privateUrl, data); + publicUrl = `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`; + } + } finally { + // Cleanup temp assembled file + if (fs.existsSync(assembledPath)) fs.unlinkSync(assembledPath); } - // Cleanup temp assembled file (except for local where we renamed it) - if (provider !== 'local' && fs.existsSync(assembledPath)) { - fs.unlinkSync(assembledPath); - } + // Cleanup session + sessionManager.removeSession(sessionId); return res.status(200).send({ message: `Uploaded the file successfully: ${privateUrl}`, @@ -1107,114 +374,54 @@ const finalizeUploadSession = async (req, res) => { }); } catch (error) { console.error('Failed to finalize upload session', error); - return res - .status(500) - .send({ message: 'Failed to finalize upload session' }); + return res.status(500).send({ message: 'Failed to finalize upload session' }); } }; -const uploadFile = async (folder, req, res) => { - const provider = getFileStorageProvider(); +// ============================================================================ +// Presigned URLs +// ============================================================================ - if (provider === 's3') { - return uploadS3(folder, req, res); - } +const PRESIGN_EXPIRY_SECONDS = 3600; - if (provider === 'gcloud') { - return uploadGCloud(folder, req, res); - } - - return uploadLocal(folder, { - entity: null, - folderIncludesAuthenticationUid: false, - })(req, res); -}; - -const downloadFile = async (req, res) => { - const provider = getFileStorageProvider(); - - if (provider === 's3') { - return downloadS3(req, res); - } - - if (provider === 'gcloud') { - return downloadGCloud(req, res); - } - - return downloadLocal(req, res); -}; - -const deleteFile = async (privateUrl) => { - const provider = getFileStorageProvider(); - - if (provider === 's3') { - return deleteS3(privateUrl); - } - - if (provider === 'gcloud') { - return deleteGCloud(privateUrl); - } - - return deleteLocal(privateUrl); -}; - -const PRESIGN_EXPIRY_SECONDS = 3600; // 1 hour - -/** - * Generate presigned GET URLs for multiple assets. - * For S3: returns direct S3 signed URLs. - * For other providers: returns backend proxy URLs. - * - * @param {string[]} urls - Array of storage_key paths - * @returns {Promise>} Map of original path to presigned/proxy URL - */ const generatePresignedUrls = async (urls) => { const provider = getFileStorageProvider(); if (provider !== 's3') { - // For non-S3 providers, return backend proxy URLs return urls.reduce((acc, url) => { acc[url] = `/api/file/download?privateUrl=${encodeURIComponent(url)}`; return acc; }, {}); } - const { client, bucket, prefix } = initS3(); - + const s3 = getS3Provider(); const presignedUrls = {}; await Promise.all( urls.map(async (url) => { - const key = buildStoragePath(prefix, url); - const command = new GetObjectCommand({ Bucket: bucket, Key: key }); - presignedUrls[url] = await getSignedUrl(client, command, { - expiresIn: PRESIGN_EXPIRY_SECONDS, - }); - }), + presignedUrls[url] = await s3.getSignedUrl(url, PRESIGN_EXPIRY_SECONDS); + }) ); return presignedUrls; }; +// ============================================================================ +// Exports +// ============================================================================ + module.exports = { + // Provider detection + getFileStorageProvider, + // Unified interface + uploadFile, + downloadFile, + deleteFile, + // Session-based chunked uploads initUploadSession, getUploadSession, uploadChunk, finalizeUploadSession, - initGCloud, - initS3, - getFileStorageProvider, - uploadFile, - downloadFile, - deleteFile, - uploadLocal, - downloadLocal, - deleteLocal, - deleteGCloud, - deleteS3, - uploadGCloud, - downloadGCloud, - uploadS3, - downloadS3, + // Presigned URLs generatePresignedUrls, }; diff --git a/backend/src/services/file/BaseStorageProvider.js b/backend/src/services/file/BaseStorageProvider.js new file mode 100644 index 0000000..d45c36a --- /dev/null +++ b/backend/src/services/file/BaseStorageProvider.js @@ -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} + */ + 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} + */ + 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} 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} 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} + */ + async deleteMany(keys) { + for (const key of keys) { + await this.delete(key); + } + } +} + +module.exports = BaseStorageProvider; diff --git a/backend/src/services/file/LocalStorageProvider.js b/backend/src/services/file/LocalStorageProvider.js new file mode 100644 index 0000000..fa1b1cb --- /dev/null +++ b/backend/src/services/file/LocalStorageProvider.js @@ -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} + */ + 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} + */ + 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} + */ + 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} 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} + */ + 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; diff --git a/backend/src/services/file/S3StorageProvider.js b/backend/src/services/file/S3StorageProvider.js new file mode 100644 index 0000000..b908bb3 --- /dev/null +++ b/backend/src/services/file/S3StorageProvider.js @@ -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} + */ + 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} + */ + 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} + */ + 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} 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} 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; diff --git a/backend/src/services/file/UploadSessionManager.js b/backend/src/services/file/UploadSessionManager.js new file mode 100644 index 0000000..e60b2f3 --- /dev/null +++ b/backend/src/services/file/UploadSessionManager.js @@ -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; diff --git a/backend/src/services/file/index.js b/backend/src/services/file/index.js new file mode 100644 index 0000000..dccee10 --- /dev/null +++ b/backend/src/services/file/index.js @@ -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, +}; diff --git a/backend/src/utils/circuit-breaker.js b/backend/src/utils/circuit-breaker.js deleted file mode 100644 index 557f443..0000000 --- a/backend/src/utils/circuit-breaker.js +++ /dev/null @@ -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 }; diff --git a/backend/src/utils/events.js b/backend/src/utils/events.js deleted file mode 100644 index 7d2cee4..0000000 --- a/backend/src/utils/events.js +++ /dev/null @@ -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 }; diff --git a/backend/src/utils/index.js b/backend/src/utils/index.js index 04b4e18..051340b 100644 --- a/backend/src/utils/index.js +++ b/backend/src/utils/index.js @@ -1,7 +1,5 @@ module.exports = { ...require('./errors'), ...require('./logger'), - ...require('./circuit-breaker'), - ...require('./events'), envValidation: require('./env-validation'), }; diff --git a/frontend/README.md b/frontend/README.md index 44380cb..3603bf6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 74249cf..830f247 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -21,10 +21,10 @@ const nextConfig = { position: 'bottom-left', }, typescript: { - ignoreBuildErrors: true, + ignoreBuildErrors: false, }, eslint: { - ignoreDuringBuilds: true, + ignoreDuringBuilds: false, }, images: { unoptimized: true, diff --git a/frontend/public/sw.js b/frontend/public/sw.js index c900d15..0dc7142 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},r=e=>[s.prefix,e,s.suffix].filter(e=>e&&e.length>0).join("-"),n={updateDetails:e=>{var t=t=>{let a=e[t];"string"==typeof a&&(s[t]=a)};for(let e of Object.keys(s))t(e)},getGoogleAnalyticsName:e=>e||r(s.googleAnalytics),getPrecacheName:e=>e||r(s.precache),getRuntimeName:e=>e||r(s.runtime)};class i extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}}function c(e){return new Promise(t=>setTimeout(t,e))}let o=new Set;function l(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function h(e,t,a,s){let r=l(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===l(i.url,a))return e.match(i,s)}class u{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}let d=async()=>{for(let e of o)await e()},m="-precache-",g=async(e,t=m)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},p=(e,t)=>{let a=t();return e.waitUntil(a),a},f=(e,t)=>t.some(t=>e instanceof t),w=new WeakMap,y=new WeakMap,_=new WeakMap,b={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return w.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return x(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function x(e){if(e instanceof IDBRequest){let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(x(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)});return _.set(t,e),t}if(y.has(e))return y.get(e);let s=function(e){if("function"==typeof e)return(a||(a=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(R(this),t),x(this.request)}:function(...t){return x(e.apply(R(this),t))};return(e instanceof IDBTransaction&&function(e){if(w.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});w.set(e,t)}(e),f(e,t||(t=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,b):e}(e);return s!==e&&(y.set(e,s),_.set(s,e)),s}let R=e=>_.get(e);function v(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=x(i);return s&&i.addEventListener("upgradeneeded",e=>{s(x(i.result),e.oldVersion,e.newVersion,x(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let E=["get","getKey","getAll","getAllKeys","count"],S=["put","add","delete","clear"],q=new Map;function N(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(q.get(t))return q.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=S.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||E.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return q.set(t,n),n}b=(e=>({...e,get:(t,a,s)=>N(t,a)||e.get(t,a,s),has:(t,a)=>!!N(t,a)||e.has(t,a)}))(b);let C=["continue","continuePrimaryKey","advance"],D={},T=new WeakMap,A=new WeakMap,P={get(e,t){if(!C.includes(t))return e[t];let a=D[t];return a||(a=D[t]=function(...e){T.set(this,A.get(this)[t](...e))}),a}};async function*k(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,P);for(A.set(a,t),_.set(a,R(t));t;)yield a,t=await (T.get(a)||t.continue()),T.delete(a)}function U(e,t){return t===Symbol.asyncIterator&&f(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&f(e,[IDBIndex,IDBObjectStore])}b=(e=>({...e,get:(t,a,s)=>U(t,a)?k:e.get(t,a,s),has:(t,a)=>U(t,a)||e.has(t,a)}))(b);let I=async(t,a)=>{let s=null;if(t.url&&(s=new URL(t.url).origin),s!==self.location.origin)throw new i("cross-origin-copy-response",{origin:s});let r=t.clone(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},c=a?a(n):n,o=!function(){if(void 0===e){let t=new Response("");if("body"in t)try{new Response(t.body),e=!0}catch{e=!1}e=!1}return e}()?await r.blob():r.body;return new Response(o,c)},L="requests",F="queueName";class W{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(L,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){let e=await this.getDb(),t=await e.transaction(L).store.openCursor();return t?.value.id}async getAllEntriesByQueueName(e){let t=await this.getDb();return await t.getAllFromIndex(L,F,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(L,F,IDBKeyRange.only(e))}async deleteEntry(e){let t=await this.getDb();await t.delete(L,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){let a=await this.getDb(),s=await a.transaction(L).store.index(F).openCursor(e,t);return s?.value}async getDb(){return this._db||(this._db=await v("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(L)&&e.deleteObjectStore(L),e.createObjectStore(L,{autoIncrement:!0,keyPath:"id"}).createIndex(F,F,{unique:!1})}}class M{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new W}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}}let O=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];class B{_requestData;static async fromRequest(e){let t={url:e.url,headers:{}};for(let a of("GET"!==e.method&&(t.body=await e.clone().arrayBuffer()),e.headers.forEach((e,a)=>{t.headers[a]=e}),O))void 0!==e[a]&&(t[a]=e[a]);return new B(t)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new B(this.toObject())}}let K="serwist-background-sync",j=new Set,H=e=>{let t={request:new B(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};class ${_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(j.has(e))throw new i("duplicate-queue-name",{name:e});j.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new M(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(H(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await B.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):H(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new i("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${K}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${K}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return j}}class G{_queue;constructor(e,t){this._queue=new $(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}}let z={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function V(e){return"string"==typeof e?new Request(e):e}class Q{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(let a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new u,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=V(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new i("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let n=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=V(e),{cacheName:s,matchOptions:r}=this._strategy,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,event:this.event})||void 0;return t}async cachePut(e,t){let a=V(e);await c(0);let s=await this.getCacheKey(a,"write");if(!t)throw new i("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:n,matchOptions:o}=this._strategy,l=await self.caches.open(n),u=this.hasCallback("cacheDidUpdate"),m=u?await h(l,s.clone(),["__WB_REVISION__"],o):null;try{await l.put(s,u?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await d(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,oldResponse:m,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=V(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}}class J{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=n.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new Q(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t),n=this._awaitComplete(r,s,a,t);return[r,n]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new i("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}}class X extends J{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(z),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let c=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!c)throw new i("no-response",{url:e.url});return c}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}}class Y extends J{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=c(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new i("no-response",{url:e.url,error:a});return s}}let Z=e=>e&&"object"==typeof e?e:{handle:e};class ee{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=Z(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=Z(e)}}class et extends J{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await I(e):e};constructor(e={}){e.cacheName=n.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=!1!==e.fallbackToNetwork,this.plugins.push(et.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new i("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new i("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(let[a,s]of this.plugins.entries())s!==et.copyRedirectedCacheableResponsesPlugin&&(s===et.defaultPrecacheCacheabilityPlugin&&(e=a),s.cacheWillUpdate&&t++);0===t?this.plugins.push(et.defaultPrecacheCacheabilityPlugin):t>1&&null!==e&&this.plugins.splice(e,1)}}class ea extends ee{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}}class es extends ee{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}}let er=e=>{if(!e)throw new i("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new i("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};class en{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}}let ei=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).flat().sort((e,t)=>e.indexe.result)};"undefined"!=typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let ec="cache-entries",eo=e=>{let t=new URL(e,location.href);return t.hash="",t.href};class el{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eo(e)}`}_upgradeDb(e){let t=e.createObjectStore(ec,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),x(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eo(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(ec,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){let t=await this.getDb(),a=await t.get(ec,this._getId(e));return a?.timestamp}async expireEntries(e,t){let a=await this.getDb(),s=await a.transaction(ec,"readwrite").store.index("timestamp").openCursor(null,"prev"),r=[],n=0;for(;s;){let a=s.value;a.cacheName===this._cacheName&&(e&&a.timestamp=t?(s.delete(),r.push(a.url)):n++),s=await s.continue()}return r}async getDb(){return this._db||(this._db=await v("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}}class eh{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new el(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||tthis.deleteCacheAndMetadata(),o.add(t))}_getCacheExpiration(e){if(e===n.getRuntimeName())throw new i("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new eh(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}}let ed="www.google-analytics.com",em="www.googletagmanager.com",eg=/^\/(\w+\/)?collect/,ep=({serwist:e,cacheName:t,...a})=>{let s=n.getGoogleAnalyticsName(t),r=new G("serwist-google-analytics",{maxRetentionTime:2880,onSync:(e=>async({queue:t})=>{let a;for(;a=await t.shiftRequest();){let{request:s,timestamp:r}=a,n=new URL(s.url);try{let t="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,a=r-(Number(t.get("qt"))||0),i=Date.now()-a;if(t.set("qt",String(i)),e.parameterOverrides)for(let a of Object.keys(e.parameterOverrides)){let s=e.parameterOverrides[a];t.set(a,s)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,t),await fetch(new Request(n.origin+n.pathname,{body:t.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(a),e}}})(a)});for(let t of[new ee(({url:e})=>e.hostname===em&&"/gtm.js"===e.pathname,new X({cacheName:s}),"GET"),new ee(({url:e})=>e.hostname===ed&&"/analytics.js"===e.pathname,new X({cacheName:s}),"GET"),new ee(({url:e})=>e.hostname===em&&"/gtag/js"===e.pathname,new X({cacheName:s}),"GET"),...(e=>{let t=({url:e})=>e.hostname===ed&&eg.test(e.pathname),a=new Y({plugins:[e]});return[new ee(t,a,"GET"),new ee(t,a,"POST")]})(r)])e.registerRoute(t)};class ef{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}}let ew=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new i("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new i("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new i("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new i("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new i("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),c=r.slice(n.start,n.end),o=c.size,l=new Response(c,{status:206,statusText:"Partial Content",headers:t.headers});return l.headers.set("Content-Length",String(o)),l.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),l}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};class ey{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ew(e,t):t}class e_ extends J{async _handle(e,t){let a,s=await t.cacheMatch(e);if(!s)try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new i("no-response",{url:e.url,error:a});return s}}class eb extends J{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(z)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new i("no-response",{url:e.url,error:a});return r}}class ex extends ee{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t){let a=e.getIntegrityForPrecacheKey(t);return{cacheKey:t,integrity:a}}}},e.precacheStrategy)}}class eR{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}}class ev{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:i,clientsClaim:c=!1,runtimeCaching:o,offlineAnalyticsConfig:l,disableDevLogs:h=!1,fallbacks:u,requestRules:d}={}){var m,p;let{precacheStrategyOptions:f,precacheRouteOptions:w,precacheMiscOptions:y}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:i,fallbackToNetwork:c,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:g,navigateFallbackAllowlist:p,navigateFallbackDenylist:f}=t??{};return{precacheStrategyOptions:{cacheName:n.getPrecacheName(a),plugins:[...s,new eR({precacheController:e})],fetchOptions:r,matchOptions:i,fallbackToNetwork:c},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:g,navigateFallbackAllowlist:p,navigateFallbackDenylist:f}}})(this,t);if(this._concurrentPrecaching=y.concurrency,this._precacheStrategy=new et(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=d,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==i&&(m={prefix:i},n.updateDetails(m)),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),c&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),y.cleanupOutdatedCaches&&(p=f.cacheName,self.addEventListener("activate",e=>{e.waitUntil(g(n.getPrecacheName(p)).then(e=>{}))})),this.registerRoute(new ex(this,w)),y.navigateFallback&&this.registerRoute(new ea(this.createHandlerBoundToUrl(y.navigateFallback),{allowlist:y.navigateFallbackAllowlist,denylist:y.navigateFallbackDenylist})),void 0!==l&&("boolean"==typeof l?l&&ep({serwist:this}):ep({...l,serwist:this})),void 0!==o){if(void 0!==u){let e=new ef({fallbackUrls:u.entries,serwist:this});o.forEach(t=>{t.handler instanceof J&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(let e of o)this.registerCapture(e.matcher,e.handler,e.method)}h&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=er(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new i("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new i("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),p(e,async()=>{let t=new en;this.precacheStrategy.plugins.push(t),await ei(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return p(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,Z(e))}setCatchHandler(e){this._catchHandler=Z(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new ee(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new es(e,t,a);if("function"==typeof e)return new ee(e,t,a);if(e instanceof ee)return e;throw new i("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new i("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new i("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new i("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}}let eE={rscPrefetch:"pages-rsc-prefetch",rsc:"pages-rsc",html:"pages"},eS=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new e_({cacheName:"google-fonts-webfonts",plugins:[new eu({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new e_({cacheName:"next-static-js-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new e_({cacheName:"static-audio-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:mp4|webm)$/i,handler:new e_({cacheName:"static-video-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new eu({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new X({cacheName:"next-data",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new X({cacheName:"static-data-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new Y({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new X({cacheName:"apis",plugins:[new eu({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rscPrefetch,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rsc,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.html,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new X({cacheName:"others",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new X({cacheName:"cross-origin",plugins:[new eu({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new Y}],eq={cacheNames:{static:"tour-builder-static-v1",dynamic:"tour-builder-dynamic-v1",assets:"tour-builder-assets-v1"}},eN=[".png",".jpg",".jpeg",".gif",".webp",".svg",".ico",".mp4",".webm",".mov",".mp3",".wav",".ogg",".m4a",".woff",".woff2",".ttf",".eot",".css",".js"],eC=e=>{let t=new URL(e.url);return[".mp4",".webm",".mov"].some(e=>t.pathname.toLowerCase().endsWith(e))},eD=new ev({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1268-d5a614f357cba985.js'},{'revision':null,'url':'/_next/static/chunks/1d2671aa.f600276eb687cd2d.js'},{'revision':null,'url':'/_next/static/chunks/223-5e7611f0ad4bb7f3.js'},{'revision':null,'url':'/_next/static/chunks/2830-265486b8fa6189cc.js'},{'revision':null,'url':'/_next/static/chunks/2841-5d7e5b4d7e4df194.js'},{'revision':null,'url':'/_next/static/chunks/3643-cd854ecad4d47645.js'},{'revision':null,'url':'/_next/static/chunks/400-ac29014eab06ed0b.js'},{'revision':null,'url':'/_next/static/chunks/4100-f9560c05ca491093.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4542-493bc803d4895f0b.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5541.e90be83844a6de15.js'},{'revision':null,'url':'/_next/static/chunks/6062-779b0d434c8f39f0.js'},{'revision':null,'url':'/_next/static/chunks/6d2b60a9-eb6c7fd9a57c4f19.js'},{'revision':null,'url':'/_next/static/chunks/7574-43336482db7d6cfd.js'},{'revision':null,'url':'/_next/static/chunks/764-1456dc10fb4078c8.js'},{'revision':null,'url':'/_next/static/chunks/7e42aecb-94f8c450c54b9556.js'},{'revision':null,'url':'/_next/static/chunks/8230-251e23b7a24ff307.js'},{'revision':null,'url':'/_next/static/chunks/8232-06043c4b3efac0d3.js'},{'revision':null,'url':'/_next/static/chunks/8317-4e9c090ccc089653.js'},{'revision':null,'url':'/_next/static/chunks/8628-0c7ed56aa881aa6a.js'},{'revision':null,'url':'/_next/static/chunks/8666-852bd0a04615812e.js'},{'revision':null,'url':'/_next/static/chunks/8974-993d7edfa339a7d4.js'},{'revision':null,'url':'/_next/static/chunks/9002-7ddc8224be6392ac.js'},{'revision':null,'url':'/_next/static/chunks/9375.2c053880845a1e8b.js'},{'revision':null,'url':'/_next/static/chunks/9848-799d062feeef8c3c.js'},{'revision':null,'url':'/_next/static/chunks/fa3de7d5.7acfffddc0ff677e.js'},{'revision':null,'url':'/_next/static/chunks/framework-4dea986807dc9d02.js'},{'revision':null,'url':'/_next/static/chunks/main-f35af714f1aa0b25.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-530d8245847656c6.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/%5Baccess_logsId%5D-695b59f392d727c6.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-26736a212b050671.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-list-fee924f65790865e.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-new-525c13f4acde1e42.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-table-958116e35951e9f1.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-view-c015b63aa6e14e4f.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-9fa2997b958d0fac.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-d84befc5642fc5b2.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-list-1c5997f6bd431d37.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-new-d8702cca0d3e82fd.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-table-f66b7a8957da8a84.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-view-97e60f0bcba99d4f.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-7c0498869bfa3306.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-f8a6245e4c51abc7.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-ad3d80982b484229.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-new-48b61699a3088b58.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-table-3084dc0cbfda21da.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-view-8d5fc0a797011964.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-0da964da4c75c71b.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-a05c3f7ce008b3c3.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults-4e30dd1a2f25f89f.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults/%5Bid%5D-60054c8273870130.js'},{'revision':null,'url':'/_next/static/chunks/pages/error-e0bd1c24aeb3d601.js'},{'revision':null,'url':'/_next/static/chunks/pages/forgot-78b70d91742fd5b1.js'},{'revision':null,'url':'/_next/static/chunks/pages/forms-f0585b7dcc01fdb1.js'},{'revision':null,'url':'/_next/static/chunks/pages/index-53587e829512c859.js'},{'revision':null,'url':'/_next/static/chunks/pages/login-bf642a48edd52161.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D-900a5566c9e7dddd.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-74a59f76161ade9d.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-b6e0a85149ef4fb2.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-52d012b54210638e.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-list-b787227aaf0fa18b.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-new-0ba054e730f828fb.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-table-078e773f24033353.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-view-23f6e803f46a3cc7.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-742a2372aca8d2c9.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-97fb7bac95cf4d74.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-list-3762c3782e904205.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-new-82b1ce912005c2b3.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-table-6928e1ad6752157a.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-view-8b384bb120b07864.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-53ea2331c015449b.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-4f68abfea6ef95f7.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults-989846de6e9c0e69.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults/%5Bid%5D-903dad2ec0b2f206.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-f1bd0cc4a331f03b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-d9cf5970d52602d7.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-list-f04f25a8fd0403fd.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-new-fbdb9a0c76bf6d80.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-table-c7570be1039babb3.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-view-b63e46ac54f753d6.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-75ada500b2eaa96e.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-492d0f7a601afd5b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-list-d2093fbbd7ae7742.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-new-f80eeffecd23de0c.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-table-fb9e567445f4b2ac.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-view-3f4bbaf0e0adbd2b.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-9d1a4ac8aff9e86f.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-59c04ec0ef200e41.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-3b855004004943d7.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-70c68debacfb0367.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-cb6786da9f67593b.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-0708dde0a7200d2b.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-a8e6beb0ff3084e0.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-1699d947cfb405e4.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-6804da85ebe7e1ae.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-new-c596490ed1c34c20.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-table-37ee89c6c04b3877.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-view-53a87f5bfd49f924.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-00a93356784ec5e4.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-e1a0e62c87c04597.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-list-c6d67dde85b38e87.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-new-76f3dac394a0f9b4.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-table-f100ac018102680a.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-view-3d983b9bf13b9269.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-749c69b41d9b75a6.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-fb15a0bcee883295.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-list-dfd66b0da8f12e5a.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-new-592ffb19685d02cb.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-table-7bd66515b4a5fff6.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-view-1ea967286287c0b4.js'},{'revision':null,'url':'/_next/static/chunks/pages/search-b5c30de26c40119e.js'},{'revision':null,'url':'/_next/static/chunks/pages/tables-a0711e913de52a07.js'},{'revision':null,'url':'/_next/static/chunks/pages/terms-of-use-7d00698d6183bde4.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/%5Btour_pagesId%5D-f9c22703928d6a30.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-3af5343835095a06.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-7c9d3b26a0e647ff.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-new-86b5e392c47600f0.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-table-4a211ad20d9e18b6.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-view-2c31ac25f8431705.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-bfafbc3f266afe20.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-219527f1f16c5991.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-list-1f55ecf57d225d7a.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-new-8a625cf81b35b49c.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-table-1fc36f16ee8fc0be.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-view-2621169ea055621e.js'},{'revision':null,'url':'/_next/static/chunks/pages/verify-email-3c9a0ef6c6d5daff.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9bfc6282834605db.js'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':null,'url':'/_next/static/css/e4299468b80661f7.css'},{'revision':'5f9f475ed5908644a7c00291ba90c075','url':'/_next/static/lgnUvXfoQ_Wn819lt7oSy/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/lgnUvXfoQ_Wn819lt7oSy/_ssgManifest.js'},{'revision':'43778b43fb039fdd5b0510561dc952ac','url':'/assets/vm-shot-2026-03-17T04-16-09-161Z.jpg'},{'revision':'3395d49fe2b96221471b4db0f5caf9e7','url':'/assets/vm-shot-2026-03-17T04-19-03-565Z.jpg'},{'revision':'62127cd8f85821f3e570a377a8a7e14b','url':'/assets/vm-shot-2026-03-17T04-36-56-252Z.jpg'},{'revision':'145c2d7e4ef298391258c6d8a8aaaece','url':'/assets/vm-shot-2026-03-17T04-45-14-111Z.jpg'},{'revision':'c6aae6521f08c847e370764d1c9c613d','url':'/assets/vm-shot-2026-03-17T04-50-58-546Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg'},{'revision':'73de6cac0695249f4e59ce778a8e6e74','url':'/assets/vm-shot-2026-03-19T06-13-19-234Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-13-54-729Z.jpg'},{'revision':'ca6cbfcc74b52f00eef0f2adc8e65456','url':'/assets/vm-shot-2026-03-24T14-29-20-260Z.jpg'},{'revision':'0957c5365895c5ba31b10a84f4e45929','url':'/data-sources/clients.json'},{'revision':'5703d42f7838705ecf87510c4032b20c','url':'/data-sources/history.json'},{'revision':'5404a85badad8210a634ce41bb511545','url':'/favicon.svg'},{'revision':'508520242399a6b1fec65430901f4e6f','url':'/locales/de/common.json'},{'revision':'0a64739b954a93627749ffcb846fceaa','url':'/locales/en/common.json'},{'revision':'34715e25a3bcbab44f84232ce082d2ee','url':'/locales/es/common.json'},{'revision':'772a72f35589c06bdd8d9179c86459e6','url':'/locales/fr/common.json'},{'revision':'c67326edc61e0b5cf87e509ea7553466','url':'/manifest.json'},{'revision':'1254c9c72aa64724c203d26278712800','url':'/offline.html'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:[{matcher:e=>{let{request:t}=e,a=new URL(t.url);return"image"===t.destination||"font"===t.destination||[".css",".js",".woff",".woff2"].some(e=>a.pathname.endsWith(e))},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{request:t}=e;return eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cachedResponseWillBeUsed:async e=>{let{cachedResponse:t,request:a}=e;if(!t)return null;let s=a.headers.get("range");if(!s)return t;let r=s.match(/bytes=(\d+)-(\d*)/);if(!r)return t;let n=parseInt(r[1],10),i=r[2]?parseInt(r[2],10):void 0,c=await t.blob(),o=void 0!==i?c.slice(n,i+1):c.slice(n);return new Response(o,{status:206,statusText:"Partial Content",headers:{"Content-Type":t.headers.get("Content-Type")||"video/mp4","Content-Length":String(o.size),"Content-Range":"bytes ".concat(n,"-").concat(void 0!==i?i:c.size-1,"/").concat(c.size),"Accept-Ranges":"bytes"}})},cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{url:t}=e;return t.pathname.startsWith("/api/")},handler:new X({cacheName:"api-cache",networkTimeoutSeconds:10})},{matcher:e=>{let{request:t}=e;return(e=>{let t=new URL(e.url);return(!t.pathname.startsWith("/api/")||!!t.pathname.includes("/file/download"))&&!!(eN.some(e=>t.pathname.toLowerCase().endsWith(e))||t.pathname.includes("/file/download")||t.hostname.includes("amazonaws.com")||t.hostname.includes("cloudfront.net"))})(t)&&!eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},...eS]});self.addEventListener("message",e=>{let{type:t,payload:a}=e.data||{};switch(t){case"CACHE_ASSETS":Array.isArray(null==a?void 0:a.urls)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>Promise.all(a.urls.map(t=>fetch(t).then(a=>{if(200===a.status)return e.put(t,a)}).catch(e=>{console.warn("[SW] Failed to cache asset:",t,e)})))));break;case"CACHE_VIDEO_CHUNK":(null==a?void 0:a.url)&&(null==a?void 0:a.chunk)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>{let t=new Response(a.chunk,{headers:{"Content-Type":a.contentType||"video/mp4","Content-Length":String(a.chunk.byteLength)}});return e.put(a.url,t)}));break;case"CLEAR_CACHE":e.waitUntil(Promise.all([caches.delete(eq.cacheNames.dynamic),caches.delete(eq.cacheNames.assets)]).then(()=>{console.log("[SW] Caches cleared")}));break;case"GET_CACHE_STATUS":e.waitUntil(caches.open(eq.cacheNames.assets).then(t=>t.keys().then(t=>{let a=e.source;null==a||a.postMessage({type:"CACHE_STATUS",payload:{cachedCount:t.length,urls:t.map(e=>e.url)}})})));break;case"SKIP_WAITING":self.skipWaiting()}}),eD.addEventListeners(),console.log("[SW] Serwist service worker loaded")})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),p(e,async()=>{let t=new en;this.precacheStrategy.plugins.push(t),await ei(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return p(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,Z(e))}setCatchHandler(e){this._catchHandler=Z(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new ee(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new es(e,t,a);if("function"==typeof e)return new ee(e,t,a);if(e instanceof ee)return e;throw new i("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new i("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new i("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new i("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:n})}catch(e){a=Promise.reject(e)}let l=i?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:n})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}}let eE={rscPrefetch:"pages-rsc-prefetch",rsc:"pages-rsc",html:"pages"},eS=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new e_({cacheName:"google-fonts-webfonts",plugins:[new eu({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new eu({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new e_({cacheName:"next-static-js-assets",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new eu({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new e_({cacheName:"static-audio-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:mp4|webm)$/i,handler:new e_({cacheName:"static-video-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new ey]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new eu({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new X({cacheName:"next-data",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new X({cacheName:"static-data-assets",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new Y({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new X({cacheName:"apis",plugins:[new eu({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rscPrefetch,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.rsc,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new X({cacheName:eE.html,plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new X({cacheName:"others",plugins:[new eu({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new X({cacheName:"cross-origin",plugins:[new eu({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new Y}],eq={cacheNames:{static:"tour-builder-static-v1",dynamic:"tour-builder-dynamic-v1",assets:"tour-builder-assets-v1"}},eN=[".png",".jpg",".jpeg",".gif",".webp",".svg",".ico",".mp4",".webm",".mov",".mp3",".wav",".ogg",".m4a",".woff",".woff2",".ttf",".eot",".css",".js"],eC=e=>{let t=new URL(e.url);return[".mp4",".webm",".mov"].some(e=>t.pathname.toLowerCase().endsWith(e))},eD=new ev({precacheEntries:[{'revision':'4937c7ff3e45d8af530123e56f1a34e6','url':'/_next/static/DQhheA-3MH9PxM-BtCwvb/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/DQhheA-3MH9PxM-BtCwvb/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1232-ee492d53bb7b1303.js'},{'revision':null,'url':'/_next/static/chunks/1268-d5a614f357cba985.js'},{'revision':null,'url':'/_next/static/chunks/1818-bbd302304458b10c.js'},{'revision':null,'url':'/_next/static/chunks/1d2671aa.f600276eb687cd2d.js'},{'revision':null,'url':'/_next/static/chunks/223-5e7611f0ad4bb7f3.js'},{'revision':null,'url':'/_next/static/chunks/2830-a3889121fe1b1fd5.js'},{'revision':null,'url':'/_next/static/chunks/2841-b4837a159982bee1.js'},{'revision':null,'url':'/_next/static/chunks/3643-cd854ecad4d47645.js'},{'revision':null,'url':'/_next/static/chunks/400-ac29014eab06ed0b.js'},{'revision':null,'url':'/_next/static/chunks/4166-356f635e40069ff3.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5541.e90be83844a6de15.js'},{'revision':null,'url':'/_next/static/chunks/6d2b60a9-eb6c7fd9a57c4f19.js'},{'revision':null,'url':'/_next/static/chunks/7574-43336482db7d6cfd.js'},{'revision':null,'url':'/_next/static/chunks/764-1456dc10fb4078c8.js'},{'revision':null,'url':'/_next/static/chunks/7e42aecb-94f8c450c54b9556.js'},{'revision':null,'url':'/_next/static/chunks/8230-251e23b7a24ff307.js'},{'revision':null,'url':'/_next/static/chunks/8232-06043c4b3efac0d3.js'},{'revision':null,'url':'/_next/static/chunks/8317-4e9c090ccc089653.js'},{'revision':null,'url':'/_next/static/chunks/8628-6ecd4d0a7ec3b342.js'},{'revision':null,'url':'/_next/static/chunks/8666-852bd0a04615812e.js'},{'revision':null,'url':'/_next/static/chunks/9375.2c053880845a1e8b.js'},{'revision':null,'url':'/_next/static/chunks/9848-799d062feeef8c3c.js'},{'revision':null,'url':'/_next/static/chunks/fa3de7d5.7acfffddc0ff677e.js'},{'revision':null,'url':'/_next/static/chunks/framework-4dea986807dc9d02.js'},{'revision':null,'url':'/_next/static/chunks/main-f35af714f1aa0b25.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-530d8245847656c6.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/%5Baccess_logsId%5D-688fe121d2a3e151.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-7ec8a23f417ac912.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-list-b7b1e92379006764.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-new-1c0b08a5d9c083df.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-table-97bbef6e9c64cb6a.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-view-e9058d9f7f414484.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-6dfd4d820b40f92f.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-35f0ff408591ee62.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-list-cf3da7bb076aa6f0.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-new-3665f1d0e6e84dd4.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-table-ee30957ff8d738c7.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-view-d0e55c5f9f8ff1b7.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-af254426d65f0864.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-95476f307d3e3e94.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-01bfcf82c04c952d.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-new-4befa34e01efba55.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-table-5c322503011d3c30.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-view-ecd33f0c9c5f2966.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-d81b5350079dd5d2.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-177a4d237aa74b5a.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults-e13fbe5ce27ee189.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults/%5Bid%5D-6f1c78f00f1397a2.js'},{'revision':null,'url':'/_next/static/chunks/pages/error-e0bd1c24aeb3d601.js'},{'revision':null,'url':'/_next/static/chunks/pages/forgot-78b70d91742fd5b1.js'},{'revision':null,'url':'/_next/static/chunks/pages/index-53587e829512c859.js'},{'revision':null,'url':'/_next/static/chunks/pages/login-79ec11c8552cf796.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D-900a5566c9e7dddd.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-f6510d16d2e9f36b.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-b0f11bedbd1544e8.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-173fccb641758e1d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-list-155df5771dec31f4.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-new-215d5a028e89340a.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-table-f664d12fd7bc2c5d.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-view-ceec75a08ed58013.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-70893e3e835a6cc8.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-aea0eed1941705cc.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-list-736e04880898639a.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-new-baaf991bd748200f.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-table-0147792af7b6e2ca.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-view-5e152f00e477a011.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-53ea2331c015449b.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-b56ae759d0e63eff.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults-a79e41b69ca7d7e5.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults/%5Bid%5D-2d4b81f712acdb63.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-78dee88c3e15735a.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-6ea2d13513ddfcb8.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-list-7444bf0ff2adcf19.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-new-ba6d6a3f8515b0d7.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-table-b985f5c250b58ae0.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-view-f4c68e34b1e8903b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-dc1dab1d0d17cd7f.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-9257888337040240.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-list-12773f6237f73890.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-new-2e8763e625895fa4.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-table-cfeb32ab7462cc52.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-view-f8ba1dac35c3b387.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-2b8acadd75b0cc80.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-454008daf9b9622b.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-4890011ca9c3a3df.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-7bca321e9df7086e.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-be4bc62f893cb6e7.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-8275d95d8f621ec7.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-94002b63deed1d14.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-f19a2b0e32f8b264.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-1f25acfd1f8ea84b.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-new-967699a276f0fc1f.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-table-b7cb7261af5db570.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-view-65ffccfa3403aaa8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-0179dd292b427e90.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-9e5afbdc298d0452.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-list-ae78031d0263b1b8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-new-393d3dda89d132c0.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-table-b9a9d80285f6ccf5.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-view-ded2ce3f13d768fd.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-1c566d0bbee23701.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-1065d4b022ea646e.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-list-bb2e8266272a34b3.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-new-f88f533479cddf80.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-table-2ae06818d79da9ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-view-bb8dbddc92922eae.js'},{'revision':null,'url':'/_next/static/chunks/pages/search-f2874b266789f090.js'},{'revision':null,'url':'/_next/static/chunks/pages/terms-of-use-7d00698d6183bde4.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/%5Btour_pagesId%5D-c5dc8a35f6edb1ec.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-fef411d3437c13e0.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-d9b4b468929c770b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-new-59a0c6ab6948843b.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-table-20ae6c4ea35a9ffe.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-view-98001111fa9b7868.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-41de512c34ec1310.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-d213af1348769a80.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-list-2185908a5a705b0e.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-new-5b4a2f9ec6e25d41.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-table-584067057f84845c.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-view-d7131a71727f174d.js'},{'revision':null,'url':'/_next/static/chunks/pages/verify-email-3c9a0ef6c6d5daff.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-81a31cf6f7504908.js'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':null,'url':'/_next/static/css/f4ec745d367e5bc9.css'},{'revision':'43778b43fb039fdd5b0510561dc952ac','url':'/assets/vm-shot-2026-03-17T04-16-09-161Z.jpg'},{'revision':'3395d49fe2b96221471b4db0f5caf9e7','url':'/assets/vm-shot-2026-03-17T04-19-03-565Z.jpg'},{'revision':'62127cd8f85821f3e570a377a8a7e14b','url':'/assets/vm-shot-2026-03-17T04-36-56-252Z.jpg'},{'revision':'145c2d7e4ef298391258c6d8a8aaaece','url':'/assets/vm-shot-2026-03-17T04-45-14-111Z.jpg'},{'revision':'c6aae6521f08c847e370764d1c9c613d','url':'/assets/vm-shot-2026-03-17T04-50-58-546Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg'},{'revision':'73de6cac0695249f4e59ce778a8e6e74','url':'/assets/vm-shot-2026-03-19T06-13-19-234Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-13-54-729Z.jpg'},{'revision':'ca6cbfcc74b52f00eef0f2adc8e65456','url':'/assets/vm-shot-2026-03-24T14-29-20-260Z.jpg'},{'revision':'0957c5365895c5ba31b10a84f4e45929','url':'/data-sources/clients.json'},{'revision':'5703d42f7838705ecf87510c4032b20c','url':'/data-sources/history.json'},{'revision':'5404a85badad8210a634ce41bb511545','url':'/favicon.svg'},{'revision':'508520242399a6b1fec65430901f4e6f','url':'/locales/de/common.json'},{'revision':'0a64739b954a93627749ffcb846fceaa','url':'/locales/en/common.json'},{'revision':'34715e25a3bcbab44f84232ce082d2ee','url':'/locales/es/common.json'},{'revision':'772a72f35589c06bdd8d9179c86459e6','url':'/locales/fr/common.json'},{'revision':'c67326edc61e0b5cf87e509ea7553466','url':'/manifest.json'},{'revision':'1254c9c72aa64724c203d26278712800','url':'/offline.html'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:[{matcher:e=>{let{request:t}=e,a=new URL(t.url);return"image"===t.destination||"font"===t.destination||[".css",".js",".woff",".woff2"].some(e=>a.pathname.endsWith(e))},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{request:t}=e;return eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cachedResponseWillBeUsed:async e=>{let{cachedResponse:t,request:a}=e;if(!t)return null;let s=a.headers.get("range");if(!s)return t;let r=s.match(/bytes=(\d+)-(\d*)/);if(!r)return t;let n=parseInt(r[1],10),i=r[2]?parseInt(r[2],10):void 0,c=await t.blob(),o=void 0!==i?c.slice(n,i+1):c.slice(n);return new Response(o,{status:206,statusText:"Partial Content",headers:{"Content-Type":t.headers.get("Content-Type")||"video/mp4","Content-Length":String(o.size),"Content-Range":"bytes ".concat(n,"-").concat(void 0!==i?i:c.size-1,"/").concat(c.size),"Accept-Ranges":"bytes"}})},cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},{matcher:e=>{let{url:t}=e;return t.pathname.startsWith("/api/")},handler:new X({cacheName:"api-cache",networkTimeoutSeconds:10})},{matcher:e=>{let{request:t}=e;return(e=>{let t=new URL(e.url);return(!t.pathname.startsWith("/api/")||!!t.pathname.includes("/file/download"))&&!!(eN.some(e=>t.pathname.toLowerCase().endsWith(e))||t.pathname.includes("/file/download")||t.hostname.includes("amazonaws.com")||t.hostname.includes("cloudfront.net"))})(t)&&!eC(t)},handler:new e_({cacheName:eq.cacheNames.assets,plugins:[{cacheWillUpdate:async e=>{let{response:t}=e;return t&&200===t.status?t:null}}]})},...eS]});self.addEventListener("message",e=>{let{type:t,payload:a}=e.data||{};switch(t){case"CACHE_ASSETS":Array.isArray(null==a?void 0:a.urls)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>Promise.all(a.urls.map(t=>fetch(t).then(a=>{if(200===a.status)return e.put(t,a)}).catch(e=>{console.warn("[SW] Failed to cache asset:",t,e)})))));break;case"CACHE_VIDEO_CHUNK":(null==a?void 0:a.url)&&(null==a?void 0:a.chunk)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>{let t=new Response(a.chunk,{headers:{"Content-Type":a.contentType||"video/mp4","Content-Length":String(a.chunk.byteLength)}});return e.put(a.url,t)}));break;case"CLEAR_CACHE":e.waitUntil(Promise.all([caches.delete(eq.cacheNames.dynamic),caches.delete(eq.cacheNames.assets)]).then(()=>{console.log("[SW] Caches cleared")}));break;case"GET_CACHE_STATUS":e.waitUntil(caches.open(eq.cacheNames.assets).then(t=>t.keys().then(t=>{let a=e.source;null==a||a.postMessage({type:"CACHE_STATUS",payload:{cachedCount:t.length,urls:t.map(e=>e.url)}})})));break;case"SKIP_WAITING":self.skipWaiting()}}),eD.addEventListeners(),console.log("[SW] Serwist service worker loaded")})(); \ No newline at end of file diff --git a/frontend/src/components/Access_logs/TableAccess_logs.tsx b/frontend/src/components/Access_logs/TableAccess_logs.tsx index 3c48d97..3df6dfd 100644 --- a/frontend/src/components/Access_logs/TableAccess_logs.tsx +++ b/frontend/src/components/Access_logs/TableAccess_logs.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'access_logs', + sliceSelector: (state) => state.access_logs, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableAccess_logs; diff --git a/frontend/src/components/Access_logs/configureAccess_logsCols.tsx b/frontend/src/components/Access_logs/configureAccess_logsCols.tsx index 54f69bc..985be76 100644 --- a/frontend/src/components/Access_logs/configureAccess_logsCols.tsx +++ b/frontend/src/components/Access_logs/configureAccess_logsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'access_logs', + columns: ACCESS_LOGS_COLUMNS, +}); diff --git a/frontend/src/components/Asset_variants/TableAsset_variants.tsx b/frontend/src/components/Asset_variants/TableAsset_variants.tsx index e51af53..eccb8b9 100644 --- a/frontend/src/components/Asset_variants/TableAsset_variants.tsx +++ b/frontend/src/components/Asset_variants/TableAsset_variants.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'asset_variants', + sliceSelector: (state) => state.asset_variants, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableAsset_variants; diff --git a/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx b/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx index 1d8a80e..e2ddf57 100644 --- a/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx +++ b/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'asset_variants', + columns: ASSET_VARIANTS_COLUMNS, +}); diff --git a/frontend/src/components/Assets/TableAssets.tsx b/frontend/src/components/Assets/TableAssets.tsx index 9711905..98d0d28 100644 --- a/frontend/src/components/Assets/TableAssets.tsx +++ b/frontend/src/components/Assets/TableAssets.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'assets', + sliceSelector: (state) => state.assets, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableAssets; diff --git a/frontend/src/components/Assets/configureAssetsCols.tsx b/frontend/src/components/Assets/configureAssetsCols.tsx index f21b21b..ddc127f 100644 --- a/frontend/src/components/Assets/configureAssetsCols.tsx +++ b/frontend/src/components/Assets/configureAssetsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'assets', + columns: ASSETS_COLUMNS, +}); diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx deleted file mode 100644 index 0b26ffe..0000000 --- a/frontend/src/components/BigCalendar.tsx +++ /dev/null @@ -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([]); - 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 ( -
- ( - - ), - }} - /> -
- ); -}; - -const MyCustomEvent = ( - props: { - onDelete: (id: string) => void; - hasUpdatePermission: boolean; - pathEdit?: string; - pathView?: string; - } & EventProps, -) => { - const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = - props; - - return ( -
- - {title} - - -
- ); -}; - -export default BigCalendar; diff --git a/frontend/src/components/ChartLineSample/config.ts b/frontend/src/components/ChartLineSample/config.ts deleted file mode 100644 index c29cbdd..0000000 --- a/frontend/src/components/ChartLineSample/config.ts +++ /dev/null @@ -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), - ], - }; -}; diff --git a/frontend/src/components/ChartLineSample/index.tsx b/frontend/src/components/ChartLineSample/index.tsx deleted file mode 100644 index 0761549..0000000 --- a/frontend/src/components/ChartLineSample/index.tsx +++ /dev/null @@ -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 ; -}; - -export default ChartLineSample; diff --git a/frontend/src/components/DataGrid/configBuilderFactory.tsx b/frontend/src/components/DataGrid/configBuilderFactory.tsx new file mode 100644 index 0000000..6b7f41f --- /dev/null +++ b/frontend/src/components/DataGrid/configBuilderFactory.tsx @@ -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> { + 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>, +): 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) => ( + + ); + } + } + + 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 ? ( + + ) : 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) => [ +
+ +
, + ], + }; +} + +export async function buildColumns( + config: ColumnBuilderConfig, + onDelete: (id: string) => void, + user: unknown, +): Promise { + 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 => { + return buildColumns(config, onDelete, user); + }; +} + +export default buildColumns; diff --git a/frontend/src/components/Factory/createTableComponent.tsx b/frontend/src/components/Factory/createTableComponent.tsx new file mode 100644 index 0000000..8b8f46b --- /dev/null +++ b/frontend/src/components/Factory/createTableComponent.tsx @@ -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 { + [key: string]: T[] | boolean | number | NotificationState | unknown[]; + loading: boolean; + count: number; + refetch: boolean; + notify: NotificationState; +} + +/** + * Configuration for creating a table component + */ +export interface TableComponentConfig { + /** Entity name (e.g., 'roles', 'users') */ + entityName: string; + /** Redux slice selector */ + sliceSelector: (state: RootState) => EntitySliceState; + /** Fetch action thunk */ + fetchAction: AsyncThunk< + T | { rows: T[]; count: number }, + { id?: string; query?: string }, + object + >; + /** Update action thunk */ + updateAction: AsyncThunk }, object>; + /** Delete single item action thunk */ + deleteAction: AsyncThunk; + /** Delete multiple items action thunk */ + deleteByIdsAction: AsyncThunk; + /** Set refetch flag action */ + setRefetchAction: (refetch: boolean) => { type: string; payload: boolean }; + /** Column loader function */ + loadColumnsFunction: ( + onDelete: (id: string) => void, + entityName: string, + user: unknown, + ) => Promise; +} + +/** + * 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({ + * entityName: 'roles', + * sliceSelector: (state) => state.roles, + * fetchAction: fetch, + * updateAction: update, + * deleteAction: deleteItem, + * deleteByIdsAction: deleteItemsByIds, + * setRefetchAction: setRefetch, + * loadColumnsFunction: loadColumns, + * }); + * + * export default TableRoles; + */ +export function createTableComponent( + config: TableComponentConfig, +): React.FC { + const TableComponent: React.FC = ({ + filterItems, + setFilterItems, + filters, + }) => { + return ( + + 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; diff --git a/frontend/src/components/FormImagePicker.tsx b/frontend/src/components/FormImagePicker.tsx index e8a8dac..d3efb77 100644 --- a/frontend/src/components/FormImagePicker.tsx +++ b/frontend/src/components/FormImagePicker.tsx @@ -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'; diff --git a/frontend/src/components/KanbanBoard/KanbanBoard.tsx b/frontend/src/components/KanbanBoard/KanbanBoard.tsx deleted file mode 100644 index 3fdcf7d..0000000 --- a/frontend/src/components/KanbanBoard/KanbanBoard.tsx +++ /dev/null @@ -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; - updateThunk: AsyncThunk; -}; - -const KanbanBoard = ({ - columns, - entityName, - columnFieldName, - filtersQuery, - showFieldName, - deleteThunk, - updateThunk, -}: Props) => { - return ( -
- - {columns.map((column) => ( -
- -
- ))} -
-
- ); -}; - -export default KanbanBoard; diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx deleted file mode 100644 index db1f489..0000000 --- a/frontend/src/components/KanbanBoard/KanbanCard.tsx +++ /dev/null @@ -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(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 ( -
-
- - {item[showFieldName] ?? 'No data'} - -
-
-

{moment(item.createdAt).format('MMM DD hh:mm a')}

- setItemIdToDelete(id)} - hasUpdatePermission={true} - className={'w-2 h-2 text-white'} - iconClassName={'w-5'} - /> -
-
- ); -}; - -export default KanbanCard; diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx deleted file mode 100644 index 28e7b1d..0000000 --- a/frontend/src/components/KanbanBoard/KanbanColumn.tsx +++ /dev/null @@ -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; - updateThunk: AsyncThunk; -}; - -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(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 ( - <> - -
-

{column.label}

-

{count}

-
-
{ - drop(node); - listInnerRef.current = node; - }} - className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'} - onScroll={onScroll} - > - {data?.map((item) => ( -
- -
- ))} - {!data?.length && ( -

- No data -

- )} -
-
- setItemIdToDelete('')} - > -

Are you sure you want to delete this item?

-
- - ); -}; - -export default KanbanColumn; diff --git a/frontend/src/components/Permissions/TablePermissions.tsx b/frontend/src/components/Permissions/TablePermissions.tsx index ff03e41..4f014c4 100644 --- a/frontend/src/components/Permissions/TablePermissions.tsx +++ b/frontend/src/components/Permissions/TablePermissions.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'permissions', + sliceSelector: (state) => state.permissions, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TablePermissions; diff --git a/frontend/src/components/Permissions/configurePermissionsCols.tsx b/frontend/src/components/Permissions/configurePermissionsCols.tsx index 9bdb282..0dad3f2 100644 --- a/frontend/src/components/Permissions/configurePermissionsCols.tsx +++ b/frontend/src/components/Permissions/configurePermissionsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'permissions', + columns: PERMISSIONS_COLUMNS, +}); diff --git a/frontend/src/components/Presigned_url_requests/TablePresigned_url_requests.tsx b/frontend/src/components/Presigned_url_requests/TablePresigned_url_requests.tsx index 18e42cd..611c188 100644 --- a/frontend/src/components/Presigned_url_requests/TablePresigned_url_requests.tsx +++ b/frontend/src/components/Presigned_url_requests/TablePresigned_url_requests.tsx @@ -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 ( - - 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({ + 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; diff --git a/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx b/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx index a0a2733..6d171e1 100644 --- a/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx +++ b/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'presigned_url_requests', + columns: PRESIGNED_URL_REQUESTS_COLUMNS, +}); diff --git a/frontend/src/components/Project_audio_tracks/TableProject_audio_tracks.tsx b/frontend/src/components/Project_audio_tracks/TableProject_audio_tracks.tsx index 160f4f4..60dc4db 100644 --- a/frontend/src/components/Project_audio_tracks/TableProject_audio_tracks.tsx +++ b/frontend/src/components/Project_audio_tracks/TableProject_audio_tracks.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + 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; diff --git a/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx b/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx index bf51465..a3ddf53 100644 --- a/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx +++ b/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'project_audio_tracks', + columns: PROJECT_AUDIO_TRACKS_COLUMNS, +}); diff --git a/frontend/src/components/Project_memberships/TableProject_memberships.tsx b/frontend/src/components/Project_memberships/TableProject_memberships.tsx index 1ea4cd1..3e6a8b6 100644 --- a/frontend/src/components/Project_memberships/TableProject_memberships.tsx +++ b/frontend/src/components/Project_memberships/TableProject_memberships.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'project_memberships', + sliceSelector: (state) => state.project_memberships, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableProject_memberships; diff --git a/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx b/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx index 82ba9db..5dd5e62 100644 --- a/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx +++ b/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'project_memberships', + columns: PROJECT_MEMBERSHIPS_COLUMNS, +}); diff --git a/frontend/src/components/Projects/TableProjects.tsx b/frontend/src/components/Projects/TableProjects.tsx index 83f90dc..e9ab682 100644 --- a/frontend/src/components/Projects/TableProjects.tsx +++ b/frontend/src/components/Projects/TableProjects.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'projects', + sliceSelector: (state) => state.projects, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableProjects; diff --git a/frontend/src/components/Projects/configureProjectsCols.tsx b/frontend/src/components/Projects/configureProjectsCols.tsx index b4e0a34..8f03bb0 100644 --- a/frontend/src/components/Projects/configureProjectsCols.tsx +++ b/frontend/src/components/Projects/configureProjectsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'projects', + columns: PROJECTS_COLUMNS, +}); diff --git a/frontend/src/components/Publish_events/TablePublish_events.tsx b/frontend/src/components/Publish_events/TablePublish_events.tsx index c9f30cc..2125386 100644 --- a/frontend/src/components/Publish_events/TablePublish_events.tsx +++ b/frontend/src/components/Publish_events/TablePublish_events.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'publish_events', + sliceSelector: (state) => state.publish_events, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TablePublish_events; diff --git a/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx b/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx index 22fc2c9..86be7d2 100644 --- a/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx +++ b/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'publish_events', + columns: PUBLISH_EVENTS_COLUMNS, +}); diff --git a/frontend/src/components/Pwa_caches/TablePwa_caches.tsx b/frontend/src/components/Pwa_caches/TablePwa_caches.tsx index c03fc20..3eea1fd 100644 --- a/frontend/src/components/Pwa_caches/TablePwa_caches.tsx +++ b/frontend/src/components/Pwa_caches/TablePwa_caches.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'pwa_caches', + sliceSelector: (state) => state.pwa_caches, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TablePwa_caches; diff --git a/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx b/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx index 2f94a49..5393eb7 100644 --- a/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx +++ b/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'pwa_caches', + columns: PWA_CACHES_COLUMNS, +}); diff --git a/frontend/src/components/Roles/TableRoles.tsx b/frontend/src/components/Roles/TableRoles.tsx index 16fe101..8e40cde 100644 --- a/frontend/src/components/Roles/TableRoles.tsx +++ b/frontend/src/components/Roles/TableRoles.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'roles', + sliceSelector: (state) => state.roles, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableRoles; diff --git a/frontend/src/components/Roles/configureRolesCols.tsx b/frontend/src/components/Roles/configureRolesCols.tsx index fb1fd9a..e5f243e 100644 --- a/frontend/src/components/Roles/configureRolesCols.tsx +++ b/frontend/src/components/Roles/configureRolesCols.tsx @@ -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 => { - 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) => ( - - ), - }, - - { - field: 'actions', - type: 'actions' as const, - minWidth: 30, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => { - return [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'roles', + columns: ROLES_COLUMNS, +}); diff --git a/frontend/src/components/TableSampleClients.tsx b/frontend/src/components/TableSampleClients.tsx deleted file mode 100644 index 5eaf9d1..0000000 --- a/frontend/src/components/TableSampleClients.tsx +++ /dev/null @@ -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 ( - <> - -

- Lorem ipsum dolor sit amet adipiscing elit -

-

This is sample modal

-
- - -

- Lorem ipsum dolor sit amet adipiscing elit -

-

This is sample modal

-
- - - - - - - - - - - - - {clientsPaginated.map((client: Client) => ( - - - - - - - - - - ))} - -
- NameCompanyCityProgressCreated -
- - {client.name}{client.company}{client.city} - - {client.progress} - - - - {client.created} - - - - setIsModalInfoActive(true)} - small - /> - setIsModalTrashActive(true)} - small - /> - -
-
-
- - {pagesList.map((page) => ( - setCurrentPage(page)} - /> - ))} - - - Page {currentPage + 1} of {numPages} - -
-
- - ); -}; - -export default TableSampleClients; diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index 3fba15c..07da268 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -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) => { - 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); diff --git a/frontend/src/components/Tour_pages/TableTour_pages.tsx b/frontend/src/components/Tour_pages/TableTour_pages.tsx index 57e519b..23a8f03 100644 --- a/frontend/src/components/Tour_pages/TableTour_pages.tsx +++ b/frontend/src/components/Tour_pages/TableTour_pages.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'tour_pages', + sliceSelector: (state) => state.tour_pages, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableTour_pages; diff --git a/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx b/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx index c5d9af9..448cb42 100644 --- a/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx +++ b/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx @@ -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 => { - 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 [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'tour_pages', + columns: TOUR_PAGES_COLUMNS, +}); diff --git a/frontend/src/components/Uploaders/FilesUploader.js b/frontend/src/components/Uploaders/FilesUploader.js deleted file mode 100644 index c68ffb9..0000000 --- a/frontend/src/components/Uploaders/FilesUploader.js +++ /dev/null @@ -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 = ( - - ); - - return ( -
- {readonly || (max && fileList().length >= max) ? null : uploadButton} - - {valuesArr() && valuesArr().length ? ( -
- {valuesArr().map((item) => { - return ( -
- - - {item.name} - - - {!readonly && ( - - )} -
- ); - })} -
- ) : null} -
- ); -}; - -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; diff --git a/frontend/src/components/Uploaders/ImagesUploader.js b/frontend/src/components/Uploaders/ImagesUploader.js deleted file mode 100644 index f4e14d0..0000000 --- a/frontend/src/components/Uploaders/ImagesUploader.js +++ /dev/null @@ -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 = ( - - - - ); - - return ( - - {readonly || (max && fileList().length >= max) ? null : uploadButton} - - {valuesArr() && valuesArr().length ? ( - - {valuesArr().map((item) => { - return ( - - {item.name} - -
-
- - {!readonly && ( - - )} -
-
-
- ); - })} -
- ) : null} - - - {imageMeta.imageAlt} - -
- ); -}; - -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; diff --git a/frontend/src/components/Uploaders/UploadService.js b/frontend/src/components/Uploaders/UploadService.js index f0d4da8..1a191ea 100644 --- a/frontend/src/components/Uploaders/UploadService.js +++ b/frontend/src/components/Uploaders/UploadService.js @@ -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}`; } diff --git a/frontend/src/components/Users/TableUsers.tsx b/frontend/src/components/Users/TableUsers.tsx index 08bed1d..843ec57 100644 --- a/frontend/src/components/Users/TableUsers.tsx +++ b/frontend/src/components/Users/TableUsers.tsx @@ -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 = ({ - filterItems, - setFilterItems, - filters, -}) => { - return ( - - 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({ + entityName: 'users', + sliceSelector: (state) => state.users, + fetchAction: fetch, + updateAction: update, + deleteAction: deleteItem, + deleteByIdsAction: deleteItemsByIds, + setRefetchAction: setRefetch, + loadColumnsFunction: loadColumns, +}); export default TableUsers; diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx index 5d334e0..8ecad32 100644 --- a/frontend/src/components/Users/configureUsersCols.tsx +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -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) => ( + + ), + }, + { + 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 => { - 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) => ( - - ), - }, - - { - 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) => ( - - ), - }, - - { - field: 'actions', - type: 'actions' as const, - minWidth: 30, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - getActions: (params: GridRowParams) => { - return [ -
- -
, - ]; - }, - }, - ]; -}; +export const loadColumns = createColumnLoader({ + entityName: 'users', + columns: USERS_COLUMNS, +}); diff --git a/frontend/src/factories/createFormPage.tsx b/frontend/src/factories/createFormPage.tsx index 36cbc31..2eadd41 100644 --- a/frontend/src/factories/createFormPage.tsx +++ b/frontend/src/factories/createFormPage.tsx @@ -32,9 +32,11 @@ export type FormFieldType = | 'textarea' | 'select' | 'selectMany' + | 'enumSelect' | 'switch' | 'image' | 'date' + | 'datetime' | 'password' | 'custom'; @@ -256,6 +258,17 @@ function renderField( /> ); + case 'enumSelect': + return ( + + {options?.map((opt) => ( + + ))} + + ); + case 'selectMany': return ( ( /> ); + case 'datetime': + return ( + + ); + case 'custom': if (CustomComponent) { return ( diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js index dc3f913..843da4a 100644 --- a/frontend/src/helpers/dataFormatter.js +++ b/frontend/src/helpers/dataFormatter.js @@ -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; diff --git a/frontend/src/helpers/textFormatters.ts b/frontend/src/helpers/textFormatters.ts new file mode 100644 index 0000000..2f1cdbd --- /dev/null +++ b/frontend/src/helpers/textFormatters.ts @@ -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(' '); +} diff --git a/frontend/src/helpers/zodAdapter.ts b/frontend/src/helpers/zodAdapter.ts deleted file mode 100644 index b79634e..0000000 --- a/frontend/src/helpers/zodAdapter.ts +++ /dev/null @@ -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'), - * }) - * - * - * ``` - */ -export function toFormikValidate( - schema: T, -): (values: z.infer) => Record { - return (values: z.infer) => { - const result = z.safeParse(schema, values); - - if (result.success) { - return {}; - } - - const errors: Record = {}; - - 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( - schema: T, -): (values: z.infer) => Promise> { - return async (values: z.infer) => { - const result = await z.safeParseAsync(schema, values); - - if (result.success) { - return {}; - } - - const errors: Record = {}; - - 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; diff --git a/frontend/src/hooks/sampleData.ts b/frontend/src/hooks/sampleData.ts deleted file mode 100644 index 8c74ad5..0000000 --- a/frontend/src/hooks/sampleData.ts +++ /dev/null @@ -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, - }; -}; diff --git a/frontend/src/hooks/useDashboardCounts.ts b/frontend/src/hooks/useDashboardCounts.ts new file mode 100644 index 0000000..bd35d8c --- /dev/null +++ b/frontend/src/hooks/useDashboardCounts.ts @@ -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; + loading: boolean; + error: Error | null; +} + +export interface UseDashboardCountsReturn extends DashboardCountsState { + refetch: () => Promise; + 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({ + 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 => { + const initial: Record = {}; + 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 = {}; + + 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; diff --git a/frontend/src/hooks/useEditPageSync.ts b/frontend/src/hooks/useEditPageSync.ts new file mode 100644 index 0000000..6515716 --- /dev/null +++ b/frontend/src/hooks/useEditPageSync.ts @@ -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> { + /** 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; + /** 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 { + /** Current form values */ + values: T; + /** Set form values directly */ + setValues: React.Dispatch>; + /** 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 ( + * + * ... + * + * ); + * }; + */ +export function useEditPageSync>( + options: UseEditPageSyncOptions, +): UseEditPageSyncReturn { + 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(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)[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>( + options: UseEditPageSyncOptions, +): [T, React.Dispatch>] { + const { values, setValues } = useEditPageSync(options); + return [values, setValues]; +} + +export default useEditPageSync; diff --git a/frontend/src/lib/slugHelpers.ts b/frontend/src/lib/slugHelpers.ts new file mode 100644 index 0000000..bbba202 --- /dev/null +++ b/frontend/src/lib/slugHelpers.ts @@ -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 { + 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); +} diff --git a/frontend/src/lib/tourFlowHelpers.ts b/frontend/src/lib/tourFlowHelpers.ts new file mode 100644 index 0000000..7a8b031 --- /dev/null +++ b/frontend/src/lib/tourFlowHelpers.ts @@ -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( + response: { data?: { rows?: T[] } } | null | undefined, +): T[] { + return Array.isArray(response?.data?.rows) ? response.data.rows : []; +} diff --git a/frontend/src/pages/access_logs/access_logs-edit.tsx b/frontend/src/pages/access_logs/access_logs-edit.tsx index c5e4deb..d32c292 100644 --- a/frontend/src/pages/access_logs/access_logs-edit.tsx +++ b/frontend/src/pages/access_logs/access_logs-edit.tsx @@ -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 }), - ); - await router.push('/access_logs/access_logs-list'); + if (id) { + await dispatch( + update({ id, data: data as unknown as Partial }), + ); + 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(), }) diff --git a/frontend/src/pages/asset_variants/asset_variants-edit.tsx b/frontend/src/pages/asset_variants/asset_variants-edit.tsx index 9b159f0..c610f07 100644 --- a/frontend/src/pages/asset_variants/asset_variants-edit.tsx +++ b/frontend/src/pages/asset_variants/asset_variants-edit.tsx @@ -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)[key] = ( - assetVariant as unknown as Record - )[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 }), + update({ id, data: data as unknown as Partial }), ); await router.push('/asset_variants/asset_variants-list'); } diff --git a/frontend/src/pages/assets/assets-edit.tsx b/frontend/src/pages/assets/assets-edit.tsx index 1bf840a..b217678 100644 --- a/frontend/src/pages/assets/assets-edit.tsx +++ b/frontend/src/pages/assets/assets-edit.tsx @@ -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)[el] = ( - asset as unknown as Record - )[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 }), - ); + if (id) { + await dispatch(update({ id, data: data as unknown as Partial })); await router.push('/assets/assets-list'); } }; @@ -213,7 +192,7 @@ const EditAssetsPage = () => { : null } onChange={(date: Date | null) => - setInitialValues({ + setValues({ ...initialValues, deleted_at_time: date, }) diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index b5b037b..11681b4 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -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)[iconKey] ?? + (icon as Record)['mdiFormatListBulleted']; + + return ( + +
+
+
+
+ {label} +
+
{count}
+
+
+ +
+
+
+ + ); +}; + 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 ( <> @@ -147,7 +139,7 @@ const Dashboard = () => { )} {!!rolesWidgets.length && hasPermission(currentUser, 'CREATE_ROLES') && ( -

+

{`${widgetsRole?.role?.label || 'Users'}'s widgets`}

)} @@ -155,7 +147,7 @@ const Dashboard = () => {
{(isFetchingQuery || loading) && (
{ ))}
- {!!rolesWidgets.length &&
} + {!!rolesWidgets.length &&
}
- {hasPermission(currentUser, 'READ_USERS') && ( - -
-
-
-
- Users -
-
- {users} -
-
-
- )['mdiAccountGroup'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_ROLES') && ( - -
-
-
-
- Roles -
-
- {roles} -
-
-
- )[ - 'mdiShieldAccountVariantOutline' - ] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PERMISSIONS') && ( - -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- )[ - 'mdiShieldAccountOutline' - ] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PROJECTS') && ( - -
-
-
-
- Projects -
-
- {projects} -
-
-
- )['mdiOfficeBuilding'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PROJECT_MEMBERSHIPS') && ( - -
-
-
-
- Project memberships -
-
- {project_memberships} -
-
-
- )['mdiAccountKey'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_ASSETS') && ( - -
-
-
-
- Assets -
-
- {assets} -
-
-
- )[ - 'mdiFolderMultipleImage' - ] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_ASSET_VARIANTS') && ( - -
-
-
-
- Asset variants -
-
- {asset_variants} -
-
-
- )['mdiImageMultiple'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PRESIGNED_URL_REQUESTS') && ( - -
-
-
-
- Presigned url requests -
-
- {presigned_url_requests} -
-
-
- )['mdiLinkLock'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_TOUR_PAGES') && ( - -
-
-
-
- Tour pages -
-
- {tour_pages} -
-
-
- )[ - 'mdiFileDocumentMultiple' - ] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PROJECT_AUDIO_TRACKS') && ( - -
-
-
-
- Project audio tracks -
-
- {project_audio_tracks} -
-
-
- )['mdiMusicNote'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PUBLISH_EVENTS') && ( - -
-
-
-
- Publish events -
-
- {publish_events} -
-
-
- )['mdiPublish'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_PWA_CACHES') && ( - -
-
-
-
- Pwa caches -
-
- {pwa_caches} -
-
-
- )['mdiCellphoneLink'] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} - - {hasPermission(currentUser, 'READ_ACCESS_LOGS') && ( - -
-
-
-
- Access logs -
-
- {access_logs} -
-
-
- )[ - 'mdiClipboardTextOutline' - ] ?? - (icon as Record)[ - 'mdiFormatListBulleted' - ] - } - /> -
-
-
- - )} + {visibleEntities.map((entity) => ( + + ))}
diff --git a/frontend/src/pages/forms.tsx b/frontend/src/pages/forms.tsx deleted file mode 100644 index 45de29b..0000000 --- a/frontend/src/pages/forms.tsx +++ /dev/null @@ -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 ( - <> - - {getPageTitle('Forms')} - - - - - {''} - - - - alert(JSON.stringify(values, null, 2))} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - Custom elements - - - - null} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - ); -}; - -FormsPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default FormsPage; diff --git a/frontend/src/pages/permissions/permissions-edit.tsx b/frontend/src/pages/permissions/permissions-edit.tsx index 86f1311..34e4878 100644 --- a/frontend/src/pages/permissions/permissions-edit.tsx +++ b/frontend/src/pages/permissions/permissions-edit.tsx @@ -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 ( diff --git a/frontend/src/pages/presigned_url_requests/presigned_url_requests-edit.tsx b/frontend/src/pages/presigned_url_requests/presigned_url_requests-edit.tsx index c01c958..8d0a752 100644 --- a/frontend/src/pages/presigned_url_requests/presigned_url_requests-edit.tsx +++ b/frontend/src/pages/presigned_url_requests/presigned_url_requests-edit.tsx @@ -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)[key] = ( - presignedRequest as unknown as Record - )[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(), }) diff --git a/frontend/src/pages/project_audio_tracks/project_audio_tracks-edit.tsx b/frontend/src/pages/project_audio_tracks/project_audio_tracks-edit.tsx index f79359d..816cf16 100644 --- a/frontend/src/pages/project_audio_tracks/project_audio_tracks-edit.tsx +++ b/frontend/src/pages/project_audio_tracks/project_audio_tracks-edit.tsx @@ -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)[key] = ( - item as unknown as Record - )[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) { diff --git a/frontend/src/pages/project_memberships/project_memberships-edit.tsx b/frontend/src/pages/project_memberships/project_memberships-edit.tsx index 2dab146..42c6f72 100644 --- a/frontend/src/pages/project_memberships/project_memberships-edit.tsx +++ b/frontend/src/pages/project_memberships/project_memberships-edit.tsx @@ -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)[key] = ( - item as unknown as Record - )[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, }) diff --git a/frontend/src/pages/publish_events/publish_events-edit.tsx b/frontend/src/pages/publish_events/publish_events-edit.tsx index d24f9db..22265d8 100644 --- a/frontend/src/pages/publish_events/publish_events-edit.tsx +++ b/frontend/src/pages/publish_events/publish_events-edit.tsx @@ -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)[key] = ( - item as unknown as Record - )[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, }) diff --git a/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx b/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx index 30d118c..e858d7d 100644 --- a/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx +++ b/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx @@ -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(), }) diff --git a/frontend/src/pages/roles/roles-edit.tsx b/frontend/src/pages/roles/roles-edit.tsx index be0039e..38f4c40 100644 --- a/frontend/src/pages/roles/roles-edit.tsx +++ b/frontend/src/pages/roles/roles-edit.tsx @@ -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 ( diff --git a/frontend/src/pages/tables.tsx b/frontend/src/pages/tables.tsx deleted file mode 100644 index 626dc0e..0000000 --- a/frontend/src/pages/tables.tsx +++ /dev/null @@ -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 ( - <> - - {getPageTitle('Tables')} - - - - {''} - - - - - - - ); -}; - -TablesPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default TablesPage; diff --git a/frontend/src/pages/tour_pages/tour_pages-edit.tsx b/frontend/src/pages/tour_pages/tour_pages-edit.tsx index e154b32..ee2d5a6 100644 --- a/frontend/src/pages/tour_pages/tour_pages-edit.tsx +++ b/frontend/src/pages/tour_pages/tour_pages-edit.tsx @@ -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)[key] = ( - tourPage as unknown as Record - )[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 }), + update({ id, data: data as unknown as Partial }), ); await router.push('/tour_pages/tour_pages-list'); } diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index 37290ca..fb9bdb1 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -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)[el] = ( - users as Record - )[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 ( diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index eba4c61..2231dd3 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -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; + 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) => 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) => void; + remove: (cardId: string) => void; + }; + carouselSlides: { + add: () => void; + update: (slideId: string, patch: Partial) => void; + remove: (slideId: string) => void; + }; +} + +/** + * Media utilities props + */ +export interface EditorMediaUtilsProps { + getDuration: (url: string) => number | undefined; +}