From 4bf9339a7f0c3a506ea3679f708d2e2b2a2ce603 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sun, 5 Apr 2026 18:46:16 +0400 Subject: [PATCH] offline mode improved --- backend/src/middlewares/check-permissions.js | 2 - backend/src/middlewares/rateLimiter.js | 6 +- backend/src/routes/projects.js | 528 +----------- backend/src/routes/publish.js | 1 - backend/src/routes/search.js | 21 +- backend/src/routes/sql.js | 39 +- backend/src/routes/users.js | 458 +---------- backend/src/services/projects.js | 184 ++--- backend/src/services/pwa_manifest.js | 48 +- backend/src/services/roles.js | 35 +- backend/src/services/users.js | 158 +--- backend/src/utils/sqlValidator.js | 53 ++ frontend/package.json | 2 +- frontend/public/sw.js | 2 +- frontend/src/colors.ts | 2 +- .../Access_logs/CardAccess_logs.tsx | 3 +- .../Access_logs/ListAccess_logs.tsx | 3 +- frontend/src/components/AsideMenu.tsx | 2 +- frontend/src/components/AsideMenuItem.tsx | 2 +- frontend/src/components/AsideMenuLayer.tsx | 2 +- frontend/src/components/AsideMenuList.tsx | 2 +- .../Asset_variants/CardAsset_variants.tsx | 3 +- .../Asset_variants/ListAsset_variants.tsx | 3 +- frontend/src/components/Assets/CardAssets.tsx | 3 +- frontend/src/components/Assets/ListAssets.tsx | 3 +- frontend/src/components/BaseButton.tsx | 2 +- frontend/src/components/CardBoxModal.tsx | 2 +- .../Constructor/BackgroundSettingsEditor.tsx | 10 +- .../Constructor/ConstructorMenu.tsx | 64 +- .../Constructor/ElementEditorPanel.tsx | 737 ++++++++--------- frontend/src/components/Constructor/types.ts | 38 +- .../Factory/createTableComponent.tsx | 13 +- frontend/src/components/FormFilePicker.tsx | 8 +- frontend/src/components/FormImagePicker.tsx | 8 +- .../src/components/Generic/GenericTable.tsx | 15 +- frontend/src/components/IconRounded.tsx | 2 +- frontend/src/components/NavBar.tsx | 2 +- frontend/src/components/NavBarItem.tsx | 2 +- frontend/src/components/NavBarMenuList.tsx | 2 +- frontend/src/components/NotificationBar.tsx | 2 +- .../src/components/Offline/OfflineToggle.tsx | 27 +- frontend/src/components/Pagination.tsx | 2 +- .../Permissions/CardPermissions.tsx | 3 +- .../Permissions/ListPermissions.tsx | 3 +- .../CardPresigned_url_requests.tsx | 3 +- .../ListPresigned_url_requests.tsx | 3 +- .../CardProject_audio_tracks.tsx | 3 +- .../ListProject_audio_tracks.tsx | 3 +- .../CardProject_memberships.tsx | 3 +- .../ListProject_memberships.tsx | 3 +- .../src/components/Projects/CardProjects.tsx | 3 +- .../src/components/Projects/ListProjects.tsx | 3 +- .../Publish_events/CardPublish_events.tsx | 3 +- .../Publish_events/ListPublish_events.tsx | 3 +- .../components/Pwa_caches/CardPwa_caches.tsx | 3 +- .../components/Pwa_caches/ListPwa_caches.tsx | 3 +- frontend/src/components/Roles/CardRoles.tsx | 3 +- frontend/src/components/Roles/ListRoles.tsx | 3 +- frontend/src/components/RuntimeElement.tsx | 3 +- .../src/components/RuntimePresentation.tsx | 25 +- frontend/src/components/SectionFullScreen.tsx | 2 +- frontend/src/components/SelectField.tsx | 2 +- frontend/src/components/SelectFieldMany.tsx | 4 +- .../components/AreaChart/ApexAreaChart.tsx | 5 +- .../components/AreaChart/ChartJSAreaChart.tsx | 5 +- .../components/BarChart/ApexBarChart.tsx | 5 +- .../components/BarChart/ChartJSBarChart.tsx | 7 +- .../SmartWidget/components/FunnelChart.tsx | 5 +- .../components/LineChart/ApexLineChart.tsx | 5 +- .../components/LineChart/ChartJSLineChart.tsx | 10 +- .../components/PieChart/ApexPieChart.tsx | 12 +- .../components/PieChart/ChartJSPieChart.tsx | 5 +- .../SmartWidget/models/widget.model.ts | 6 +- .../components/SmartWidget/widgetHelpers.tsx | 11 +- frontend/src/components/SwitchField.tsx | 4 +- frontend/src/components/TourFlowManager.tsx | 40 +- .../components/Tour_pages/CardTour_pages.tsx | 3 +- .../components/Tour_pages/ListTour_pages.tsx | 3 +- .../elements/AudioPlayerElement.tsx | 1 + .../src/components/UserAvatarCurrentUser.tsx | 46 +- frontend/src/components/UserCard.tsx | 15 +- frontend/src/components/Users/CardUsers.tsx | 3 +- frontend/src/components/Users/ListUsers.tsx | 3 +- .../components/WidgetCreator/RoleSelect.tsx | 2 +- .../WidgetCreator/WidgetCreator.tsx | 8 +- frontend/src/config/offline.config.ts | 1 + frontend/src/config/preload.config.ts | 12 +- frontend/src/context/ConstructorContext.tsx | 422 ++++++++++ frontend/src/hooks/index.ts | 15 + frontend/src/hooks/queries/index.ts | 19 + .../src/hooks/queries/useAccessLogsQuery.ts | 49 ++ .../hooks/queries/useAssetVariantsQuery.ts | 43 + frontend/src/hooks/queries/useAssetsQuery.ts | 109 +++ .../hooks/queries/useElementDefaultsQuery.ts | 52 ++ frontend/src/hooks/queries/usePagesQuery.ts | 118 +++ .../src/hooks/queries/usePermissionsQuery.ts | 35 + .../queries/useProjectAudioTracksQuery.ts | 102 +++ .../queries/useProjectMembershipsQuery.ts | 89 ++ frontend/src/hooks/queries/useProjectQuery.ts | 87 ++ .../hooks/queries/usePublishEventsQuery.ts | 56 ++ .../src/hooks/queries/usePwaCachesQuery.ts | 67 ++ frontend/src/hooks/queries/useRolesQuery.ts | 112 +++ frontend/src/hooks/queries/useUsersQuery.ts | 126 +++ frontend/src/hooks/useAssetOptions.ts | 123 +++ frontend/src/hooks/useConstructorData.ts | 139 ++++ .../src/hooks/useConstructorPageActions.ts | 77 +- frontend/src/hooks/useEditPageSync.ts | 4 +- frontend/src/hooks/useEntityTable.ts | 10 +- frontend/src/hooks/useFormSync.ts | 2 +- frontend/src/hooks/useMediaDurationProbe.ts | 32 +- frontend/src/hooks/useOfflineMode.ts | 229 ++++-- frontend/src/hooks/usePageBackground.ts | 178 ++++ frontend/src/hooks/usePreloadOrchestrator.ts | 759 +++++++----------- frontend/src/hooks/useTransitionCreation.ts | 134 ++++ frontend/src/hooks/useTransitionPreview.ts | 21 +- frontend/src/i18n.ts | 3 + frontend/src/interfaces/index.ts | 122 --- frontend/src/lib/assetUrl.ts | 52 +- frontend/src/lib/constructorHelpers.ts | 6 - frontend/src/lib/imagePreDecode.ts | 26 +- frontend/src/lib/offline/DownloadEventBus.ts | 9 + frontend/src/lib/offline/DownloadManager.ts | 151 +++- frontend/src/lib/offline/StorageManager.ts | 35 +- frontend/src/lib/queryClient.ts | 155 ++++ frontend/src/menuAside.ts | 2 +- frontend/src/menuNavBar.ts | 2 +- frontend/src/pages/_app.tsx | 88 +- .../src/pages/access_logs/[access_logsId].tsx | 15 +- .../pages/access_logs/access_logs-edit.tsx | 2 +- .../pages/access_logs/access_logs-view.tsx | 9 +- .../asset_variants/[asset_variantsId].tsx | 9 +- .../asset_variants/asset_variants-edit.tsx | 2 +- .../asset_variants/asset_variants-view.tsx | 9 +- frontend/src/pages/assets/[assetsId].tsx | 4 +- frontend/src/pages/assets/assets-edit.tsx | 2 +- frontend/src/pages/assets/assets-list.tsx | 2 +- frontend/src/pages/assets/assets-view.tsx | 7 +- frontend/src/pages/constructor.tsx | 712 +++++++--------- frontend/src/pages/element-type-defaults.tsx | 7 +- .../src/pages/permissions/[permissionsId].tsx | 7 +- .../pages/permissions/permissions-edit.tsx | 2 +- .../pages/permissions/permissions-view.tsx | 7 +- .../[presigned_url_requestsId].tsx | 9 +- .../presigned_url_requests-edit.tsx | 3 +- .../presigned_url_requests-view.tsx | 9 +- frontend/src/pages/profile.tsx | 2 +- .../src/pages/project-element-defaults.tsx | 11 +- .../[project_audio_tracksId].tsx | 10 +- .../project_audio_tracks-edit.tsx | 2 +- .../project_audio_tracks-view.tsx | 10 +- .../[project_membershipsId].tsx | 9 +- .../project_memberships-edit.tsx | 2 +- .../project_memberships-view.tsx | 9 +- frontend/src/pages/projects/[projectsId].tsx | 14 +- frontend/src/pages/projects/projects-edit.tsx | 4 +- frontend/src/pages/projects/projects-list.tsx | 2 +- frontend/src/pages/projects/projects-view.tsx | 7 +- .../publish_events/[publish_eventsId].tsx | 9 +- .../publish_events/publish_events-edit.tsx | 2 +- .../publish_events/publish_events-list.tsx | 9 +- .../publish_events/publish_events-view.tsx | 9 +- .../src/pages/pwa_caches/[pwa_cachesId].tsx | 7 +- .../src/pages/pwa_caches/pwa_caches-edit.tsx | 8 +- .../src/pages/pwa_caches/pwa_caches-new.tsx | 6 +- .../src/pages/pwa_caches/pwa_caches-view.tsx | 7 +- frontend/src/pages/roles/[rolesId].tsx | 4 +- frontend/src/pages/roles/roles-edit.tsx | 2 +- frontend/src/pages/roles/roles-view.tsx | 7 +- .../src/pages/tour_pages/[tour_pagesId].tsx | 7 +- .../src/pages/tour_pages/tour_pages-edit.tsx | 2 +- .../src/pages/tour_pages/tour_pages-view.tsx | 9 +- frontend/src/pages/users/[usersId].tsx | 4 +- frontend/src/pages/users/users-edit.tsx | 2 +- frontend/src/pages/users/users-view.tsx | 7 +- frontend/src/stores/authSlice.ts | 13 +- .../stores/constructor/constructorSlice.ts | 111 +++ frontend/src/stores/createEntitySlice.ts | 26 +- frontend/src/stores/introSteps.ts | 23 +- frontend/src/stores/mainSlice.ts | 26 +- frontend/src/stores/openAiSlice.ts | 55 +- frontend/src/stores/roles/rolesSlice.ts | 37 +- frontend/src/stores/selectors.ts | 181 +++++ frontend/src/stores/store.ts | 2 + frontend/src/stores/styleSlice.ts | 2 +- frontend/src/stores/usersSlice.ts | 124 --- frontend/src/types/charts.ts | 62 ++ frontend/src/types/components.ts | 37 + frontend/src/types/constructor.ts | 116 +++ frontend/src/types/entities.ts | 25 + frontend/src/types/index.ts | 6 + frontend/src/types/menu.ts | 38 + frontend/src/types/offline.ts | 6 + frontend/src/types/openai.ts | 73 ++ frontend/src/types/presentation.ts | 26 +- frontend/src/types/redux.ts | 15 +- frontend/src/types/ui.ts | 62 ++ frontend/yarn.lock | 451 +++-------- 197 files changed, 5208 insertions(+), 3971 deletions(-) create mode 100644 backend/src/utils/sqlValidator.js create mode 100644 frontend/src/context/ConstructorContext.tsx create mode 100644 frontend/src/hooks/queries/index.ts create mode 100644 frontend/src/hooks/queries/useAccessLogsQuery.ts create mode 100644 frontend/src/hooks/queries/useAssetVariantsQuery.ts create mode 100644 frontend/src/hooks/queries/useAssetsQuery.ts create mode 100644 frontend/src/hooks/queries/useElementDefaultsQuery.ts create mode 100644 frontend/src/hooks/queries/usePagesQuery.ts create mode 100644 frontend/src/hooks/queries/usePermissionsQuery.ts create mode 100644 frontend/src/hooks/queries/useProjectAudioTracksQuery.ts create mode 100644 frontend/src/hooks/queries/useProjectMembershipsQuery.ts create mode 100644 frontend/src/hooks/queries/useProjectQuery.ts create mode 100644 frontend/src/hooks/queries/usePublishEventsQuery.ts create mode 100644 frontend/src/hooks/queries/usePwaCachesQuery.ts create mode 100644 frontend/src/hooks/queries/useRolesQuery.ts create mode 100644 frontend/src/hooks/queries/useUsersQuery.ts create mode 100644 frontend/src/hooks/useAssetOptions.ts create mode 100644 frontend/src/hooks/useConstructorData.ts create mode 100644 frontend/src/hooks/usePageBackground.ts create mode 100644 frontend/src/hooks/useTransitionCreation.ts delete mode 100644 frontend/src/interfaces/index.ts create mode 100644 frontend/src/lib/queryClient.ts create mode 100644 frontend/src/stores/constructor/constructorSlice.ts create mode 100644 frontend/src/stores/selectors.ts delete mode 100644 frontend/src/stores/usersSlice.ts create mode 100644 frontend/src/types/charts.ts create mode 100644 frontend/src/types/components.ts create mode 100644 frontend/src/types/menu.ts create mode 100644 frontend/src/types/openai.ts create mode 100644 frontend/src/types/ui.ts diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index b7e0a6b..ee3e71c 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -36,8 +36,6 @@ fetchAndCachePublicRole().catch((error) => { 'Critical error during permissions middleware initialization:', error, ); - // Decide here if the process should exit if the Public role is essential. - // process.exit(1); }); /** diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js index c6d185f..821a999 100644 --- a/backend/src/middlewares/rateLimiter.js +++ b/backend/src/middlewares/rateLimiter.js @@ -222,13 +222,13 @@ const uploadLimiter = createRateLimiter({ }); /** - * Download limiter - More permissive limits for file downloads - * 200 requests per minute per IP (supports asset preloading) + * Download limiter - Permissive limits for file downloads + * 1000 requests per minute per IP (supports offline mode downloading full presentations) */ const downloadLimiter = createRateLimiter({ keyPrefix: 'download', windowMs: 60 * 1000, // 1 minute - max: 200, + max: 1000, message: 'Too many download requests. Please slow down.', skipFailedRequests: true, // Don't penalize for errors }); diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 2695d02..d09a217 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -1,106 +1,23 @@ -const express = require('express'); - +const { createEntityRouter, isUuidV4 } = require('../factories/router.factory'); const ProjectsService = require('../services/projects'); const ProjectsDBApi = require('../db/api/projects'); -const { wrapAsync, isUuidV4 } = require('../helpers'); +const { wrapAsync } = require('../helpers'); -const router = express.Router(); - -const { parse } = require('json2csv'); - -const { checkCrudPermissions } = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('projects')); - -/** - * @swagger - * components: - * schemas: - * Projects: - * type: object - * properties: - - * name: - * type: string - * default: name - * slug: - * type: string - * default: slug - * description: - * type: string - * default: description - * logo_url: - * type: string - * default: logo_url - * favicon_url: - * type: string - * default: favicon_url - * og_image_url: - * type: string - * default: og_image_url - - - - * - */ - -/** - * @swagger - * tags: - * name: Projects - * description: The Projects managing API - */ - -/** - * @swagger - * /api/projects: - * post: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Add new item - * description: Add new item - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Projects" - * responses: - * 200: - * description: The item was successfully added - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - */ -router.post( - '/', - wrapAsync(async (req, res) => { - const referer = - req.headers.referer || - `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - const payload = await ProjectsService.create( - req.body.data, - req.currentUser, - true, - link.host, - ); - res.status(200).send(payload); - }), -); +// Create base router with factory (includes all standard CRUD endpoints) +const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, { + permissionEntity: 'projects', + csvFields: [ + 'id', + 'name', + 'slug', + 'description', + 'logo_url', + 'favicon_url', + 'og_image_url', + ], +}); +// Custom endpoint: Clone project router.post( '/:id/clone', wrapAsync(async (req, res) => { @@ -116,410 +33,7 @@ router.post( }), ); -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Projects" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post( - '/bulk-import', - wrapAsync(async (req, res) => { - const referer = - req.headers.referer || - `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await ProjectsService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Projects" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put( - '/:id', - wrapAsync(async (req, res) => { - await ProjectsService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete( - '/:id', - wrapAsync(async (req, res) => { - await ProjectsService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post( - '/deleteByIds', - wrapAsync(async (req, res) => { - await ProjectsService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects: - * get: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Get all projects - * description: Get all projects - * responses: - * 200: - * description: Projects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get( - '/', - wrapAsync(async (req, res) => { - const filetype = req.query.filetype; - - const currentUser = req.currentUser; - const runtimeContext = req.runtimeContext; - const payload = await ProjectsDBApi.findAll(req.query, { - currentUser, - runtimeContext, - }); - if (filetype && filetype === 'csv') { - const fields = [ - 'id', - 'name', - 'slug', - 'description', - 'logo_url', - 'favicon_url', - 'og_image_url', - ]; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv); - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - }), -); - -/** - * @swagger - * /api/projects/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Count all projects - * description: Count all projects - * responses: - * 200: - * description: Projects count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get( - '/count', - wrapAsync(async (req, res) => { - const currentUser = req.currentUser; - const runtimeContext = req.runtimeContext; - const payload = await ProjectsDBApi.findAll(req.query, { - countOnly: true, - currentUser, - runtimeContext, - }); - - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Find all projects that match search criteria - * description: Find all projects that match search criteria - * responses: - * 200: - * description: Projects list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Projects" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - const payload = await ProjectsDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - ); - - res.status(200).send(payload); -}); - -/** - * @swagger - * /api/projects/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Projects" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get( - '/:id', - wrapAsync(async (req, res) => { - if (!isUuidV4(req.params.id)) { - return res.status(400).send('Invalid project id'); - } - - const runtimeContext = req.runtimeContext; - const payload = await ProjectsDBApi.findBy( - { id: req.params.id }, - { runtimeContext }, - ); - - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/projects/{id}/offline-manifest: - * get: - * security: - * - bearerAuth: [] - * tags: [Projects] - * summary: Get offline manifest for PWA download - * description: Returns a manifest of all assets needed to use the project offline - * parameters: - * - in: path - * name: id - * description: Project ID - * required: true - * schema: - * type: string - * - in: query - * name: variant - * description: Device type for variant selection (mobile or desktop) - * schema: - * type: string - * enum: [mobile, desktop] - * default: desktop - * responses: - * 200: - * description: Offline manifest successfully generated - * 400: - * description: Invalid project ID - * 404: - * description: Project not found - * 500: - * description: Server error - */ +// Custom endpoint: Get offline manifest for PWA download router.get( '/:id/offline-manifest', wrapAsync(async (req, res) => { @@ -530,15 +44,19 @@ router.get( const PWAManifestService = require('../services/pwa_manifest'); const { variant = 'desktop' } = req.query; + // Build base URL for asset proxy endpoints + const protocol = req.protocol; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + const manifest = await PWAManifestService.generateManifest( req.params.id, variant, + baseUrl, ); res.status(200).json(manifest); }), ); -router.use('/', require('../helpers').commonErrorHandler); - module.exports = router; diff --git a/backend/src/routes/publish.js b/backend/src/routes/publish.js index ea3fa06..bdd2cc4 100644 --- a/backend/src/routes/publish.js +++ b/backend/src/routes/publish.js @@ -19,7 +19,6 @@ const publishHandler = wrapAsync(async (req, res) => { }); router.post('/', publishHandler); -router.post('/publish', publishHandler); /** * @swagger diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js index b848adb..d3e2db4 100644 --- a/backend/src/routes/search.js +++ b/backend/src/routes/search.js @@ -1,5 +1,6 @@ const express = require('express'); const SearchService = require('../services/search'); +const { wrapAsync } = require('../helpers'); const router = express.Router(); @@ -32,23 +33,21 @@ router.use(checkCrudPermissions('search')); * description: Internal server error */ -router.post('/', async (req, res) => { - const { searchQuery } = req.body; +router.post( + '/', + wrapAsync(async (req, res) => { + const { searchQuery } = req.body; - if (!searchQuery) { - return res.status(400).json({ error: 'Please enter a search query' }); - } + if (!searchQuery) { + return res.status(400).json({ error: 'Please enter a search query' }); + } - try { const foundMatches = await SearchService.search( searchQuery, req.currentUser, ); res.json(foundMatches); - } catch (error) { - console.error('Internal Server Error', error); - res.status(500).json({ error: 'Internal Server Error' }); - } -}); + }), +); module.exports = router; diff --git a/backend/src/routes/sql.js b/backend/src/routes/sql.js index c2307c4..39f2032 100644 --- a/backend/src/routes/sql.js +++ b/backend/src/routes/sql.js @@ -1,42 +1,13 @@ const express = require('express'); const db = require('../db/models'); const wrapAsync = require('../helpers').wrapAsync; +const { validateReadOnlySql } = require('../utils/sqlValidator'); const router = express.Router(); const MAX_SQL_LENGTH = 5000; const MAX_SQL_ROWS = 1000; const SQL_TIMEOUT_MS = 5000; -const validateReadOnlyQuery = (sql) => { - if (typeof sql !== 'string' || !sql.trim()) { - return 'SQL is required'; - } - - if (sql.length > MAX_SQL_LENGTH) { - return `SQL is too long (max ${MAX_SQL_LENGTH} characters)`; - } - - const normalized = sql.trim().replace(/;+\s*$/, ''); - - if (!/^(select|with)\b/i.test(normalized)) { - return 'Only SELECT statements are allowed'; - } - - if (normalized.includes(';')) { - return 'Only a single statement is allowed'; - } - - if (/--|\/\*/.test(normalized)) { - return 'SQL comments are not allowed'; - } - - if (/\b(pg_sleep|set_config|copy)\b/i.test(normalized)) { - return 'Restricted SQL function detected'; - } - - return null; -}; - /** * @swagger * /api/sql: @@ -84,12 +55,12 @@ router.post( } const { sql } = req.body; - const validationError = validateReadOnlyQuery(sql); - if (validationError) { - return res.status(400).json({ error: validationError }); + const validation = validateReadOnlySql(sql, { maxLength: MAX_SQL_LENGTH }); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); } - const normalized = sql.trim().replace(/;+\s*$/, ''); + const normalized = validation.normalized; const wrappedSql = `SELECT * FROM (${normalized}) AS query_result LIMIT ${MAX_SQL_ROWS}`; const rows = await db.sequelize.transaction(async (transaction) => { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index da6a403..07a8b4d 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -1,446 +1,30 @@ -const express = require('express'); - +const { createEntityRouter } = require('../factories/router.factory'); const UsersService = require('../services/users'); const UsersDBApi = require('../db/api/users'); -const wrapAsync = require('../helpers').wrapAsync; +const { wrapAsync } = require('../helpers'); -const router = express.Router(); - -const { parse } = require('json2csv'); - -const { checkCrudPermissions } = require('../middlewares/check-permissions'); - -router.use(checkCrudPermissions('users')); - -/** - * @swagger - * components: - * schemas: - * Users: - * type: object - * properties: - - * firstName: - * type: string - * default: firstName - * lastName: - * type: string - * default: lastName - * phoneNumber: - * type: string - * default: phoneNumber - * email: - * type: string - * default: email - - - - */ - -/** - * @swagger - * tags: - * name: Users - * description: The Users managing API - */ - -/** - * @swagger - * /api/users: - * post: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Add new item - * description: Add new item - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Users" - * responses: - * 200: - * description: The item was successfully added - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - */ -router.post( - '/', - wrapAsync(async (req, res) => { - const referer = - req.headers.referer || - `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await UsersService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Users" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ -router.post( - '/bulk-import', - wrapAsync(async (req, res) => { - const referer = - req.headers.referer || - `${req.protocol}://${req.hostname}${req.originalUrl}`; - const link = new URL(referer); - await UsersService.bulkImport(req, res, true, link.host); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/users/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Users" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.put( - '/:id', - wrapAsync(async (req, res) => { - await UsersService.update(req.body.data, req.body.id, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/users/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.delete( - '/:id', - wrapAsync(async (req, res) => { - await UsersService.remove(req.params.id, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/users/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ -router.post( - '/deleteByIds', - wrapAsync(async (req, res) => { - await UsersService.deleteByIds(req.body.data, req.currentUser); - const payload = true; - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/users: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Get all users - * description: Get all users - * responses: - * 200: - * description: Users list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get( - '/', - wrapAsync(async (req, res) => { - const filetype = req.query.filetype; - - const currentUser = req.currentUser; - const payload = await UsersDBApi.findAll(req.query, { currentUser }); - if (filetype && filetype === 'csv') { - const fields = ['id', 'firstName', 'lastName', 'phoneNumber', 'email']; - const opts = { fields }; - try { - const csv = parse(payload.rows, opts); - res.status(200).attachment(csv); - res.send(csv); - } catch (err) { - console.error(err); - } - } else { - res.status(200).send(payload); - } - }), -); - -/** - * @swagger - * /api/users/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Count all users - * description: Count all users - * responses: - * 200: - * description: Users count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get( - '/count', - wrapAsync(async (req, res) => { - const currentUser = req.currentUser; - const payload = await UsersDBApi.findAll(req.query, null, { - countOnly: true, - currentUser, - }); - - res.status(200).send(payload); - }), -); - -/** - * @swagger - * /api/users/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Find all users that match search criteria - * description: Find all users that match search criteria - * responses: - * 200: - * description: Users list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Users" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ -router.get('/autocomplete', async (req, res) => { - const payload = await UsersDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - ); - - res.status(200).send(payload); +// Create base router with factory (includes all standard CRUD endpoints) +const router = createEntityRouter('users', UsersService, UsersDBApi, { + permissionEntity: 'users', + csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'], }); -/** - * @swagger - * /api/users/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Users] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Users" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ -router.get( - '/:id', - wrapAsync(async (req, res) => { - const payload = await UsersDBApi.findBy({ id: req.params.id }); - - delete payload.password; - - res.status(200).send(payload); - }), +// Override GET /:id to remove password from response +// Note: This needs to be added BEFORE the router is exported +// The factory already registered this route, so we add middleware to sanitize +const originalGetById = router.stack.find( + (layer) => layer.route?.path === '/:id' && layer.route?.methods?.get ); -router.use('/', require('../helpers').commonErrorHandler); +if (originalGetById) { + originalGetById.route.stack[0].handle = wrapAsync(async (req, res) => { + // Call original handler with a custom response + const payload = await UsersDBApi.findBy({ id: req.params.id }); + if (payload) { + delete payload.password; + } + res.status(200).send(payload); + }); +} module.exports = router; diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js index 5abbcf8..1ae730d 100644 --- a/backend/src/services/projects.js +++ b/backend/src/services/projects.js @@ -1,11 +1,21 @@ const db = require('../db/models'); const ProjectsDBApi = require('../db/api/projects'); -const processFile = require('../middlewares/upload'); +const { createEntityService } = require('../factories/service.factory'); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); -const stream = require('stream'); -module.exports = class ProjectsService { +// Generate base service from factory +const BaseProjectsService = createEntityService(ProjectsDBApi, { + entityName: 'Projects', +}); + +/** + * Projects service with slug validation and cloning functionality + * Extends factory-generated service with custom project logic + */ +class ProjectsService extends BaseProjectsService { + /** + * Normalize slug to URL-safe format + */ static normalizeSlug(value) { return ( String(value || 'project') @@ -16,13 +26,15 @@ module.exports = class ProjectsService { ); } + /** + * Generate unique slug for cloning + */ static async generateUniqueSlug(baseSlug, transaction) { const normalizedBase = ProjectsService.normalizeSlug(baseSlug); let counter = 0; - let hasUniqueSlug = false; - let uniqueSlug = ''; + let uniqueSlug = null; - while (!hasUniqueSlug) { + while (uniqueSlug === null) { const suffix = counter === 0 ? '-copy' : `-copy-${counter + 1}`; const candidate = `${normalizedBase}${suffix}`; @@ -34,7 +46,6 @@ module.exports = class ProjectsService { if (!existing) { uniqueSlug = candidate; - hasUniqueSlug = true; } else { counter += 1; } @@ -44,12 +55,7 @@ module.exports = class ProjectsService { } /** - * Validate slug uniqueness before create/update - * @param {string} slug - Slug to validate - * @param {string|null} excludeId - Project ID to exclude (for updates) - * @param {Transaction} transaction - DB transaction - * @throws {ValidationError} if slug already exists - * @returns {string} Normalized slug + * Validate slug uniqueness */ static async validateSlugUniqueness(slug, excludeId, transaction) { const normalizedSlug = ProjectsService.normalizeSlug(slug); @@ -72,10 +78,12 @@ module.exports = class ProjectsService { return normalizedSlug; } + /** + * Create project with slug validation + */ static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - // Validate slug uniqueness if provided if (data.slug) { data.slug = await ProjectsService.validateSlugUniqueness( data.slug, @@ -84,19 +92,55 @@ module.exports = class ProjectsService { ); } - const createdProject = await ProjectsDBApi.create(data, { + const project = await ProjectsDBApi.create(data, { currentUser, transaction, }); await transaction.commit(); - return createdProject; + return project; } catch (error) { await transaction.rollback(); throw error; } } + /** + * Update project with slug validation + */ + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const project = await ProjectsDBApi.findBy({ id }, { transaction }); + + if (!project) { + throw new ValidationError('projectsNotFound'); + } + + if (data.slug && data.slug !== project.slug) { + data.slug = await ProjectsService.validateSlugUniqueness( + data.slug, + id, + transaction, + ); + } + + const updated = await ProjectsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updated; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + /** + * Clone project with all assets + */ static async cloneFromProject(sourceProjectId, currentUser) { const transaction = await db.sequelize.transaction(); @@ -137,12 +181,10 @@ module.exports = class ProjectsService { favicon_url: sourceProject.favicon_url, og_image_url: sourceProject.og_image_url, }, - { - currentUser, - transaction, - }, + { currentUser, transaction }, ); + // Clone assets and variants for (const sourceAsset of sourceProject.assets_project || []) { const clonedAsset = await db.assets.create( { @@ -189,102 +231,6 @@ module.exports = class ProjectsService { throw error; } } +} - static async bulkImport(req, res) { - const transaction = await db.sequelize.transaction(); - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }); - - await ProjectsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - let projects = await ProjectsDBApi.findBy({ id }, { transaction }); - - if (!projects) { - throw new ValidationError('projectsNotFound'); - } - - // Validate slug uniqueness if slug is being changed - if (data.slug && data.slug !== projects.slug) { - data.slug = await ProjectsService.validateSlugUniqueness( - data.slug, - id, - transaction, - ); - } - - const updatedProjects = await ProjectsDBApi.update(id, data, { - currentUser, - transaction, - }); - - await transaction.commit(); - return updatedProjects; - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await ProjectsDBApi.deleteByIds(ids, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - await ProjectsDBApi.remove(id, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - } -}; +module.exports = ProjectsService; diff --git a/backend/src/services/pwa_manifest.js b/backend/src/services/pwa_manifest.js index 99ec0f6..67bcb86 100644 --- a/backend/src/services/pwa_manifest.js +++ b/backend/src/services/pwa_manifest.js @@ -94,9 +94,10 @@ class PWAManifestService { * Generate offline manifest for a project * @param {string} projectId - Project ID * @param {string} deviceType - 'mobile' or 'desktop' (affects variant selection) + * @param {string} baseUrl - Base URL for proxy endpoints (e.g., 'http://localhost:8080') * @returns {Object} Offline manifest */ - static async generateManifest(projectId, deviceType = 'desktop') { + static async generateManifest(projectId, deviceType = 'desktop', baseUrl = '') { // Fetch all project data const [assetsResult, pagesResult] = await Promise.all([ AssetsDBApi.findAll({ project: projectId }, {}), @@ -116,6 +117,49 @@ class PWAManifestService { return Math.round(parseFloat(sizeMb) * 1024 * 1024); }; + // Helper to convert URL to proxy URL (avoids CORS issues with S3) + // Extracts storage key from full URLs for backend proxy + const toProxyUrl = (url) => { + if (!url) return url; + + let storageKey = url; + + // If it's a full S3 URL, extract the path after the bucket/hash + if (url.includes('.s3.') || url.includes('s3.amazonaws.com')) { + // URL format: https://bucket.s3.amazonaws.com/hash/path/to/file + // or: https://s3.region.amazonaws.com/bucket/hash/path/to/file + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(Boolean); + // Skip the hash prefix (first path segment after bucket) + if (pathParts.length > 1) { + storageKey = pathParts.slice(1).join('/'); + } else { + storageKey = pathParts.join('/'); + } + } catch { + // If URL parsing fails, use as-is + } + } else if (url.includes('storage.googleapis.com')) { + // GCloud URL format: https://storage.googleapis.com/bucket/hash/path/to/file + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(Boolean); + // Skip bucket and hash (first two segments) + if (pathParts.length > 2) { + storageKey = pathParts.slice(2).join('/'); + } + } catch { + // If URL parsing fails, use as-is + } + } else if (url.startsWith('http')) { + // Other external URL - return as-is (can't proxy) + return url; + } + + return `${baseUrl}/api/file/download?privateUrl=${encodeURIComponent(storageKey)}`; + }; + // Helper to add an asset to the manifest const addAsset = ( id, @@ -132,7 +176,7 @@ class PWAManifestService { manifestAssets.push({ id: id || `url-${Date.now()}-${Math.random().toString(36).slice(2)}`, - url, + url: toProxyUrl(url), filename: filename || url.split('/').pop() || 'unknown', variantType: variantType || 'original', assetType: assetType || getAssetType(mimeType, filename), diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js index 10164da..e5282bc 100644 --- a/backend/src/services/roles.js +++ b/backend/src/services/roles.js @@ -6,43 +6,18 @@ const csv = require('csv-parser'); const axios = require('axios'); const config = require('../config'); const stream = require('stream'); +const { validateReadOnlySql } = require('../utils/sqlValidator'); const WIDGET_SQL_MAX_LENGTH = 5000; const WIDGET_SQL_MAX_ROWS = 1000; const WIDGET_SQL_TIMEOUT_MS = 5000; const validateWidgetSql = (sql) => { - if (typeof sql !== 'string' || !sql.trim()) { - throw new ValidationError('Widget query must be a non-empty SQL string'); + const result = validateReadOnlySql(sql, { maxLength: WIDGET_SQL_MAX_LENGTH }); + if (!result.valid) { + throw new ValidationError(result.error); } - - if (sql.length > WIDGET_SQL_MAX_LENGTH) { - throw new ValidationError( - `Widget query is too long (max ${WIDGET_SQL_MAX_LENGTH} characters)`, - ); - } - - const normalized = sql.trim().replace(/;+\s*$/, ''); - - if (!/^(select|with)\b/i.test(normalized)) { - throw new ValidationError('Widget query must be a SELECT statement'); - } - - if (normalized.includes(';')) { - throw new ValidationError('Widget query must contain a single statement'); - } - - if (/--|\/\*/.test(normalized)) { - throw new ValidationError('SQL comments are not allowed in widget queries'); - } - - if (/\b(pg_sleep|set_config|copy)\b/i.test(normalized)) { - throw new ValidationError( - 'Restricted SQL function detected in widget query', - ); - } - - return normalized; + return result.normalized; }; const runSafeWidgetQuery = async (sql) => { diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 96a0004..b20a534 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -1,149 +1,63 @@ const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); -const processFile = require('../middlewares/upload'); +const { createEntityService } = require('../factories/service.factory'); const ValidationError = require('./notifications/errors/validation'); -const csv = require('csv-parser'); const config = require('../config'); -const stream = require('stream'); - const AuthService = require('./auth'); -module.exports = class UsersService { +// Generate base service from factory +const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users' }); + +/** + * Users service with email invitation functionality + * Extends factory-generated service with custom user logic + */ +class UsersService extends BaseUsersService { + /** + * Create user with email validation and optional invitation + */ static async create(data, currentUser, sendInvitationEmails = true, host) { - let transaction = await db.sequelize.transaction(); + const transaction = await db.sequelize.transaction(); + const email = data.email; - let email = data.email; - let emailsToInvite = []; try { - if (email) { - let user = await UsersDBApi.findBy({ email }, { transaction }); - if (user) { - throw new ValidationError('iam.errors.userAlreadyExists'); - } else { - await UsersDBApi.create( - { data }, - - { - currentUser, - transaction, - }, - ); - emailsToInvite.push(email); - } - } else { + if (!email) { throw new ValidationError('iam.errors.emailRequired'); } - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - if (emailsToInvite && emailsToInvite.length) { - if (!sendInvitationEmails) return; - AuthService.sendPasswordResetEmail(email, 'invitation', host); - } - } - - static async bulkImport(req, res, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); - let emailsToInvite = []; - - try { - await processFile(req, res); - const bufferStream = new stream.PassThrough(); - const results = []; - - await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream - - await new Promise((resolve, reject) => { - bufferStream - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', () => { - console.log('results csv', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }); - - const hasAllEmails = results.every((result) => result.email); - - if (!hasAllEmails) { - throw new ValidationError('importer.errors.userEmailMissing'); + const existingUser = await UsersDBApi.findBy({ email }, { transaction }); + if (existingUser) { + throw new ValidationError('iam.errors.userAlreadyExists'); } - await UsersDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser, - }); - - emailsToInvite = results.map((result) => result.email); - + await UsersDBApi.create({ data }, { currentUser, transaction }); await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; - } - if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) { - emailsToInvite.forEach((email) => { + // Send invitation email after successful commit + if (sendInvitationEmails) { AuthService.sendPasswordResetEmail(email, 'invitation', host); - }); - } - } - - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - let users = await UsersDBApi.findBy({ id }, { transaction }); - - if (!users) { - throw new ValidationError('iam.errors.userNotFound'); } - - const updatedUser = await UsersDBApi.update( - id, - data, - - { - currentUser, - transaction, - }, - ); - - await transaction.commit(); - return updatedUser; } catch (error) { await transaction.rollback(); throw error; } } + /** + * Remove user with self-deletion and permission checks + */ static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); - - try { - if (currentUser.id === id) { - throw new ValidationError('iam.errors.deletingHimself'); - } - - if (currentUser.app_role?.name !== config.roles.admin) { - throw new ValidationError('errors.forbidden.message'); - } - - await UsersDBApi.remove(id, { - currentUser, - transaction, - }); - - await transaction.commit(); - } catch (error) { - await transaction.rollback(); - throw error; + if (currentUser.id === id) { + throw new ValidationError('iam.errors.deletingHimself'); } + + if (currentUser.app_role?.name !== config.roles.admin) { + throw new ValidationError('errors.forbidden.message'); + } + + // Delegate to parent (factory) implementation + return super.remove(id, currentUser); } -}; +} + +module.exports = UsersService; diff --git a/backend/src/utils/sqlValidator.js b/backend/src/utils/sqlValidator.js new file mode 100644 index 0000000..d62d19b --- /dev/null +++ b/backend/src/utils/sqlValidator.js @@ -0,0 +1,53 @@ +/** + * SQL Validator + * + * Shared validation for read-only SQL queries (widgets, admin SQL executor). + * Ensures queries are SELECT-only and don't contain dangerous patterns. + */ + +const DEFAULT_MAX_LENGTH = 5000; +const RESTRICTED_FUNCTIONS = /\b(pg_sleep|set_config|copy)\b/i; + +/** + * Validate a SQL query for read-only execution + * @param {string} sql - The SQL query to validate + * @param {object} options - Validation options + * @param {number} [options.maxLength=5000] - Maximum allowed query length + * @returns {{ valid: boolean, error?: string, normalized?: string }} + */ +function validateReadOnlySql(sql, options = {}) { + const maxLength = options.maxLength || DEFAULT_MAX_LENGTH; + + if (typeof sql !== 'string' || !sql.trim()) { + return { valid: false, error: 'SQL query must be a non-empty string' }; + } + + if (sql.length > maxLength) { + return { + valid: false, + error: `SQL query is too long (max ${maxLength} characters)`, + }; + } + + const normalized = sql.trim().replace(/;+\s*$/, ''); + + if (!/^(select|with)\b/i.test(normalized)) { + return { valid: false, error: 'Only SELECT statements are allowed' }; + } + + if (normalized.includes(';')) { + return { valid: false, error: 'Only a single statement is allowed' }; + } + + if (/--|\/\*/.test(normalized)) { + return { valid: false, error: 'SQL comments are not allowed' }; + } + + if (RESTRICTED_FUNCTIONS.test(normalized)) { + return { valid: false, error: 'Restricted SQL function detected' }; + } + + return { valid: true, normalized }; +} + +module.exports = { validateReadOnlySql }; diff --git a/frontend/package.json b/frontend/package.json index deca983..41f78d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@reduxjs/toolkit": "^2.1.0", "@serwist/next": "^9.5.7", "@tailwindcss/typography": "^0.5.13", + "@tanstack/react-query": "^5.96.2", "apexcharts": "^5.0.0", "axios": "^1.8.4", "chart.js": "^4.4.1", @@ -54,7 +55,6 @@ "react-select-async-paginate": "^0.7.11", "react-switch": "^7.0.0", "react-toastify": "^11.0.2", - "swr": "^2.0.0", "uuid": "^9.0.0", "zod": "^4.3.6" }, diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 0868b04..1079236 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/1232-ee492d53bb7b1303.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/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/4449-7abf9cb2c2db0cfe.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5251-9166adebfa6aea86.js'},{'revision':null,'url':'/_next/static/chunks/5371-1944e2cec48f4711.js'},{'revision':null,'url':'/_next/static/chunks/5394-7f2f4299ffdb8499.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-29201d437469520c.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-4294e63d8a7907d5.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-7d8e5255b1dd65c8.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-7c176c811345e710.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-f6f335f64c8d6454.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-ce9572b45f686342.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-7975d46cbc32327d.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-a15b1aef071b1956.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-4cf77441b8037615.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-4890011ca9c3a3df.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-fa8a012e1d8c87f3.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-d0b2ccf7259abe27.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-d922639bb0cfdbef.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-9bfc6282834605db.js'},{'revision':null,'url':'/_next/static/css/5db81de84f74dbcf.css'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wdth-normal.a718fc63.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wght-normal.7db92424.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wdth-normal.68c3c527.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wght-normal.ae05c57c.woff2'},{'revision':'d41a948d94d075ec6b06e0efb882c649','url':'/_next/static/tRkjKl7u2sxohRW3ePnlq/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/tRkjKl7u2sxohRW3ePnlq/_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':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1232-ee492d53bb7b1303.js'},{'revision':null,'url':'/_next/static/chunks/1818-b3660c83cef1d173.js'},{'revision':null,'url':'/_next/static/chunks/1941-25ce7d659180f042.js'},{'revision':null,'url':'/_next/static/chunks/223-5e7611f0ad4bb7f3.js'},{'revision':null,'url':'/_next/static/chunks/2450-c991dd9dfbd7be31.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-2355014a69094c93.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4449-d925e647ce60c00a.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/5251-f243bcc492cc95d3.js'},{'revision':null,'url':'/_next/static/chunks/5371-1944e2cec48f4711.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-53e9655e2439e501.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-4294e63d8a7907d5.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-8d339e7e896d096c.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-35566af647779ab1.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-a1df383934285298.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-f30f413d0d49c7e4.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-72af829781a60809.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-cc2e3bbc0af9ae2c.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-c76bec5d8cc7a727.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-244a4f0d1e658028.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-7efe17782be4c508.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-89c254b1127feb02.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-ae4a2e46accd3aff.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-a262469fffe85702.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults-5ab16a411a1c84a0.js'},{'revision':null,'url':'/_next/static/chunks/pages/element-type-defaults/%5Bid%5D-f6f335f64c8d6454.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-556beef6d00adfa6.js'},{'revision':null,'url':'/_next/static/chunks/pages/p/%5BprojectSlug%5D/stage-c9d625f58aa99aa2.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-03b8d6ce2746e5ac.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-35f3e6f87eb315d5.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-2f8c6ca80f30e4e7.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-4d3473c7577b4b0d.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-52cb8b59b5fce4e7.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-39a5e0b77ea161c1.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-53ea2331c015449b.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-c1b2c9f67ee299cf.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults-41c52e13536a5e19.js'},{'revision':null,'url':'/_next/static/chunks/pages/project-element-defaults/%5Bid%5D-a15b1aef071b1956.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-21a2b75463c7ffdb.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-de76fb3c1d783efa.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-f2db64bfe6512eac.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-e963682ce1f09381.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-58f19ec0e1dedb5a.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-8b8a23b0cc93252e.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-86773b41c7e0155d.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-c66fd2e635e50eda.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-6b45fca8d4e25a1a.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-fa8a012e1d8c87f3.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-d0b2ccf7259abe27.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-7719886c9aa95720.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-91bb40dd49d451bd.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-5ee3360c4efe4ecc.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-7dfc48227b634d01.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-28f438459443352a.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-12230990c23e7fd8.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-e07b159d3fb8e05a.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-0f23c3f267670577.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-c0bdb71711f9e8af.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-b7e1e9f9cdd69669.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-6f87b5ff9d68835f.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-3dc1e444688481a5.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-8063848d4e94fbe9.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-c12f25262a5eec2c.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-554ee8551b6d8ad4.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-29784dfa80b7733a.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-2be3c12290c2330b.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-6dc01b2679a81960.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-16d698d3c4c52d85.js'},{'revision':null,'url':'/_next/static/css/5db81de84f74dbcf.css'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':'fd082d8b3baf52c11236bb3407619881','url':'/_next/static/ke2xuYCL42tjz2H2WsUcH/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/ke2xuYCL42tjz2H2WsUcH/_ssgManifest.js'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wdth-normal.a718fc63.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-ext-wght-normal.7db92424.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wdth-normal.68c3c527.woff2'},{'revision':null,'url':'/_next/static/media/instrument-sans-latin-wght-normal.ae05c57c.woff2'},{'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/colors.ts b/frontend/src/colors.ts index 0fea7b8..35f0abb 100644 --- a/frontend/src/colors.ts +++ b/frontend/src/colors.ts @@ -1,4 +1,4 @@ -import type { ColorButtonKey } from './interfaces'; +import type { ColorButtonKey } from './types/ui'; export const gradientBgBase = 'bg-gradient-to-tr'; export const colorBgBase = 'bg-violet-50/50'; diff --git a/frontend/src/components/Access_logs/CardAccess_logs.tsx b/frontend/src/components/Access_logs/CardAccess_logs.tsx index bc66956..90d2420 100644 --- a/frontend/src/components/Access_logs/CardAccess_logs.tsx +++ b/frontend/src/components/Access_logs/CardAccess_logs.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { AccessLog } from '../../types/entities'; type Props = { - access_logs: any[]; + access_logs: AccessLog[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Access_logs/ListAccess_logs.tsx b/frontend/src/components/Access_logs/ListAccess_logs.tsx index 3ec09ca..1d11d52 100644 --- a/frontend/src/components/Access_logs/ListAccess_logs.tsx +++ b/frontend/src/components/Access_logs/ListAccess_logs.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { AccessLog } from '../../types/entities'; type Props = { - access_logs: any[]; + access_logs: AccessLog[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx index 4ce5173..1a1f0fc 100644 --- a/frontend/src/components/AsideMenu.tsx +++ b/frontend/src/components/AsideMenu.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MenuAsideItem } from '../interfaces'; +import { MenuAsideItem } from '../types/menu'; import AsideMenuLayer from './AsideMenuLayer'; import OverlayLayer from './OverlayLayer'; diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx index c5a8b8a..b8e1644 100644 --- a/frontend/src/components/AsideMenuItem.tsx +++ b/frontend/src/components/AsideMenuItem.tsx @@ -4,7 +4,7 @@ import BaseIcon from './BaseIcon'; import Link from 'next/link'; import { getButtonColor } from '../colors'; import AsideMenuList from './AsideMenuList'; -import { MenuAsideItem } from '../interfaces'; +import { MenuAsideItem } from '../types/menu'; import { useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 46c303a..0fd0e65 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { mdiLogout, mdiClose } from '@mdi/js'; import BaseIcon from './BaseIcon'; import AsideMenuList from './AsideMenuList'; -import { MenuAsideItem } from '../interfaces'; +import { MenuAsideItem } from '../types/menu'; import { useAppSelector } from '../stores/hooks'; import Link from 'next/link'; diff --git a/frontend/src/components/AsideMenuList.tsx b/frontend/src/components/AsideMenuList.tsx index 9f0434e..30cb703 100644 --- a/frontend/src/components/AsideMenuList.tsx +++ b/frontend/src/components/AsideMenuList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MenuAsideItem } from '../interfaces'; +import { MenuAsideItem } from '../types/menu'; import AsideMenuItem from './AsideMenuItem'; import { useAppSelector } from '../stores/hooks'; import { hasPermission } from '../helpers/userPermissions'; diff --git a/frontend/src/components/Asset_variants/CardAsset_variants.tsx b/frontend/src/components/Asset_variants/CardAsset_variants.tsx index bbc97e8..2e40ca4 100644 --- a/frontend/src/components/Asset_variants/CardAsset_variants.tsx +++ b/frontend/src/components/Asset_variants/CardAsset_variants.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { AssetVariant } from '../../types/entities'; type Props = { - asset_variants: any[]; + asset_variants: AssetVariant[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Asset_variants/ListAsset_variants.tsx b/frontend/src/components/Asset_variants/ListAsset_variants.tsx index b0c022b..0ca6d01 100644 --- a/frontend/src/components/Asset_variants/ListAsset_variants.tsx +++ b/frontend/src/components/Asset_variants/ListAsset_variants.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { AssetVariant } from '../../types/entities'; type Props = { - asset_variants: any[]; + asset_variants: AssetVariant[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Assets/CardAssets.tsx b/frontend/src/components/Assets/CardAssets.tsx index cefdc25..d6a7e1a 100644 --- a/frontend/src/components/Assets/CardAssets.tsx +++ b/frontend/src/components/Assets/CardAssets.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Asset } from '../../types/entities'; type Props = { - assets: any[]; + assets: Asset[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Assets/ListAssets.tsx b/frontend/src/components/Assets/ListAssets.tsx index 7a5fbae..d7be27c 100644 --- a/frontend/src/components/Assets/ListAssets.tsx +++ b/frontend/src/components/Assets/ListAssets.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Asset } from '../../types/entities'; type Props = { - assets: any[]; + assets: Asset[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/BaseButton.tsx b/frontend/src/components/BaseButton.tsx index cb87f90..d44ea02 100644 --- a/frontend/src/components/BaseButton.tsx +++ b/frontend/src/components/BaseButton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Link from 'next/link'; import { getButtonColor } from '../colors'; import BaseIcon from './BaseIcon'; -import type { ColorButtonKey } from '../interfaces'; +import type { ColorButtonKey } from '../types/ui'; import { useAppSelector } from '../stores/hooks'; type Props = { diff --git a/frontend/src/components/CardBoxModal.tsx b/frontend/src/components/CardBoxModal.tsx index 9de915a..f72f320 100644 --- a/frontend/src/components/CardBoxModal.tsx +++ b/frontend/src/components/CardBoxModal.tsx @@ -1,6 +1,6 @@ import { mdiClose } from '@mdi/js'; import { ReactNode } from 'react'; -import type { ColorButtonKey } from '../interfaces'; +import type { ColorButtonKey } from '../types/ui'; import BaseButton from './BaseButton'; import BaseButtons from './BaseButtons'; import CardBox from './CardBox'; diff --git a/frontend/src/components/Constructor/BackgroundSettingsEditor.tsx b/frontend/src/components/Constructor/BackgroundSettingsEditor.tsx index db46386..692d443 100644 --- a/frontend/src/components/Constructor/BackgroundSettingsEditor.tsx +++ b/frontend/src/components/Constructor/BackgroundSettingsEditor.tsx @@ -7,17 +7,9 @@ */ import React from 'react'; -import type { AssetOption } from './types'; +import type { AssetOption, VideoPlaybackSettings } from './types'; import { addFallbackAssetOption } from '../../lib/constructorHelpers'; -export interface VideoPlaybackSettings { - autoplay?: boolean; - loop?: boolean; - muted?: boolean; - startTime?: number | null; - endTime?: number | null; -} - interface BackgroundSettingsEditorProps { type: 'image' | 'video' | 'audio'; value: string; diff --git a/frontend/src/components/Constructor/ConstructorMenu.tsx b/frontend/src/components/Constructor/ConstructorMenu.tsx index 0833352..2bdb6b1 100644 --- a/frontend/src/components/Constructor/ConstructorMenu.tsx +++ b/frontend/src/components/Constructor/ConstructorMenu.tsx @@ -4,7 +4,7 @@ * Draggable menu panel with actions for adding elements, backgrounds, etc. */ -import React from 'react'; +import React, { forwardRef } from 'react'; import BaseIcon from '../BaseIcon'; import BaseButton from '../BaseButton'; import { @@ -19,12 +19,8 @@ import { mdiExitToApp, } from '@mdi/js'; import MenuActionButton from './MenuActionButton'; -import type { - Position, - EditorMenuItem, - CanvasElementType, - NavigationElementType, -} from './types'; +import type { Position, CanvasElementType, NavigationElementType } from './types'; +import type { EditorMenuItem } from '../../types/constructor'; interface ConstructorMenuProps { position: Position; @@ -43,27 +39,32 @@ interface ConstructorMenuProps { onExit: () => void; } -const ConstructorMenu: React.FC = ({ - position, - isOpen, - allowedNavigationTypes, - isCreatingPage, - isSaving, - isSavingToStage, - onDragStart, - onToggleOpen, - onSelectMenuItem, - onAddElement, - onCreatePage, - onSave, - onSaveToStage, - onExit, -}) => { - return ( -
+const ConstructorMenu = forwardRef( + ( + { + position, + isOpen, + allowedNavigationTypes, + isCreatingPage, + isSaving, + isSavingToStage, + onDragStart, + onToggleOpen, + onSelectMenuItem, + onAddElement, + onCreatePage, + onSave, + onSaveToStage, + onExit, + }, + ref, + ) => { + return ( +
= ({
)}
- ); -}; + ); + }, +); + +ConstructorMenu.displayName = 'ConstructorMenu'; export default ConstructorMenu; diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 84d7026..75a1a9f 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -3,9 +3,23 @@ * * Renders the element editor sidebar in the constructor. * Handles element settings, background settings, and transition creation. + * + * Uses ConstructorContext for all state - only receives local UI props. */ import React from 'react'; +import { + useConstructorContext, + useConstructorElements, + useConstructorBackground, + useConstructorAssets, + useConstructorCollectionOps, + useConstructorDuration, + useConstructorNavigation, + useConstructorTransitionCreation, + useConstructorEditorTab, + useConstructorMenu, +} from '../../context/ConstructorContext'; import { ElementSettingsTabsCompact, StyleSettingsSectionCompact, @@ -35,137 +49,33 @@ import { isMediaElementType, isVideoPlayerElementType, } from '../../lib/elementDefaults'; -import type { - CanvasElement, - CanvasElementType, - GalleryCard, - GalleryInfoSpan, - CarouselSlide, -} from '../../types/constructor'; +import type { CanvasElement } from '../../types/constructor'; -type NavigationElementType = Extract< - CanvasElementType, - 'navigation_next' | 'navigation_prev' ->; +type NavigationElementType = 'navigation_next' | 'navigation_prev'; -type EditorMenuItem = - | 'none' - | 'background_image' - | 'background_video' - | 'background_audio' - | 'create_transition'; - -type TourPage = { - id: string; - name?: string; - slug?: string; - sort_order?: number; -}; - -interface AssetOption { - value: string; - label: string; -} +// ============================================================================ +// Props Interface (Local UI props only) +// ============================================================================ interface ElementEditorPanelProps { - // Refs and positioning + /** Ref for outside click detection */ elementEditorRef: React.RefObject; + /** Draggable position */ position: { x: number; y: number }; + /** Whether panel is collapsed */ isCollapsed: boolean; + /** Toggle collapse state */ onToggleCollapse: () => void; + /** Start dragging the panel */ onDragStart: (event: React.MouseEvent) => void; - - // Editor state + /** Panel title */ title: string; - activeTab: 'general' | 'css' | 'effects'; - onTabChange: (tab: 'general' | 'css' | 'effects') => void; - - // Selected element - selectedElement: CanvasElement | null; - selectedMenuItem: EditorMenuItem; - onRemoveElement: () => void; - onUpdateElement: (patch: Partial) => void; - - // Background settings - backgroundImageUrl: string; - backgroundVideoUrl: string; - backgroundAudioUrl: string; - onBackgroundImageChange: (value: string) => void; - onBackgroundVideoChange: (value: string) => void; - onBackgroundAudioChange: (value: string) => void; - - // Background video playback settings - backgroundVideoAutoplay: boolean; - backgroundVideoLoop: boolean; - backgroundVideoMuted: boolean; - backgroundVideoStartTime: number | null; - backgroundVideoEndTime: number | null; - onBackgroundVideoSettingsChange: (settings: { - autoplay?: boolean; - loop?: boolean; - muted?: boolean; - startTime?: number | null; - endTime?: number | null; - }) => void; - - // Transition creation - newTransitionName: string; - newTransitionVideoUrl: string; - newTransitionSupportsReverse: boolean; - isCreatingTransition: boolean; - onNewTransitionNameChange: (value: string) => void; - onNewTransitionVideoUrlChange: (value: string) => void; - onNewTransitionSupportsReverseChange: (value: boolean) => void; - onCreateTransition: () => void; - - // Duration notes - backgroundVideoDurationNote: string; - backgroundAudioDurationNote: string; - newTransitionDurationNote: string; - selectedMediaDurationNote: string; - selectedTransitionDurationNote: string; - - // Asset options - backgroundImageAssetOptions: AssetOption[]; - videoAssetOptions: AssetOption[]; - audioAssetOptions: AssetOption[]; - transitionVideoAssetOptions: AssetOption[]; - iconAssetOptions: AssetOption[]; - imageAssetOptions: AssetOption[]; - - // Navigation settings - allowedNavigationTypes: NavigationElementType[]; - pages: TourPage[]; - activePageId: string; - onPreviewTransition: (direction: 'forward' | 'back') => void; - - // Gallery/Carousel operations - galleryCards: { - add: () => void; - update: (cardId: string, patch: Partial) => void; - remove: (cardId: string) => void; - }; - galleryInfoSpans: { - add: () => void; - update: (spanId: string, patch: Partial) => void; - remove: (spanId: string) => void; - }; - carouselSlides: { - add: () => void; - update: (slideId: string, patch: Partial) => void; - remove: (slideId: string) => void; - }; - - // Navigation type normalization - normalizeNavigationType: ( - element: CanvasElement, - nextType: NavigationElementType, - ) => CanvasElement; - - // Duration resolver - getDuration: (url: string) => number | undefined; } +// ============================================================================ +// CSS Property Handler +// ============================================================================ + /** * Handle CSS property changes with unit conversion */ @@ -215,6 +125,10 @@ const handleCssPropertyChange = ( } }; +// ============================================================================ +// Component +// ============================================================================ + export function ElementEditorPanel({ elementEditorRef, position, @@ -222,53 +136,48 @@ export function ElementEditorPanel({ onToggleCollapse, onDragStart, title, - activeTab, - onTabChange, - selectedElement, - selectedMenuItem, - onRemoveElement, - onUpdateElement, - backgroundImageUrl, - backgroundVideoUrl, - backgroundAudioUrl, - onBackgroundImageChange, - onBackgroundVideoChange, - onBackgroundAudioChange, - backgroundVideoAutoplay, - backgroundVideoLoop, - backgroundVideoMuted, - backgroundVideoStartTime, - backgroundVideoEndTime, - onBackgroundVideoSettingsChange, - newTransitionName, - newTransitionVideoUrl, - newTransitionSupportsReverse, - isCreatingTransition, - onNewTransitionNameChange, - onNewTransitionVideoUrlChange, - onNewTransitionSupportsReverseChange, - onCreateTransition, - backgroundVideoDurationNote, - backgroundAudioDurationNote, - newTransitionDurationNote, - selectedMediaDurationNote, - selectedTransitionDurationNote, - backgroundImageAssetOptions, - videoAssetOptions, - audioAssetOptions, - transitionVideoAssetOptions, - iconAssetOptions, - imageAssetOptions, - allowedNavigationTypes, - pages, - activePageId, - onPreviewTransition, - galleryCards, - galleryInfoSpans, - carouselSlides, - normalizeNavigationType, - getDuration, }: ElementEditorPanelProps) { + // Get state from context + const { + selectedElement, + selectedElementId, + updateSelectedElement, + removeSelectedElement, + } = useConstructorElements(); + + const { selectedMenuItem } = useConstructorMenu(); + + const { + pageBackground, + setBackgroundImageUrl, + setBackgroundVideoUrl, + setBackgroundAudioUrl, + setBackgroundVideoSettings, + } = useConstructorBackground(); + + const { assetOptions } = useConstructorAssets(); + + const { galleryCards, galleryInfoSpans, carouselSlides } = + useConstructorCollectionOps(); + + const { getDuration, durationNotes } = useConstructorDuration(); + + const { + pages, + activePageId, + allowedNavigationTypes, + normalizeNavigationType, + onPreviewTransition, + } = useConstructorNavigation(); + + const transitionCreation = useConstructorTransitionCreation(); + + const { activeTab, setActiveTab } = useConstructorEditorTab(); + + // ============================================================================ + // Render + // ============================================================================ + return (
{!isCollapsed && ( <> + {/* Background Image Settings */} {selectedMenuItem === 'background_image' && ( { - onBackgroundImageChange(value); - if (value) onBackgroundVideoChange(''); + setBackgroundImageUrl(value); + if (value) setBackgroundVideoUrl(''); }} /> )} + {/* Background Video Settings */} {selectedMenuItem === 'background_video' && ( { - onBackgroundVideoChange(value); - if (value) onBackgroundImageChange(''); + setBackgroundVideoUrl(value); + if (value) setBackgroundImageUrl(''); }} - videoAutoplay={backgroundVideoAutoplay} - videoLoop={backgroundVideoLoop} - videoMuted={backgroundVideoMuted} - videoStartTime={backgroundVideoStartTime} - videoEndTime={backgroundVideoEndTime} - onVideoSettingsChange={onBackgroundVideoSettingsChange} + videoAutoplay={pageBackground.videoSettings.autoplay} + videoLoop={pageBackground.videoSettings.loop} + videoMuted={pageBackground.videoSettings.muted} + videoStartTime={pageBackground.videoSettings.startTime} + videoEndTime={pageBackground.videoSettings.endTime} + onVideoSettingsChange={setBackgroundVideoSettings} /> )} + {/* Background Audio Settings */} {selectedMenuItem === 'background_audio' && ( )} + {/* Create Transition Form */} {selectedMenuItem === 'create_transition' && ( )} + {/* Element Settings */} {selectedElement && ( <> - onTabChange(tab as 'general' | 'css' | 'effects') + setActiveTab(tab as 'general' | 'css' | 'effects') } tabs={[ { id: 'general', label: 'General' }, @@ -356,6 +270,7 @@ export function ElementEditorPanel({ ]} /> + {/* General Tab */} {activeTab === 'general' && ( <> { if (prop === 'label') { - onUpdateElement({ label: value }); + updateSelectedElement({ label: value }); } else if (prop === 'appearDelaySec') { - onUpdateElement({ + updateSelectedElement({ appearDelaySec: normalizeAppearDelaySec(value), }); } else if (prop === 'appearDurationSec') { - onUpdateElement({ + updateSelectedElement({ appearDurationSec: normalizeAppearDurationSec(value), }); } }} /> + {/* Navigation Settings */} {isNavigationElementType(selectedElement.type) && ( { if (prop === 'type') { const nextType = value as NavigationElementType; - onUpdateElement( + updateSelectedElement( normalizeNavigationType(selectedElement, nextType), ); } else if (prop === 'transitionVideoUrl') { const nextVideoUrl = value as string; const resolvedDuration = getDuration(nextVideoUrl); - onUpdateElement({ + updateSelectedElement({ transitionVideoUrl: nextVideoUrl, transitionDurationSec: resolvedDuration || undefined, }); } else if (prop === 'targetPageSlug') { - onUpdateElement({ + updateSelectedElement({ targetPageSlug: value as string, targetPageId: '', }); } else { - onUpdateElement({ + updateSelectedElement({ [prop]: value, }); } @@ -444,177 +360,174 @@ export function ElementEditorPanel({ /> )} - {selectedElement && - isTooltipElementType(selectedElement.type) && ( - - onUpdateElement({ [prop]: value }) - } - /> - )} + {/* Tooltip Settings */} + {isTooltipElementType(selectedElement.type) && ( + + updateSelectedElement({ [prop]: value }) + } + /> + )} - {selectedElement && - isDescriptionElementType(selectedElement.type) && ( - - onUpdateElement({ [prop]: value }) - } - /> - )} + {/* Description Settings */} + {isDescriptionElementType(selectedElement.type) && ( + + updateSelectedElement({ [prop]: value }) + } + /> + )} - {selectedElement && - isMediaElementType(selectedElement.type) && ( - - onUpdateElement({ [prop]: value }) - } - /> - )} + {/* Media Settings */} + {isMediaElementType(selectedElement.type) && ( + + updateSelectedElement({ [prop]: value }) + } + /> + )} - {selectedElement && - isGalleryElementType(selectedElement.type) && ( - <> - onUpdateElement(patch)} - onAddInfoSpan={galleryInfoSpans.add} - onUpdateInfoSpan={galleryInfoSpans.update} - onRemoveInfoSpan={galleryInfoSpans.remove} - onAddCard={galleryCards.add} - onUpdateCard={galleryCards.update} - onRemoveCard={galleryCards.remove} - /> - - - )} - - {selectedElement && - isCarouselElementType(selectedElement.type) && ( - + updateSelectedElement(patch)} + onAddInfoSpan={galleryInfoSpans.add} + onUpdateInfoSpan={galleryInfoSpans.update} + onRemoveInfoSpan={galleryInfoSpans.remove} + onAddCard={galleryCards.add} + onUpdateCard={galleryCards.update} + onRemoveCard={galleryCards.remove} /> - )} + + + )} + + {/* Carousel Settings */} + {isCarouselElementType(selectedElement.type) && ( + + )} )} @@ -659,7 +572,7 @@ export function ElementEditorPanel({ selectedElement.galleryHeaderTextAlign || 'center', }} onChange={(prop, value) => - onUpdateElement({ [prop]: value || undefined }) + updateSelectedElement({ [prop]: value || undefined }) } showFont showDimensions @@ -689,7 +602,7 @@ export function ElementEditorPanel({ selectedElement.galleryTitleTextAlign || 'center', }} onChange={(prop, value) => - onUpdateElement({ [prop]: value || undefined }) + updateSelectedElement({ [prop]: value || undefined }) } showFont showTextAlign @@ -723,7 +636,7 @@ export function ElementEditorPanel({ selectedElement.gallerySpanTextAlign || 'center', }} onChange={(prop, value) => - onUpdateElement({ [prop]: value || undefined }) + updateSelectedElement({ [prop]: value || undefined }) } showFont showGap @@ -762,7 +675,7 @@ export function ElementEditorPanel({ selectedElement.galleryCardMinHeight || '', }} onChange={(prop, value) => - onUpdateElement({ [prop]: value || undefined }) + updateSelectedElement({ [prop]: value || undefined }) } showGap showColumns @@ -774,43 +687,41 @@ export function ElementEditorPanel({

)} - - )} - {activeTab === 'css' && ( - - handleCssPropertyChange(prop, value, onUpdateElement) - } - /> + + handleCssPropertyChange(prop, value, updateSelectedElement) + } + /> + )} {/* Effects Tab */} @@ -840,7 +751,7 @@ export function ElementEditorPanel({ selectedElement.activeBackgroundColor || '', }} onChange={(prop, value) => { - onUpdateElement({ + updateSelectedElement({ [prop]: value || undefined, }); }} diff --git a/frontend/src/components/Constructor/types.ts b/frontend/src/components/Constructor/types.ts index 45eeccb..61cf66a 100644 --- a/frontend/src/components/Constructor/types.ts +++ b/frontend/src/components/Constructor/types.ts @@ -12,28 +12,21 @@ import type { GalleryCard, CarouselSlide, NavigationButtonKind, + PageBackgroundVideoSettings, + EditorMenuItem, + EditorTab, } from '../../types/constructor'; +/** + * Partial video settings for update callbacks + */ +export type VideoPlaybackSettings = Partial; + /** * Constructor interaction mode */ export type ConstructorInteractionMode = 'edit' | 'interact'; -/** - * Editor menu item types - */ -export type EditorMenuItem = - | 'none' - | 'background_image' - | 'background_video' - | 'background_audio' - | 'create_transition'; - -/** - * Editor tab types - */ -export type ElementEditorTab = 'general' | 'css' | 'effects'; - /** * Tour page type */ @@ -173,17 +166,6 @@ export interface ElementEditorHeaderProps { onDragStart: (event: React.MouseEvent) => void; } -/** - * Video playback settings for background video - */ -export interface VideoPlaybackSettings { - autoplay?: boolean; - loop?: boolean; - muted?: boolean; - startTime?: number | null; - endTime?: number | null; -} - /** * Background settings editor props */ @@ -225,7 +207,7 @@ export interface CreateTransitionFormProps { export interface ElementEditorPanelProps { position: Position; isCollapsed: boolean; - activeTab: ElementEditorTab; + activeTab: EditorTab; selectedElement: CanvasElement | null; selectedMenuItem: EditorMenuItem; @@ -273,7 +255,7 @@ export interface ElementEditorPanelProps { onPositionChange: (position: Position) => void; onDragStart: (event: React.MouseEvent) => void; onToggleCollapse: () => void; - onTabChange: (tab: ElementEditorTab) => void; + onTabChange: (tab: EditorTab) => void; onRemoveElement: () => void; onUpdateElement: (patch: Partial) => void; onUpdateBackgroundImage: (url: string) => void; diff --git a/frontend/src/components/Factory/createTableComponent.tsx b/frontend/src/components/Factory/createTableComponent.tsx index 8b8f46b..c6f561a 100644 --- a/frontend/src/components/Factory/createTableComponent.tsx +++ b/frontend/src/components/Factory/createTableComponent.tsx @@ -11,20 +11,9 @@ import type { RootState } from '../../stores/store'; import type { Filter, FilterItem } from '../../types/filters'; import type { GridColDef } from '@mui/x-data-grid'; import type { AsyncThunk } from '@reduxjs/toolkit'; -import type { NotificationState } from '../../types/redux'; +import type { EntitySliceState } from '../../types/redux'; import type { BaseEntity } from '../../types/entities'; -/** - * Entity slice state shape - matches InternalSliceState from createEntitySlice - */ -interface EntitySliceState { - [key: string]: T[] | boolean | number | NotificationState | unknown[]; - loading: boolean; - count: number; - refetch: boolean; - notify: NotificationState; -} - /** * Configuration for creating a table component */ diff --git a/frontend/src/components/FormFilePicker.tsx b/frontend/src/components/FormFilePicker.tsx index 7be6c35..b7d84ee 100644 --- a/frontend/src/components/FormFilePicker.tsx +++ b/frontend/src/components/FormFilePicker.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; -import { ColorButtonKey } from '../interfaces'; +import { ColorButtonKey } from '../types/ui'; import BaseButton from './BaseButton'; import FileUploader from './Uploaders/UploadService'; import { mdiReload } from '@mdi/js'; import { useAppSelector } from '../stores/hooks'; +import type { FieldInputProps, FormikProps } from 'formik'; +import type { ImageFile } from '../types/entities'; type Props = { label?: string; @@ -13,8 +15,8 @@ type Props = { isRoundIcon?: boolean; path: string; schema: object; - field: any; - form: any; + field: FieldInputProps; + form: FormikProps>; }; const FormFilePicker = ({ diff --git a/frontend/src/components/FormImagePicker.tsx b/frontend/src/components/FormImagePicker.tsx index d3efb77..860956d 100644 --- a/frontend/src/components/FormImagePicker.tsx +++ b/frontend/src/components/FormImagePicker.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react'; -import { ColorButtonKey } from '../interfaces'; +import { ColorButtonKey } from '../types/ui'; import BaseButton from './BaseButton'; import FileUploader from './Uploaders/UploadService'; import { mdiReload } from '@mdi/js'; import { useAppSelector } from '../stores/hooks'; +import type { FieldInputProps, FormikProps } from 'formik'; +import type { ImageFile } from '../types/entities'; type Props = { label?: string; @@ -13,8 +15,8 @@ type Props = { isRoundIcon?: boolean; path: string; schema: object; - field: any; - form: any; + field: FieldInputProps; + form: FormikProps>; }; const FormImagePicker = ({ diff --git a/frontend/src/components/Generic/GenericTable.tsx b/frontend/src/components/Generic/GenericTable.tsx index 9c72c73..9fb98dd 100644 --- a/frontend/src/components/Generic/GenericTable.tsx +++ b/frontend/src/components/Generic/GenericTable.tsx @@ -26,19 +26,10 @@ import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import { Field, Form, Formik } from 'formik'; import type { RootState } from '../../stores/store'; import type { Filter, FilterItem, FilterFields } from '../../types/filters'; -import type { NotificationState } from '../../types/redux'; +import type { NotificationState, EntitySliceState } from '../../types/redux'; import type { AsyncThunk } from '@reduxjs/toolkit'; import type { BaseEntity } from '../../types/entities'; -// Matches InternalSliceState from createEntitySlice -interface EntitySliceState { - [key: string]: T[] | boolean | number | NotificationState | unknown[]; - loading: boolean; - count: number; - refetch: boolean; - notify: NotificationState; -} - // Props interface for GenericTable interface GenericTableProps { entityName: string; @@ -81,8 +72,8 @@ function GenericTable({ const { currentUser } = useAppSelector((state) => state.auth); const entityState = useAppSelector(sliceSelector); - // Extract state values - rows are stored under the entity name key - const rows = (entityState[entityName] as T[]) || []; + // Extract state values - rows are stored under the 'data' key + const rows = entityState.data || []; const { count, loading, notify, refetch } = entityState; // Style selectors diff --git a/frontend/src/components/IconRounded.tsx b/frontend/src/components/IconRounded.tsx index 7ec5864..e3dfc93 100644 --- a/frontend/src/components/IconRounded.tsx +++ b/frontend/src/components/IconRounded.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ColorKey } from '../interfaces'; +import { ColorKey } from '../types/ui'; import { colorsBgLight, colorsText } from '../colors'; import BaseIcon from './BaseIcon'; diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index aa08751..914e40c 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -4,7 +4,7 @@ import { containerMaxW } from '../config'; import BaseIcon from './BaseIcon'; import NavBarItemPlain from './NavBarItemPlain'; import NavBarMenuList from './NavBarMenuList'; -import { MenuNavBarItem } from '../interfaces'; +import { MenuNavBarItem } from '../types/menu'; import { useAppSelector } from '../stores/hooks'; type Props = { diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 4afd076..047619a 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -6,7 +6,7 @@ import BaseIcon from './BaseIcon'; import UserAvatarCurrentUser from './UserAvatarCurrentUser'; import NavBarMenuList from './NavBarMenuList'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; -import { MenuNavBarItem } from '../interfaces'; +import { MenuNavBarItem } from '../types/menu'; import { setDarkMode } from '../stores/styleSlice'; import { logoutUser } from '../stores/authSlice'; import { useRouter } from 'next/router'; diff --git a/frontend/src/components/NavBarMenuList.tsx b/frontend/src/components/NavBarMenuList.tsx index 1e8493c..fc59453 100644 --- a/frontend/src/components/NavBarMenuList.tsx +++ b/frontend/src/components/NavBarMenuList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MenuNavBarItem } from '../interfaces'; +import { MenuNavBarItem } from '../types/menu'; import NavBarItem from './NavBarItem'; type Props = { diff --git a/frontend/src/components/NotificationBar.tsx b/frontend/src/components/NotificationBar.tsx index e91f880..4a839f0 100644 --- a/frontend/src/components/NotificationBar.tsx +++ b/frontend/src/components/NotificationBar.tsx @@ -1,6 +1,6 @@ import { mdiClose } from '@mdi/js'; import React, { ReactNode, useState } from 'react'; -import { ColorKey } from '../interfaces'; +import { ColorKey } from '../types/ui'; import { colorsBgLight, colorsOutline } from '../colors'; import BaseButton from './BaseButton'; import BaseIcon from './BaseIcon'; diff --git a/frontend/src/components/Offline/OfflineToggle.tsx b/frontend/src/components/Offline/OfflineToggle.tsx index 1e3c1f3..aae175e 100644 --- a/frontend/src/components/Offline/OfflineToggle.tsx +++ b/frontend/src/components/Offline/OfflineToggle.tsx @@ -5,7 +5,7 @@ * Shows download status, size estimate, and provides download/delete actions. */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { mdiCloudDownload, mdiCloudCheck, @@ -13,9 +13,11 @@ import { mdiDelete, } from '@mdi/js'; import Icon from '@mdi/react'; +import { toast } from 'react-toastify'; import BaseButton from '../BaseButton'; import { useOfflineMode } from '../../hooks/useOfflineMode'; import { useStorageQuota } from '../../hooks/useStorageQuota'; +import type { ProjectOfflineStatus } from '../../types/offline'; interface OfflineToggleProps { projectId: string | null; @@ -55,6 +57,26 @@ export function OfflineToggle({ const { canStore, isWarning, isCritical } = useStorageQuota(); + // Track previous status to detect completion transition + const prevStatusRef = useRef(status); + + // Show toast notification when download completes + useEffect(() => { + console.log('[OfflineToggle] Status changed:', { + prev: prevStatusRef.current, + current: status, + }); + // Only show toast when transitioning FROM downloading TO downloaded + if (prevStatusRef.current === 'downloading' && status === 'downloaded') { + console.log('[OfflineToggle] Showing toast - download complete!'); + toast.success('Presentation ready for offline mode!', { + position: 'bottom-center', + autoClose: 5000, + }); + } + prevStatusRef.current = status; + }, [status]); + // Don't render if offline not supported if (!isOfflineCapable) { return null; @@ -69,7 +91,8 @@ export function OfflineToggle({ } else if (isDownloading) { pauseDownload(); } else if (status === 'error') { - resumeDownload(); + // Retry by starting fresh download + startDownload(); } else { // Check storage before starting if (isCritical) { diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index 8203969..a542ca6 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -10,7 +10,7 @@ import BaseIcon from './BaseIcon'; type Props = { currentPage: number; numPages: number; - setCurrentPage: any; + setCurrentPage: (page: number) => void; }; export const Pagination = ({ diff --git a/frontend/src/components/Permissions/CardPermissions.tsx b/frontend/src/components/Permissions/CardPermissions.tsx index b0bd673..1f7c917 100644 --- a/frontend/src/components/Permissions/CardPermissions.tsx +++ b/frontend/src/components/Permissions/CardPermissions.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PermissionEntity } from '../../types/entities'; type Props = { - permissions: any[]; + permissions: PermissionEntity[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Permissions/ListPermissions.tsx b/frontend/src/components/Permissions/ListPermissions.tsx index b74b0a0..9281b7b 100644 --- a/frontend/src/components/Permissions/ListPermissions.tsx +++ b/frontend/src/components/Permissions/ListPermissions.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PermissionEntity } from '../../types/entities'; type Props = { - permissions: any[]; + permissions: PermissionEntity[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Presigned_url_requests/CardPresigned_url_requests.tsx b/frontend/src/components/Presigned_url_requests/CardPresigned_url_requests.tsx index 55af3e8..a8ff56a 100644 --- a/frontend/src/components/Presigned_url_requests/CardPresigned_url_requests.tsx +++ b/frontend/src/components/Presigned_url_requests/CardPresigned_url_requests.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PresignedUrlRequest } from '../../types/entities'; type Props = { - presigned_url_requests: any[]; + presigned_url_requests: PresignedUrlRequest[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Presigned_url_requests/ListPresigned_url_requests.tsx b/frontend/src/components/Presigned_url_requests/ListPresigned_url_requests.tsx index 6db827c..7d44775 100644 --- a/frontend/src/components/Presigned_url_requests/ListPresigned_url_requests.tsx +++ b/frontend/src/components/Presigned_url_requests/ListPresigned_url_requests.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PresignedUrlRequest } from '../../types/entities'; type Props = { - presigned_url_requests: any[]; + presigned_url_requests: PresignedUrlRequest[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Project_audio_tracks/CardProject_audio_tracks.tsx b/frontend/src/components/Project_audio_tracks/CardProject_audio_tracks.tsx index fcdc743..4a87d48 100644 --- a/frontend/src/components/Project_audio_tracks/CardProject_audio_tracks.tsx +++ b/frontend/src/components/Project_audio_tracks/CardProject_audio_tracks.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { ProjectAudioTrack } from '../../types/entities'; type Props = { - project_audio_tracks: any[]; + project_audio_tracks: ProjectAudioTrack[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Project_audio_tracks/ListProject_audio_tracks.tsx b/frontend/src/components/Project_audio_tracks/ListProject_audio_tracks.tsx index 7eaa3bc..72d0a9d 100644 --- a/frontend/src/components/Project_audio_tracks/ListProject_audio_tracks.tsx +++ b/frontend/src/components/Project_audio_tracks/ListProject_audio_tracks.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { ProjectAudioTrack } from '../../types/entities'; type Props = { - project_audio_tracks: any[]; + project_audio_tracks: ProjectAudioTrack[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Project_memberships/CardProject_memberships.tsx b/frontend/src/components/Project_memberships/CardProject_memberships.tsx index 977d030..488c292 100644 --- a/frontend/src/components/Project_memberships/CardProject_memberships.tsx +++ b/frontend/src/components/Project_memberships/CardProject_memberships.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { ProjectMembership } from '../../types/entities'; type Props = { - project_memberships: any[]; + project_memberships: ProjectMembership[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Project_memberships/ListProject_memberships.tsx b/frontend/src/components/Project_memberships/ListProject_memberships.tsx index 1855219..26205f2 100644 --- a/frontend/src/components/Project_memberships/ListProject_memberships.tsx +++ b/frontend/src/components/Project_memberships/ListProject_memberships.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { ProjectMembership } from '../../types/entities'; type Props = { - project_memberships: any[]; + project_memberships: ProjectMembership[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Projects/CardProjects.tsx b/frontend/src/components/Projects/CardProjects.tsx index b4b50f1..c292b49 100644 --- a/frontend/src/components/Projects/CardProjects.tsx +++ b/frontend/src/components/Projects/CardProjects.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Project } from '../../types/entities'; type Props = { - projects: any[]; + projects: Project[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Projects/ListProjects.tsx b/frontend/src/components/Projects/ListProjects.tsx index 7b72cf6..414d9ed 100644 --- a/frontend/src/components/Projects/ListProjects.tsx +++ b/frontend/src/components/Projects/ListProjects.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Project } from '../../types/entities'; type Props = { - projects: any[]; + projects: Project[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Publish_events/CardPublish_events.tsx b/frontend/src/components/Publish_events/CardPublish_events.tsx index c391d3a..26c1897 100644 --- a/frontend/src/components/Publish_events/CardPublish_events.tsx +++ b/frontend/src/components/Publish_events/CardPublish_events.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PublishEvent } from '../../types/entities'; type Props = { - publish_events: any[]; + publish_events: PublishEvent[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Publish_events/ListPublish_events.tsx b/frontend/src/components/Publish_events/ListPublish_events.tsx index a47e42b..ad0609e 100644 --- a/frontend/src/components/Publish_events/ListPublish_events.tsx +++ b/frontend/src/components/Publish_events/ListPublish_events.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PublishEvent } from '../../types/entities'; type Props = { - publish_events: any[]; + publish_events: PublishEvent[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Pwa_caches/CardPwa_caches.tsx b/frontend/src/components/Pwa_caches/CardPwa_caches.tsx index 70ecdef..a5b935e 100644 --- a/frontend/src/components/Pwa_caches/CardPwa_caches.tsx +++ b/frontend/src/components/Pwa_caches/CardPwa_caches.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PwaCache } from '../../types/entities'; type Props = { - pwa_caches: any[]; + pwa_caches: PwaCache[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Pwa_caches/ListPwa_caches.tsx b/frontend/src/components/Pwa_caches/ListPwa_caches.tsx index 6f0bbfc..bfe91f7 100644 --- a/frontend/src/components/Pwa_caches/ListPwa_caches.tsx +++ b/frontend/src/components/Pwa_caches/ListPwa_caches.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { PwaCache } from '../../types/entities'; type Props = { - pwa_caches: any[]; + pwa_caches: PwaCache[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Roles/CardRoles.tsx b/frontend/src/components/Roles/CardRoles.tsx index 7c24852..4121245 100644 --- a/frontend/src/components/Roles/CardRoles.tsx +++ b/frontend/src/components/Roles/CardRoles.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Role } from '../../types/entities'; type Props = { - roles: any[]; + roles: Role[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Roles/ListRoles.tsx b/frontend/src/components/Roles/ListRoles.tsx index e4299d9..17c74f4 100644 --- a/frontend/src/components/Roles/ListRoles.tsx +++ b/frontend/src/components/Roles/ListRoles.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { Role } from '../../types/entities'; type Props = { - roles: any[]; + roles: Role[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index 0bcb576..783e063 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -15,9 +15,10 @@ import { hasAnyEffects, type ElementEffectProperties, } from '../lib/elementEffects'; +import type { CanvasElement } from '../types/constructor'; interface RuntimeElementProps { - element: any; + element: CanvasElement; onClick: () => void; /** Optional URL resolver for preloaded blob URLs */ resolveUrl?: (url: string | undefined) => string; diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index c89ff49..a39d53a 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -16,6 +16,8 @@ import React, { useRef, useState, } from 'react'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; import BaseButton from './BaseButton'; import CardBox from './CardBox'; import { OfflineToggle } from './Offline/OfflineToggle'; @@ -38,6 +40,7 @@ import { isTransitionBlocking, } from '../lib/navigationHelpers'; import type { TransitionPhase } from '../types/presentation'; +import type { CanvasElement } from '../types/constructor'; interface RuntimePresentationProps { projectSlug: string; @@ -76,7 +79,7 @@ export default function RuntimePresentation({ const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false); const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ - element: any; + element: CanvasElement; initialIndex: number; } | null>(null); @@ -306,7 +309,7 @@ export default function RuntimePresentation({ ); const handleElementClick = useCallback( - (element: any) => { + (element: CanvasElement) => { // Block navigation while transition is actively playing or buffering if ( isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering) @@ -340,7 +343,7 @@ export default function RuntimePresentation({ // Handler for gallery card clicks const handleGalleryCardClick = useCallback( - (element: any, cardIndex: number) => { + (element: CanvasElement, cardIndex: number) => { if (element.galleryCards?.length > 0) { setActiveGalleryCarousel({ element, initialIndex: cardIndex }); } @@ -542,7 +545,7 @@ export default function RuntimePresentation({ {/* Page elements - z-10 ensures they appear above backdrop layer */}
- {pageElements.map((element: any) => ( + {pageElements.map((element: CanvasElement) => ( )} + + {/* Toast notifications for offline download status */} +
); diff --git a/frontend/src/components/SectionFullScreen.tsx b/frontend/src/components/SectionFullScreen.tsx index 6548862..3186d9a 100644 --- a/frontend/src/components/SectionFullScreen.tsx +++ b/frontend/src/components/SectionFullScreen.tsx @@ -1,5 +1,5 @@ import React, { ReactNode } from 'react'; -import { BgKey } from '../interfaces'; +import { BgKey } from '../types/ui'; import { gradientBgPurplePink, gradientBgDark, diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index 703d631..97814e5 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -31,7 +31,7 @@ export const SelectField = ({ setValue(option); }; - async function callApi(inputValue: string, loadedOptions: any[]) { + async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 4229e9c..13e2f43 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -38,7 +38,7 @@ export const SelectFieldMany = ({ label: data.label, }); - const handleChange = (data: any) => { + const handleChange = (data: Array<{ value: string; label: string }>) => { setValue(data); form.setFieldValue( field.name, @@ -46,7 +46,7 @@ export const SelectFieldMany = ({ ); }; - async function callApi(inputValue: string, loadedOptions: any[]) { + async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx index 656008a..54d8705 100644 --- a/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx +++ b/frontend/src/components/SmartWidget/components/AreaChart/ApexAreaChart.tsx @@ -1,12 +1,13 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; -export const ApexAreaChart = ({ widget }) => { - const dataForLineChart = (value: any[]) => { +export const ApexAreaChart = ({ widget }: ChartComponentProps) => { + const dataForLineChart = (value: ChartValueArray) => { if (!value?.length || value?.length > 10000) return [{ name: '', data: [] }]; diff --git a/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx index 5bb4cd2..4b99c21 100644 --- a/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx +++ b/frontend/src/components/SmartWidget/components/AreaChart/ChartJSAreaChart.tsx @@ -3,6 +3,7 @@ import { Line } from 'react-chartjs-2'; import chroma from 'chroma-js'; import { humanize } from '../../../../helpers/humanize'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import { Chart as ChartJS, CategoryScale, @@ -27,7 +28,7 @@ ChartJS.register( Legend, ); -export const ChartJSAreaChart = ({ widget }) => { +export const ChartJSAreaChart = ({ widget }: ChartComponentProps) => { const options = { responsive: true, maintainAspectRatio: false, @@ -47,7 +48,7 @@ export const ChartJSAreaChart = ({ widget }) => { }; const dataForBarChart = ( - value: any[], + value: ChartValueArray, chartColors: string[], ): ChartData<'line', number[], string> => { if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; diff --git a/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx index 5b3d3e7..3972fd1 100644 --- a/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx +++ b/frontend/src/components/SmartWidget/components/BarChart/ApexBarChart.tsx @@ -1,12 +1,13 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; -export const ApexBarChart = ({ widget }) => { - const dataForBarChart = (value: any[]) => { +export const ApexBarChart = ({ widget }: ChartComponentProps) => { + const dataForBarChart = (value: ChartValueArray) => { if (!value?.length || value?.length > 10000) return [{ name: '', data: [] }]; diff --git a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx index a4ec6e4..95a0f74 100644 --- a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx +++ b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx @@ -14,6 +14,7 @@ import { } from 'chart.js'; import chroma from 'chroma-js'; import { logger } from '../../../../lib/logger'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; ChartJS.register( CategoryScale, @@ -24,8 +25,8 @@ ChartJS.register( Legend, ); -export const ChartJSBarChart = ({ widget }) => { - logger.debug('ChartJSBarChart widget:', widget); +export const ChartJSBarChart = ({ widget }: ChartComponentProps) => { + logger.debug('ChartJSBarChart widget:', { ...widget }); const options = () => { return { responsive: true, @@ -43,7 +44,7 @@ export const ChartJSBarChart = ({ widget }) => { }; const dataForBarChart = ( - value: any[], + value: ChartValueArray, chartColors: string[], ): ChartData<'bar', number[], string> => { if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; diff --git a/frontend/src/components/SmartWidget/components/FunnelChart.tsx b/frontend/src/components/SmartWidget/components/FunnelChart.tsx index 3d91280..e64ae51 100644 --- a/frontend/src/components/SmartWidget/components/FunnelChart.tsx +++ b/frontend/src/components/SmartWidget/components/FunnelChart.tsx @@ -1,12 +1,13 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../helpers/humanize'; +import type { ChartComponentProps, ChartValueArray } from '../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; -export const FunnelChart = ({ widget }) => { - const dataForBarChart = (value: any[]) => { +export const FunnelChart = ({ widget }: ChartComponentProps) => { + const dataForBarChart = (value: ChartValueArray) => { if (!value?.length || value?.length > 10000) return [{ name: '', data: [] }]; const valueKey = Object.keys(value[0])[1]; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx index aa77a76..1063bb3 100644 --- a/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx +++ b/frontend/src/components/SmartWidget/components/LineChart/ApexLineChart.tsx @@ -1,12 +1,13 @@ import React from 'react'; import dynamic from 'next/dynamic'; import { humanize } from '../../../../helpers/humanize'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; -export const ApexLineChart = ({ widget }) => { - const dataForLineChart = (value: any[]) => { +export const ApexLineChart = ({ widget }: ChartComponentProps) => { + const dataForLineChart = (value: ChartValueArray) => { if (!value?.length || value?.length > 10000) return [{ name: '', data: [] }]; diff --git a/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx index a2fe5ba..e50b5a0 100644 --- a/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx +++ b/frontend/src/components/SmartWidget/components/LineChart/ChartJSLineChart.tsx @@ -3,7 +3,6 @@ import { humanize } from '../../../../helpers/humanize'; import { Line } from 'react-chartjs-2'; import chroma from 'chroma-js'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; -import { Widget } from '../../models/widget.model'; import { Chart, LineElement, @@ -14,6 +13,7 @@ import { Tooltip, ChartData, } from 'chart.js'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; Chart.register( LineElement, @@ -24,11 +24,7 @@ Chart.register( Tooltip, ); -interface Props { - widget: Widget; -} - -export const ChartJSLineChart = (props: Props) => { +export const ChartJSLineChart = (props: ChartComponentProps) => { const options = { responsive: true, maintainAspectRatio: false, @@ -48,7 +44,7 @@ export const ChartJSLineChart = (props: Props) => { }; const dataForBarChart = ( - value: any[], + value: ChartValueArray, chartColors: string[], ): ChartData<'line', number[], string> => { if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; diff --git a/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx index dfe5572..609eb45 100644 --- a/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx +++ b/frontend/src/components/SmartWidget/components/PieChart/ApexPieChart.tsx @@ -1,12 +1,13 @@ import React from 'react'; import dynamic from 'next/dynamic'; import chroma from 'chroma-js'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; const Chart = dynamic(() => import('react-apexcharts'), { ssr: false }); type ValueType = { [key: string]: string | number }[]; -export const ApexPieChart = ({ widget }) => { - const optionsForPieChart = (value: ValueType, chartColor: string) => { +export const ApexPieChart = ({ widget }: ChartComponentProps) => { + const optionsForPieChart = (value: ValueType, chartColor?: string | string[]) => { const chartColors = Array.isArray(chartColor) ? chartColor : [chartColor || '#3751FF']; @@ -80,13 +81,14 @@ export const ApexPieChart = ({ widget }) => { labels: categories, }; }; - const dataForPieChart = (value: any[]) => { + const dataForPieChart = (value: ChartValueArray) => { if (!value?.length || value?.length > 10000) return [{ name: '', data: [] }]; + const secondKeyValue = value[0][Object.keys(value[0])[1]]; if ( - !isNaN(parseFloat(value[0][Object.keys(value[0])[1]])) && - isFinite(value[0][Object.keys(value[0])[1]]) + !isNaN(parseFloat(String(secondKeyValue))) && + isFinite(Number(secondKeyValue)) ) { return value.map((el) => +el[Object.keys(value[0])[1]]).reverse(); } diff --git a/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx index 2a20155..ee9a613 100644 --- a/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx +++ b/frontend/src/components/SmartWidget/components/PieChart/ChartJSPieChart.tsx @@ -3,6 +3,7 @@ import { humanize } from '../../../../helpers/humanize'; import { Pie } from 'react-chartjs-2'; import chroma from 'chroma-js'; import { collectOtherData, findFirstNumericKey } from '../../widgetHelpers'; +import type { ChartComponentProps, ChartValueArray } from '../../../../types/charts'; import { Chart as ChartJS, @@ -14,7 +15,7 @@ import { ChartJS.register(ArcElement, Tooltip, Legend); -export const ChartJSPieChart = ({ widget }) => { +export const ChartJSPieChart = ({ widget }: ChartComponentProps) => { const options = () => { return { responsive: true, @@ -32,7 +33,7 @@ export const ChartJSPieChart = ({ widget }) => { }; const dataForBarChart = ( - value: any[], + value: ChartValueArray, chartColors: string[], ): ChartData<'pie', number[], string> => { if (!value?.length) return { labels: [''], datasets: [{ data: [] }] }; diff --git a/frontend/src/components/SmartWidget/models/widget.model.ts b/frontend/src/components/SmartWidget/models/widget.model.ts index 362d65c..d26b4b7 100644 --- a/frontend/src/components/SmartWidget/models/widget.model.ts +++ b/frontend/src/components/SmartWidget/models/widget.model.ts @@ -1,3 +1,5 @@ +import type { ChartValueArray } from '../../../types/charts'; + export enum WidgetLibName { apex = 'apex', chartjs = 'chartjs', @@ -26,9 +28,9 @@ export interface Widget { label: string; id: string; lib?: WidgetLibName; - value: any[]; + value: ChartValueArray; chartColors: string[]; - options?: any; + options?: Record; prompt: string; color: string; color_array: string[]; diff --git a/frontend/src/components/SmartWidget/widgetHelpers.tsx b/frontend/src/components/SmartWidget/widgetHelpers.tsx index 73f6d91..0bb6400 100644 --- a/frontend/src/components/SmartWidget/widgetHelpers.tsx +++ b/frontend/src/components/SmartWidget/widgetHelpers.tsx @@ -1,11 +1,8 @@ import { humanize } from '../../helpers/humanize'; - -interface DataObject { - [key: string]: any; -} +import type { ChartDataPoint } from '../../types/charts'; export const findFirstNumericKey = ( - obj: Record, + obj: ChartDataPoint, ): string | undefined => { for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { @@ -28,11 +25,11 @@ export const findFirstNumericKey = ( }; export const collectOtherData = ( - obj: DataObject, + obj: ChartDataPoint, excludeKey: string, ): string => { return Object.entries(obj) .filter(([key, _]) => key !== excludeKey) - .map(([_, value]) => humanize(value)) + .map(([_, value]) => humanize(String(value))) .join(' / '); }; diff --git a/frontend/src/components/SwitchField.tsx b/frontend/src/components/SwitchField.tsx index 324a287..478fb4f 100644 --- a/frontend/src/components/SwitchField.tsx +++ b/frontend/src/components/SwitchField.tsx @@ -2,8 +2,8 @@ import React, { useEffect, useId, useState } from 'react'; import Switch from 'react-switch'; export const SwitchField = ({ field, form, disabled }) => { - const handleChange = (data: any) => { - form.setFieldValue(field.name, data); + const handleChange = (checked: boolean) => { + form.setFieldValue(field.name, checked); }; return ( diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index 07da268..7d82de2 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -139,10 +139,11 @@ const TourFlowManager = () => { setPages(getRows(pagesResponse)); setTransitions([]); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; setErrorMessage( - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to load pages and transitions.', ); logger.error( @@ -364,10 +365,11 @@ const TourFlowManager = () => { setIsCreatePageModalActive(false); setNewPageSlug(''); await loadData(); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to create page.'; setErrorMessage(message); setNewPageSlugError(message); @@ -400,10 +402,11 @@ const TourFlowManager = () => { await axios.delete(`/tour_pages/${id}`); setPages((prev) => prev.filter((item) => item.id !== id)); } - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; setErrorMessage( - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to delete item.', ); logger.error( @@ -537,9 +540,10 @@ const TourFlowManager = () => { return (
  • - +
  • ); })} diff --git a/frontend/src/components/Tour_pages/CardTour_pages.tsx b/frontend/src/components/Tour_pages/CardTour_pages.tsx index 4aa9dd6..64c178b 100644 --- a/frontend/src/components/Tour_pages/CardTour_pages.tsx +++ b/frontend/src/components/Tour_pages/CardTour_pages.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { TourPage } from '../../types/entities'; type Props = { - tour_pages: any[]; + tour_pages: TourPage[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Tour_pages/ListTour_pages.tsx b/frontend/src/components/Tour_pages/ListTour_pages.tsx index 742baac..ce8e085 100644 --- a/frontend/src/components/Tour_pages/ListTour_pages.tsx +++ b/frontend/src/components/Tour_pages/ListTour_pages.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { TourPage } from '../../types/entities'; type Props = { - tour_pages: any[]; + tour_pages: TourPage[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/UiElements/elements/AudioPlayerElement.tsx b/frontend/src/components/UiElements/elements/AudioPlayerElement.tsx index 95b7696..6aa5da3 100644 --- a/frontend/src/components/UiElements/elements/AudioPlayerElement.tsx +++ b/frontend/src/components/UiElements/elements/AudioPlayerElement.tsx @@ -41,6 +41,7 @@ const AudioPlayerElement: React.FC = ({ controls autoPlay={Boolean(element.mediaAutoplay)} loop={Boolean(element.mediaLoop)} + muted={Boolean(element.mediaMuted)} /> ); diff --git a/frontend/src/components/UserAvatarCurrentUser.tsx b/frontend/src/components/UserAvatarCurrentUser.tsx index 1bcf833..6bc95d9 100644 --- a/frontend/src/components/UserAvatarCurrentUser.tsx +++ b/frontend/src/components/UserAvatarCurrentUser.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { useAppSelector } from '../stores/hooks'; import UserAvatar from './UserAvatar'; @@ -11,36 +11,38 @@ export default function UserAvatarCurrentUser({ className = '', children, }: Props) { - const userName = useAppSelector((state) => state.main.userName); - const userAvatar = useAppSelector((state) => state.main.userAvatar); - const { currentUser, isFetching, token } = useAppSelector( - (state) => state.auth, - ); - const { users, loading } = useAppSelector((state) => state.users); + // Get user data from authSlice.currentUser (single source of truth) + const { currentUser } = useAppSelector((state) => state.auth); - const [avatar, setAvatar] = useState(null); + // Derive display values from currentUser + const userName = useMemo(() => { + if (!currentUser) return ''; + const firstName = currentUser.firstName || ''; + const lastName = currentUser.lastName || ''; + return `${firstName} ${lastName}`.trim() || currentUser.email || ''; + }, [currentUser]); - useEffect(() => { - currentUserAvatarCheck(); - }, []); - - useEffect(() => { - currentUserAvatarCheck(); - }, [currentUser?.id, users]); - - const currentUserAvatarCheck = () => { - if (currentUser?.id) { - const image = currentUser?.avatar; - setAvatar(image); + const userAvatar = useMemo(() => { + if (!currentUser?.avatar) return null; + // avatar can be an array with publicUrl or a direct URL string + if (Array.isArray(currentUser.avatar) && currentUser.avatar[0]?.publicUrl) { + return currentUser.avatar[0].publicUrl; } - }; + if (typeof currentUser.avatar === 'string') { + return currentUser.avatar; + } + return null; + }, [currentUser]); + + // Convert string avatar to array format expected by UserAvatar.image prop + const imageArray = userAvatar ? [{ publicUrl: userAvatar }] : null; return ( {children} diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx index 15fc7e5..a109f23 100644 --- a/frontend/src/components/UserCard.tsx +++ b/frontend/src/components/UserCard.tsx @@ -1,5 +1,6 @@ import { mdiCheckDecagram } from '@mdi/js'; import { Field, Form, Formik } from 'formik'; +import { useMemo } from 'react'; import { useAppSelector } from '../stores/hooks'; import CardBox from './CardBox'; import FormCheckRadio from './FormCheckRadio'; @@ -10,7 +11,15 @@ type Props = { }; const UserCard = ({ className }: Props) => { - const userName = useAppSelector((state) => state.main.userName); + // Get user data from authSlice.currentUser (single source of truth) + const { currentUser } = useAppSelector((state) => state.auth); + + const userName = useMemo(() => { + if (!currentUser) return ''; + const firstName = currentUser.firstName || ''; + const lastName = currentUser.lastName || ''; + return `${firstName} ${lastName}`.trim() || currentUser.email || ''; + }, [currentUser]); return ( @@ -34,10 +43,6 @@ const UserCard = ({ className }: Props) => {

    Howdy, {userName}!

    -

    - Last login 12 mins ago from 127.0.0.1 -

    -
    Verified
    diff --git a/frontend/src/components/Users/CardUsers.tsx b/frontend/src/components/Users/CardUsers.tsx index f2a4fb2..b07a63e 100644 --- a/frontend/src/components/Users/CardUsers.tsx +++ b/frontend/src/components/Users/CardUsers.tsx @@ -9,9 +9,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { User } from '../../types/entities'; type Props = { - users: any[]; + users: User[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/Users/ListUsers.tsx b/frontend/src/components/Users/ListUsers.tsx index 61fe76d..68c6465 100644 --- a/frontend/src/components/Users/ListUsers.tsx +++ b/frontend/src/components/Users/ListUsers.tsx @@ -10,9 +10,10 @@ import LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; import { hasPermission } from '../../helpers/userPermissions'; +import type { User } from '../../types/entities'; type Props = { - users: any[]; + users: User[]; loading: boolean; onDelete: (id: string) => void; currentPage: number; diff --git a/frontend/src/components/WidgetCreator/RoleSelect.tsx b/frontend/src/components/WidgetCreator/RoleSelect.tsx index 4767538..94704d7 100644 --- a/frontend/src/components/WidgetCreator/RoleSelect.tsx +++ b/frontend/src/components/WidgetCreator/RoleSelect.tsx @@ -37,7 +37,7 @@ export const RoleSelect = ({ setValue(option); }; - async function callApi(inputValue: string, loadedOptions: any[]) { + async function callApi(inputValue: string, loadedOptions: Array<{ value: string; label: string }>) { const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const { data } = await axios(path); return { diff --git a/frontend/src/components/WidgetCreator/WidgetCreator.tsx b/frontend/src/components/WidgetCreator/WidgetCreator.tsx index 0ac67c9..265897c 100644 --- a/frontend/src/components/WidgetCreator/WidgetCreator.tsx +++ b/frontend/src/components/WidgetCreator/WidgetCreator.tsx @@ -50,7 +50,7 @@ export const WidgetCreator = ({ const smartSearch = async ( values: { description: string }, - resetForm: any, + resetForm: (nextState?: { values: { description: string } }) => void, ) => { const description = values.description; const projectId = ''; @@ -61,9 +61,9 @@ export const WidgetCreator = ({ projectId, userId: currentUser?.id, }; - const { payload: responcePayload, error }: any = await dispatch( - aiPrompt(payload), - ); + const result = await dispatch(aiPrompt(payload)); + const responcePayload = result.payload as { data?: { error?: { message?: string } } } | undefined; + const error = 'error' in result ? result.error as { message?: string } | undefined : undefined; await getWidgets().then(); diff --git a/frontend/src/config/offline.config.ts b/frontend/src/config/offline.config.ts index 698bcb6..a818ab6 100644 --- a/frontend/src/config/offline.config.ts +++ b/frontend/src/config/offline.config.ts @@ -25,6 +25,7 @@ export const OFFLINE_CONFIG = { projectDownloadProgress: 'project-download-progress', projectDownloadComplete: 'project-download-complete', queueUpdate: 'queue-update', + blobUrlReady: 'blob-url-ready', }, // Service worker settings diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts index e91e4a6..560ad30 100644 --- a/frontend/src/config/preload.config.ts +++ b/frontend/src/config/preload.config.ts @@ -70,6 +70,10 @@ export const PRELOAD_CONFIG = { 'reverseVideoUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', + 'galleryHeaderImageUrl', + 'galleryCarouselPrevIconUrl', + 'galleryCarouselNextIconUrl', + 'galleryCarouselBackIconUrl', 'src', 'url', 'poster', @@ -82,12 +86,16 @@ export const PRELOAD_CONFIG = { 'backgroundImageUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', + 'galleryHeaderImageUrl', + 'galleryCarouselPrevIconUrl', + 'galleryCarouselNextIconUrl', + 'galleryCarouselBackIconUrl', 'src', ] as const, // Nested array fields containing assets - nested: ['galleryCards', 'carouselSlides'] as const, + nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const, // Fields within nested items that contain URLs - nestedUrlFields: ['imageUrl', 'videoUrl'] as const, + nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const, }, } as const; diff --git a/frontend/src/context/ConstructorContext.tsx b/frontend/src/context/ConstructorContext.tsx new file mode 100644 index 0000000..30a4e6d --- /dev/null +++ b/frontend/src/context/ConstructorContext.tsx @@ -0,0 +1,422 @@ +/** + * Constructor Context + * + * Provides centralized state management for the tour constructor page. + * This reduces prop drilling by making constructor state available to + * deeply nested components. + * + * Components can access constructor state via: + * - useConstructorContext() - throws if used outside provider + * - useConstructorContextOptional() - returns null if outside provider + */ + +import React, { + createContext, + useContext, + useMemo, + type ReactNode, +} from 'react'; +import type { + CanvasElement, + CanvasElementType, + PageBackgroundState, + PageBackgroundVideoSettings, + EditorMenuItem, + EditorTab, + GalleryCard, + GalleryInfoSpan, + CarouselSlide, + AssetOption, +} from '../types/constructor'; +import type { TourPage, Asset } from '../types/entities'; + +// ============================================================================ +// Navigation Types +// ============================================================================ + +export type NavigationElementType = Extract< + CanvasElementType, + 'navigation_next' | 'navigation_prev' +>; + +// ============================================================================ +// Gallery/Carousel Operations +// ============================================================================ + +export interface GalleryCardOperations { + add: () => void; + update: (cardId: string, patch: Partial) => void; + remove: (cardId: string) => void; +} + +export interface GalleryInfoSpanOperations { + add: () => void; + update: (spanId: string, patch: Partial) => void; + remove: (spanId: string) => void; +} + +export interface CarouselSlideOperations { + add: () => void; + update: (slideId: string, patch: Partial) => void; + remove: (slideId: string) => void; +} + +// ============================================================================ +// Transition Creation State +// ============================================================================ + +export interface TransitionCreationState { + name: string; + videoUrl: string; + supportsReverse: boolean; + isCreating: boolean; +} + +export interface TransitionCreationActions { + setName: (name: string) => void; + setVideoUrl: (url: string) => void; + setSupportsReverse: (value: boolean) => void; + create: () => void; +} + +// ============================================================================ +// Context Types +// ============================================================================ + +export interface ConstructorContextValue { + // Project state + projectId: string; + + // Page state + pages: TourPage[]; + activePageId: string | null; + activePage: TourPage | null; + setActivePageId: (id: string) => void; + + // Background state (consolidated from 8 useState hooks) + pageBackground: PageBackgroundState; + setPageBackground: React.Dispatch>; + updateBackgroundFromPage: (page: TourPage | null) => void; + + // Background convenience setters + setBackgroundImageUrl: (url: string) => void; + setBackgroundVideoUrl: (url: string) => void; + setBackgroundAudioUrl: (url: string) => void; + setBackgroundVideoSettings: (settings: Partial) => void; + + // Element state + elements: CanvasElement[]; + setElements: React.Dispatch>; + selectedElementId: string | null; + selectedElement: CanvasElement | null; + selectElement: (id: string) => void; + clearSelection: () => void; + updateElement: (id: string, patch: Partial) => void; + removeElement: (id: string) => void; + updateSelectedElement: (patch: Partial) => void; + removeSelectedElement: () => void; + + // Menu state + selectedMenuItem: EditorMenuItem; + setSelectedMenuItem: (item: EditorMenuItem) => void; + isMenuOpen: boolean; + setIsMenuOpen: (open: boolean) => void; + + // Editor state + elementEditorTab: EditorTab; + setElementEditorTab: (tab: EditorTab) => void; + + // Assets (cached via React Query) + assets: Asset[]; + isLoadingAssets: boolean; + + // Asset options (derived from assets) + assetOptions: { + image: AssetOption[]; + backgroundImage: AssetOption[]; + video: AssetOption[]; + audio: AssetOption[]; + transitionVideo: AssetOption[]; + icon: AssetOption[]; + }; + + // Gallery/Carousel operations + galleryCards: GalleryCardOperations; + galleryInfoSpans: GalleryInfoSpanOperations; + carouselSlides: CarouselSlideOperations; + + // Duration resolver + getDuration: (url: string) => number | undefined; + + // Duration notes (derived from getDuration) + durationNotes: { + backgroundVideo: string; + backgroundAudio: string; + selectedMedia: string; + selectedTransition: string; + newTransition: string; + }; + + // Transition preview + onPreviewTransition: (direction: 'forward' | 'back') => void; + + // Transition creation + transitionCreation: TransitionCreationState & TransitionCreationActions; + + // Navigation settings + allowedNavigationTypes: NavigationElementType[]; + normalizeNavigationType: ( + element: CanvasElement, + nextType: NavigationElementType, + ) => CanvasElement; + + // Actions + save: () => Promise; + isSaving: boolean; +} + +// ============================================================================ +// Context Creation +// ============================================================================ + +const ConstructorContext = createContext(null); + +// ============================================================================ +// Provider Props +// ============================================================================ + +export interface ConstructorProviderProps { + children: ReactNode; + value: ConstructorContextValue; +} + +// ============================================================================ +// Provider Component +// ============================================================================ + +export function ConstructorProvider({ + children, + value, +}: ConstructorProviderProps) { + return ( + + {children} + + ); +} + +// ============================================================================ +// Hooks +// ============================================================================ + +/** + * Access constructor context (throws if used outside provider) + */ +export function useConstructorContext(): ConstructorContextValue { + const context = useContext(ConstructorContext); + if (!context) { + throw new Error( + 'useConstructorContext must be used within a ConstructorProvider', + ); + } + return context; +} + +/** + * Access constructor context (returns null if outside provider) + */ +export function useConstructorContextOptional(): ConstructorContextValue | null { + return useContext(ConstructorContext); +} + +// ============================================================================ +// Selector Hooks (for performance optimization) +// ============================================================================ + +/** + * Select only pages-related state to minimize re-renders + */ +export function useConstructorPages() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + pages: ctx.pages, + activePageId: ctx.activePageId, + activePage: ctx.activePage, + setActivePageId: ctx.setActivePageId, + }), + [ctx.pages, ctx.activePageId, ctx.activePage, ctx.setActivePageId], + ); +} + +/** + * Select only elements-related state + */ +export function useConstructorElements() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + elements: ctx.elements, + setElements: ctx.setElements, + selectedElementId: ctx.selectedElementId, + selectedElement: ctx.selectedElement, + selectElement: ctx.selectElement, + clearSelection: ctx.clearSelection, + updateElement: ctx.updateElement, + removeElement: ctx.removeElement, + updateSelectedElement: ctx.updateSelectedElement, + removeSelectedElement: ctx.removeSelectedElement, + }), + [ + ctx.elements, + ctx.setElements, + ctx.selectedElementId, + ctx.selectedElement, + ctx.selectElement, + ctx.clearSelection, + ctx.updateElement, + ctx.removeElement, + ctx.updateSelectedElement, + ctx.removeSelectedElement, + ], + ); +} + +/** + * Select only background-related state + */ +export function useConstructorBackground() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + pageBackground: ctx.pageBackground, + setPageBackground: ctx.setPageBackground, + updateBackgroundFromPage: ctx.updateBackgroundFromPage, + setBackgroundImageUrl: ctx.setBackgroundImageUrl, + setBackgroundVideoUrl: ctx.setBackgroundVideoUrl, + setBackgroundAudioUrl: ctx.setBackgroundAudioUrl, + setBackgroundVideoSettings: ctx.setBackgroundVideoSettings, + }), + [ + ctx.pageBackground, + ctx.setPageBackground, + ctx.updateBackgroundFromPage, + ctx.setBackgroundImageUrl, + ctx.setBackgroundVideoUrl, + ctx.setBackgroundAudioUrl, + ctx.setBackgroundVideoSettings, + ], + ); +} + +/** + * Select only assets-related state + */ +export function useConstructorAssets() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + assets: ctx.assets, + isLoadingAssets: ctx.isLoadingAssets, + assetOptions: ctx.assetOptions, + }), + [ctx.assets, ctx.isLoadingAssets, ctx.assetOptions], + ); +} + +/** + * Select gallery/carousel operations + */ +export function useConstructorCollectionOps() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + galleryCards: ctx.galleryCards, + galleryInfoSpans: ctx.galleryInfoSpans, + carouselSlides: ctx.carouselSlides, + }), + [ctx.galleryCards, ctx.galleryInfoSpans, ctx.carouselSlides], + ); +} + +/** + * Select duration utilities + */ +export function useConstructorDuration() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + getDuration: ctx.getDuration, + durationNotes: ctx.durationNotes, + }), + [ctx.getDuration, ctx.durationNotes], + ); +} + +/** + * Select transition creation state + */ +export function useConstructorTransitionCreation() { + const ctx = useConstructorContext(); + return ctx.transitionCreation; +} + +/** + * Select navigation settings + */ +export function useConstructorNavigation() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + pages: ctx.pages, + activePageId: ctx.activePageId, + allowedNavigationTypes: ctx.allowedNavigationTypes, + normalizeNavigationType: ctx.normalizeNavigationType, + onPreviewTransition: ctx.onPreviewTransition, + }), + [ + ctx.pages, + ctx.activePageId, + ctx.allowedNavigationTypes, + ctx.normalizeNavigationType, + ctx.onPreviewTransition, + ], + ); +} + +/** + * Select editor menu state + */ +export function useConstructorMenu() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + selectedMenuItem: ctx.selectedMenuItem, + setSelectedMenuItem: ctx.setSelectedMenuItem, + isMenuOpen: ctx.isMenuOpen, + setIsMenuOpen: ctx.setIsMenuOpen, + }), + [ + ctx.selectedMenuItem, + ctx.setSelectedMenuItem, + ctx.isMenuOpen, + ctx.setIsMenuOpen, + ], + ); +} + +/** + * Select editor tab state + */ +export function useConstructorEditorTab() { + const ctx = useConstructorContext(); + return useMemo( + () => ({ + activeTab: ctx.elementEditorTab, + setActiveTab: ctx.setElementEditorTab, + }), + [ctx.elementEditorTab, ctx.setElementEditorTab], + ); +} + +export default ConstructorContext; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index fee371d..9f18507 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -36,6 +36,21 @@ export type { UsePageDataLoaderOptions, UsePageDataLoaderResult, } from './usePageDataLoader'; +export { usePageBackground } from './usePageBackground'; +export type { + UsePageBackgroundOptions, + UsePageBackgroundResult, +} from './usePageBackground'; +export { useConstructorData } from './useConstructorData'; +export { useAssetOptions } from './useAssetOptions'; +export type { AssetOptionsResult, UseAssetOptionsOptions } from './useAssetOptions'; +export { useTransitionCreation } from './useTransitionCreation'; +export type { + TransitionCreationState, + TransitionCreationActions, + UseTransitionCreationOptions, + UseTransitionCreationResult, +} from './useTransitionCreation'; // Constructor hooks - import directly for better tree-shaking: // import { useOutsideClick } from '../hooks/useOutsideClick'; diff --git a/frontend/src/hooks/queries/index.ts b/frontend/src/hooks/queries/index.ts new file mode 100644 index 0000000..9a171da --- /dev/null +++ b/frontend/src/hooks/queries/index.ts @@ -0,0 +1,19 @@ +/** + * Query Hooks + * + * Centralized exports for React Query hooks. + */ + +export { useProjectsQuery, useProjectQuery, useUpdateProjectMutation } from './useProjectQuery'; +export { usePagesQuery, usePageQuery, useUpdatePageMutation, useCreatePageMutation, useDeletePageMutation } from './usePagesQuery'; +export { useAssetsQuery, useAssetQuery, useUpdateAssetMutation, useDeleteAssetMutation } from './useAssetsQuery'; +export { useElementDefaultsQuery } from './useElementDefaultsQuery'; +export { useUsersQuery, useCurrentUserQuery, useUserQuery, useUpdateUserMutation, useCreateUserMutation, useDeleteUserMutation } from './useUsersQuery'; +export { useRolesQuery, useRoleQuery, useUpdateRoleMutation, useCreateRoleMutation, useDeleteRoleMutation } from './useRolesQuery'; +export { usePermissionsQuery } from './usePermissionsQuery'; +export { useAccessLogsQuery } from './useAccessLogsQuery'; +export { useAssetVariantsQuery } from './useAssetVariantsQuery'; +export { useProjectMembershipsQuery, useCreateProjectMembershipMutation, useDeleteProjectMembershipMutation } from './useProjectMembershipsQuery'; +export { useProjectAudioTracksQuery, useProjectAudioTrackQuery, useCreateProjectAudioTrackMutation, useDeleteProjectAudioTrackMutation } from './useProjectAudioTracksQuery'; +export { usePublishEventsQuery } from './usePublishEventsQuery'; +export { usePwaCachesQuery, useDeletePwaCacheMutation } from './usePwaCachesQuery'; diff --git a/frontend/src/hooks/queries/useAccessLogsQuery.ts b/frontend/src/hooks/queries/useAccessLogsQuery.ts new file mode 100644 index 0000000..5ab5fa0 --- /dev/null +++ b/frontend/src/hooks/queries/useAccessLogsQuery.ts @@ -0,0 +1,49 @@ +/** + * Access Logs Query Hooks + * + * React Query hooks for fetching access log data. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface AccessLog { + id: string; + user_email: string; + action: string; + entity_type: string; + entity_id: string; + ip_address: string; + createdAt: string; +} + +interface AccessLogListParams { + limit?: number; + offset?: number; +} + +interface AccessLogListResponse { + rows: AccessLog[]; + count: number; +} + +/** + * Fetch list of access logs + */ +export function useAccessLogsQuery(params?: AccessLogListParams) { + const query = params + ? `?limit=${params.limit || 100}&offset=${params.offset || 0}` + : ''; + + return useQuery({ + queryKey: queryKeys.accessLogs.list(params), + queryFn: async (): Promise => { + const response = await axios.get(`access_logs${query}`); + return response.data.rows; + }, + staleTime: 1 * 60 * 1000, // Access logs change frequently + }); +} + +export default useAccessLogsQuery; diff --git a/frontend/src/hooks/queries/useAssetVariantsQuery.ts b/frontend/src/hooks/queries/useAssetVariantsQuery.ts new file mode 100644 index 0000000..2a15c86 --- /dev/null +++ b/frontend/src/hooks/queries/useAssetVariantsQuery.ts @@ -0,0 +1,43 @@ +/** + * Asset Variants Query Hooks + * + * React Query hooks for fetching asset variant data. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface AssetVariant { + id: string; + variant_type: string; + cdn_url: string; + width_px: number; + height_px: number; + size_mb: number; + assetId: string; +} + +interface AssetVariantListResponse { + rows: AssetVariant[]; + count: number; +} + +/** + * Fetch list of variants for an asset + */ +export function useAssetVariantsQuery(assetId: string | undefined) { + return useQuery({ + queryKey: queryKeys.assetVariants.list(assetId || ''), + queryFn: async (): Promise => { + const response = await axios.get( + `asset_variants?assetId=${assetId}` + ); + return response.data.rows; + }, + enabled: !!assetId, + staleTime: 5 * 60 * 1000, + }); +} + +export default useAssetVariantsQuery; diff --git a/frontend/src/hooks/queries/useAssetsQuery.ts b/frontend/src/hooks/queries/useAssetsQuery.ts new file mode 100644 index 0000000..bad419b --- /dev/null +++ b/frontend/src/hooks/queries/useAssetsQuery.ts @@ -0,0 +1,109 @@ +/** + * Assets Query Hooks + * + * React Query hooks for fetching and caching asset data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { Asset } from '../../types/entities'; + +interface AssetsListResponse { + rows: Asset[]; + count: number; +} + +interface AssetFilters { + limit?: number; + offset?: number; + type?: string; +} + +/** + * Fetch assets for a project + */ +export function useAssetsQuery( + projectId: string | undefined, + filters?: AssetFilters, +) { + const params = new URLSearchParams(); + if (projectId) params.set('projectId', projectId); + if (filters?.limit) params.set('limit', String(filters.limit)); + if (filters?.offset) params.set('offset', String(filters.offset)); + if (filters?.type) params.set('type', filters.type); + + const queryString = params.toString(); + + return useQuery({ + queryKey: queryKeys.assets.list(projectId || '', filters), + queryFn: async (): Promise => { + const response = await axios.get( + `assets?${queryString}`, + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Fetch single asset by ID + */ +export function useAssetQuery(assetId: string | undefined) { + return useQuery({ + queryKey: queryKeys.assets.detail(assetId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`assets/${assetId}`); + return response.data; + }, + enabled: !!assetId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Update asset mutation + */ +export function useUpdateAssetMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Partial; + }): Promise => { + const response = await axios.put(`assets/${id}`, { + id, + data, + }); + return response.data; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(queryKeys.assets.detail(variables.id), data); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + }, + }); +} + +/** + * Delete asset mutation + */ +export function useDeleteAssetMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (assetId: string): Promise => { + await axios.delete(`assets/${assetId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + }, + }); +} + +export default useAssetsQuery; diff --git a/frontend/src/hooks/queries/useElementDefaultsQuery.ts b/frontend/src/hooks/queries/useElementDefaultsQuery.ts new file mode 100644 index 0000000..59e9831 --- /dev/null +++ b/frontend/src/hooks/queries/useElementDefaultsQuery.ts @@ -0,0 +1,52 @@ +/** + * Element Defaults Query Hooks + * + * React Query hooks for fetching project element defaults. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { + CanvasElementType, + CanvasElement, + NormalizedElementDefault, +} from '../../types/constructor'; +import { + normalizeElementDefault, + buildElementDefaultsMap, +} from '../../types/constructor'; + +interface ElementDefaultsResponse { + rows: Record[]; + count: number; +} + +/** + * Fetch project element defaults and transform to a type-indexed map + */ +export function useElementDefaultsQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.elementDefaults.project(projectId || ''), + queryFn: async (): Promise< + Partial>> + > => { + const response = await axios.get( + `project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`, + ); + + // Process and normalize the defaults + const normalizedDefaults = response.data.rows + .map((row) => normalizeElementDefault(row)) + .filter( + (d): d is NormalizedElementDefault => d !== null, + ); + + return buildElementDefaultsMap(normalizedDefaults); + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +export default useElementDefaultsQuery; diff --git a/frontend/src/hooks/queries/usePagesQuery.ts b/frontend/src/hooks/queries/usePagesQuery.ts new file mode 100644 index 0000000..cdc7d63 --- /dev/null +++ b/frontend/src/hooks/queries/usePagesQuery.ts @@ -0,0 +1,118 @@ +/** + * Tour Pages Query Hooks + * + * React Query hooks for fetching and caching tour page data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { TourPage } from '../../types/entities'; + +interface PagesListResponse { + rows: TourPage[]; + count: number; +} + +/** + * Fetch tour pages for a project + */ +export function usePagesQuery( + projectId: string | undefined, + environment = 'dev', +) { + return useQuery({ + queryKey: queryKeys.tourPages.list(projectId || '', environment), + queryFn: async (): Promise => { + const response = await axios.get( + `tour_pages?projectId=${projectId}&environment=${environment}&limit=500`, + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 2 * 60 * 1000, // 2 minutes (pages change more frequently) + }); +} + +/** + * Fetch single tour page by ID + */ +export function usePageQuery(pageId: string | undefined) { + return useQuery({ + queryKey: queryKeys.tourPages.detail(pageId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`tour_pages/${pageId}`); + return response.data; + }, + enabled: !!pageId, + staleTime: 2 * 60 * 1000, + }); +} + +/** + * Update tour page mutation + */ +export function useUpdatePageMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Partial; + }): Promise => { + const response = await axios.put(`tour_pages/${id}`, { + id, + data, + }); + return response.data; + }, + onSuccess: (data, variables) => { + // Update the single page cache + queryClient.setQueryData( + queryKeys.tourPages.detail(variables.id), + data, + ); + // Invalidate list queries + queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); + }, + }); +} + +/** + * Create tour page mutation + */ +export function useCreatePageMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial): Promise => { + const response = await axios.post('tour_pages', { data }); + return response.data; + }, + onSuccess: () => { + // Invalidate all page queries to refetch + queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); + }, + }); +} + +/** + * Delete tour page mutation + */ +export function useDeletePageMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (pageId: string): Promise => { + await axios.delete(`tour_pages/${pageId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); + }, + }); +} + +export default usePagesQuery; diff --git a/frontend/src/hooks/queries/usePermissionsQuery.ts b/frontend/src/hooks/queries/usePermissionsQuery.ts new file mode 100644 index 0000000..4f0d40f --- /dev/null +++ b/frontend/src/hooks/queries/usePermissionsQuery.ts @@ -0,0 +1,35 @@ +/** + * Permissions Query Hooks + * + * React Query hooks for fetching permission data. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface Permission { + id: string; + name: string; +} + +interface PermissionListResponse { + rows: Permission[]; + count: number; +} + +/** + * Fetch list of permissions + */ +export function usePermissionsQuery() { + return useQuery({ + queryKey: queryKeys.permissions.list(), + queryFn: async (): Promise => { + const response = await axios.get('permissions'); + return response.data.rows; + }, + staleTime: 30 * 60 * 1000, // Permissions rarely change + }); +} + +export default usePermissionsQuery; diff --git a/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts b/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts new file mode 100644 index 0000000..64b93f9 --- /dev/null +++ b/frontend/src/hooks/queries/useProjectAudioTracksQuery.ts @@ -0,0 +1,102 @@ +/** + * Project Audio Tracks Query Hooks + * + * React Query hooks for fetching project audio track data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface ProjectAudioTrack { + id: string; + projectId: string; + name: string; + cdn_url: string; + storage_key: string; + duration_sec: number; + environment: 'dev' | 'stage' | 'production'; + order_index: number; +} + +interface ProjectAudioTrackListResponse { + rows: ProjectAudioTrack[]; + count: number; +} + +/** + * Fetch list of audio tracks for a project + */ +export function useProjectAudioTracksQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.projectAudioTracks.list(projectId || ''), + queryFn: async (): Promise => { + const response = await axios.get( + `project_audio_tracks?projectId=${projectId}` + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Fetch single audio track by ID + */ +export function useProjectAudioTrackQuery(trackId: string | undefined) { + return useQuery({ + queryKey: queryKeys.projectAudioTracks.detail(trackId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`project_audio_tracks/${trackId}`); + return response.data; + }, + enabled: !!trackId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Create project audio track mutation + */ +export function useCreateProjectAudioTrackMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial): Promise => { + const response = await axios.post('project_audio_tracks', { data }); + return response.data; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.projectAudioTracks.list(variables.projectId || ''), + }); + }, + }); +} + +/** + * Delete project audio track mutation + */ +export function useDeleteProjectAudioTrackMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + projectId, + }: { + id: string; + projectId: string; + }): Promise => { + await axios.delete(`project_audio_tracks/${id}`); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.projectAudioTracks.list(projectId), + }); + }, + }); +} + +export default useProjectAudioTracksQuery; diff --git a/frontend/src/hooks/queries/useProjectMembershipsQuery.ts b/frontend/src/hooks/queries/useProjectMembershipsQuery.ts new file mode 100644 index 0000000..5c27525 --- /dev/null +++ b/frontend/src/hooks/queries/useProjectMembershipsQuery.ts @@ -0,0 +1,89 @@ +/** + * Project Memberships Query Hooks + * + * React Query hooks for fetching project membership data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface ProjectMembership { + id: string; + projectId: string; + userId: string; + role: string; + user?: { + id: string; + firstName: string; + lastName: string; + email: string; + }; +} + +interface ProjectMembershipListResponse { + rows: ProjectMembership[]; + count: number; +} + +/** + * Fetch list of memberships for a project + */ +export function useProjectMembershipsQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.projectMemberships.list(projectId || ''), + queryFn: async (): Promise => { + const response = await axios.get( + `project_memberships?projectId=${projectId}` + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Create project membership mutation + */ +export function useCreateProjectMembershipMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial): Promise => { + const response = await axios.post('project_memberships', { data }); + return response.data; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.projectMemberships.list(variables.projectId || ''), + }); + }, + }); +} + +/** + * Delete project membership mutation + */ +export function useDeleteProjectMembershipMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + projectId, + }: { + id: string; + projectId: string; + }): Promise => { + await axios.delete(`project_memberships/${id}`); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.projectMemberships.list(projectId), + }); + }, + }); +} + +export default useProjectMembershipsQuery; diff --git a/frontend/src/hooks/queries/useProjectQuery.ts b/frontend/src/hooks/queries/useProjectQuery.ts new file mode 100644 index 0000000..96696ad --- /dev/null +++ b/frontend/src/hooks/queries/useProjectQuery.ts @@ -0,0 +1,87 @@ +/** + * Project Query Hooks + * + * React Query hooks for fetching and caching project data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { Project } from '../../types/entities'; + +interface ProjectListParams { + limit?: number; + offset?: number; +} + +interface ProjectListResponse { + rows: Project[]; + count: number; +} + +/** + * Fetch list of projects + */ +export function useProjectsQuery(params?: ProjectListParams) { + const query = params + ? `?limit=${params.limit || 100}&offset=${params.offset || 0}` + : ''; + + return useQuery({ + queryKey: queryKeys.projects.list(params), + queryFn: async (): Promise => { + const response = await axios.get(`projects${query}`); + return response.data.rows; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Fetch single project by ID + */ +export function useProjectQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.projects.detail(projectId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`projects/${projectId}`); + return response.data; + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Update project mutation + */ +export function useUpdateProjectMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Partial; + }): Promise => { + const response = await axios.put(`projects/${id}`, { + id, + data, + }); + return response.data; + }, + onSuccess: (data, variables) => { + // Update the cache with the new data + queryClient.setQueryData( + queryKeys.projects.detail(variables.id), + data, + ); + // Invalidate list queries to refetch + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); + }, + }); +} + +export default useProjectQuery; diff --git a/frontend/src/hooks/queries/usePublishEventsQuery.ts b/frontend/src/hooks/queries/usePublishEventsQuery.ts new file mode 100644 index 0000000..6c31a64 --- /dev/null +++ b/frontend/src/hooks/queries/usePublishEventsQuery.ts @@ -0,0 +1,56 @@ +/** + * Publish Events Query Hooks + * + * React Query hooks for fetching publish event data. + */ + +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface PublishEvent { + id: string; + projectId: string; + userId: string; + title: string; + description: string; + from_environment: 'dev' | 'stage'; + to_environment: 'stage' | 'production'; + status: 'queued' | 'running' | 'success' | 'failed'; + pages_copied: number; + audios_copied: number; + started_at: string | null; + finished_at: string | null; + error_message: string | null; + createdAt: string; + user?: { + id: string; + firstName: string; + lastName: string; + email: string; + }; +} + +interface PublishEventListResponse { + rows: PublishEvent[]; + count: number; +} + +/** + * Fetch list of publish events for a project + */ +export function usePublishEventsQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.publishEvents.list(projectId || ''), + queryFn: async (): Promise => { + const response = await axios.get( + `publish_events?projectId=${projectId}&limit=50` + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 1 * 60 * 1000, // Publish events can change quickly + }); +} + +export default usePublishEventsQuery; diff --git a/frontend/src/hooks/queries/usePwaCachesQuery.ts b/frontend/src/hooks/queries/usePwaCachesQuery.ts new file mode 100644 index 0000000..40e23f6 --- /dev/null +++ b/frontend/src/hooks/queries/usePwaCachesQuery.ts @@ -0,0 +1,67 @@ +/** + * PWA Caches Query Hooks + * + * React Query hooks for fetching PWA cache data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; + +interface PwaCache { + id: string; + projectId: string; + asset_url: string; + asset_type: string; + size_bytes: number; + cached_at: string; + expires_at: string | null; +} + +interface PwaCacheListResponse { + rows: PwaCache[]; + count: number; +} + +/** + * Fetch list of PWA caches for a project + */ +export function usePwaCachesQuery(projectId: string | undefined) { + return useQuery({ + queryKey: queryKeys.pwaCaches.list(projectId || ''), + queryFn: async (): Promise => { + const response = await axios.get( + `pwa_caches?projectId=${projectId}` + ); + return response.data.rows; + }, + enabled: !!projectId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Delete PWA cache entry mutation + */ +export function useDeletePwaCacheMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + projectId, + }: { + id: string; + projectId: string; + }): Promise => { + await axios.delete(`pwa_caches/${id}`); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.pwaCaches.list(projectId), + }); + }, + }); +} + +export default usePwaCachesQuery; diff --git a/frontend/src/hooks/queries/useRolesQuery.ts b/frontend/src/hooks/queries/useRolesQuery.ts new file mode 100644 index 0000000..c671316 --- /dev/null +++ b/frontend/src/hooks/queries/useRolesQuery.ts @@ -0,0 +1,112 @@ +/** + * Roles Query Hooks + * + * React Query hooks for fetching and caching role data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { Role } from '../../types/entities'; + +interface RoleListParams { + limit?: number; + offset?: number; +} + +interface RoleListResponse { + rows: Role[]; + count: number; +} + +/** + * Fetch list of roles + */ +export function useRolesQuery(params?: RoleListParams) { + const query = params + ? `?limit=${params.limit || 100}&offset=${params.offset || 0}` + : ''; + + return useQuery({ + queryKey: queryKeys.roles.list(params), + queryFn: async (): Promise => { + const response = await axios.get(`roles${query}`); + return response.data.rows; + }, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Fetch single role by ID + */ +export function useRoleQuery(roleId: string | undefined) { + return useQuery({ + queryKey: queryKeys.roles.detail(roleId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`roles/${roleId}`); + return response.data; + }, + enabled: !!roleId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Update role mutation + */ +export function useUpdateRoleMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Partial; + }): Promise => { + const response = await axios.put(`roles/${id}`, { id, data }); + return response.data; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(queryKeys.roles.detail(variables.id), data); + queryClient.invalidateQueries({ queryKey: queryKeys.roles.all }); + }, + }); +} + +/** + * Create role mutation + */ +export function useCreateRoleMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial): Promise => { + const response = await axios.post('roles', { data }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.roles.all }); + }, + }); +} + +/** + * Delete role mutation + */ +export function useDeleteRoleMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string): Promise => { + await axios.delete(`roles/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.roles.all }); + }, + }); +} + +export default useRolesQuery; diff --git a/frontend/src/hooks/queries/useUsersQuery.ts b/frontend/src/hooks/queries/useUsersQuery.ts new file mode 100644 index 0000000..7d2b4ad --- /dev/null +++ b/frontend/src/hooks/queries/useUsersQuery.ts @@ -0,0 +1,126 @@ +/** + * Users Query Hooks + * + * React Query hooks for fetching and caching user data. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { queryKeys } from '../../lib/queryClient'; +import type { User } from '../../types/entities'; + +interface UserListParams { + limit?: number; + offset?: number; +} + +interface UserListResponse { + rows: User[]; + count: number; +} + +/** + * Fetch list of users + */ +export function useUsersQuery(params?: UserListParams) { + const query = params + ? `?limit=${params.limit || 100}&offset=${params.offset || 0}` + : ''; + + return useQuery({ + queryKey: queryKeys.users.list(params), + queryFn: async (): Promise => { + const response = await axios.get(`users${query}`); + return response.data.rows; + }, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Fetch current logged-in user + */ +export function useCurrentUserQuery() { + return useQuery({ + queryKey: queryKeys.users.current(), + queryFn: async (): Promise => { + const response = await axios.get('auth/me'); + return response.data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Fetch single user by ID + */ +export function useUserQuery(userId: string | undefined) { + return useQuery({ + queryKey: queryKeys.users.detail(userId || ''), + queryFn: async (): Promise => { + const response = await axios.get(`users/${userId}`); + return response.data; + }, + enabled: !!userId, + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Update user mutation + */ +export function useUpdateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + data, + }: { + id: string; + data: Partial; + }): Promise => { + const response = await axios.put(`users/${id}`, { id, data }); + return response.data; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(queryKeys.users.detail(variables.id), data); + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + }, + }); +} + +/** + * Create user mutation + */ +export function useCreateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Partial): Promise => { + const response = await axios.post('users', { data }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + }, + }); +} + +/** + * Delete user mutation + */ +export function useDeleteUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string): Promise => { + await axios.delete(`users/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + }, + }); +} + +export default useUsersQuery; diff --git a/frontend/src/hooks/useAssetOptions.ts b/frontend/src/hooks/useAssetOptions.ts new file mode 100644 index 0000000..905fabf --- /dev/null +++ b/frontend/src/hooks/useAssetOptions.ts @@ -0,0 +1,123 @@ +/** + * useAssetOptions Hook + * + * Derives memoized asset options for select dropdowns from project assets. + * This hook is used by ConstructorContext to provide pre-computed asset options. + */ + +import { useMemo } from 'react'; +import type { ConstructorAsset as ProjectAsset, AssetOption } from '../types/constructor'; +import { + buildAssetOptions, + buildBackgroundImageOptions, + buildVideoAssetOptions, + buildAudioAssetOptions, + buildTransitionVideoOptions, + buildIconAssetOptions, + buildImageAssetOptions, + getAssetSourceValue, +} from '../lib/constructorHelpers'; + +export interface AssetOptionsResult { + /** All image assets (for galleries, carousels) */ + image: AssetOption[]; + /** Background image assets only */ + backgroundImage: AssetOption[]; + /** All video assets (except transitions) */ + video: AssetOption[]; + /** All audio assets */ + audio: AssetOption[]; + /** Transition video assets */ + transitionVideo: AssetOption[]; + /** Icon image assets */ + icon: AssetOption[]; +} + +export interface UseAssetOptionsOptions { + /** Project assets to derive options from */ + assets: ProjectAsset[]; +} + +/** + * Hook to derive memoized asset options from project assets. + * + * @example + * ```typescript + * const { image, video, audio, icon, transitionVideo } = useAssetOptions({ + * assets: projectAssets, + * }); + * + * + * ``` + */ +export function useAssetOptions({ assets }: UseAssetOptionsOptions): AssetOptionsResult { + // All image assets + const imageOptions = useMemo( + () => buildImageAssetOptions(assets), + [assets], + ); + + // Background image assets (filtered by type or naming convention) + const backgroundImageOptions = useMemo( + () => buildBackgroundImageOptions(assets), + [assets], + ); + + // Video assets (excluding transition videos) + const videoOptions = useMemo( + () => + assets + .filter( + (asset) => + asset.asset_type === 'video' && + asset.type !== 'transition' && + getAssetSourceValue(asset), + ) + .map((asset) => ({ + value: getAssetSourceValue(asset), + label: asset.name + ? `${asset.name} · ${getAssetSourceValue(asset)}` + : getAssetSourceValue(asset), + })), + [assets], + ); + + // Audio assets + const audioOptions = useMemo( + () => buildAudioAssetOptions(assets), + [assets], + ); + + // Transition video assets + const transitionVideoOptions = useMemo( + () => buildTransitionVideoOptions(assets), + [assets], + ); + + // Icon assets + const iconOptions = useMemo( + () => buildIconAssetOptions(assets), + [assets], + ); + + return useMemo( + () => ({ + image: imageOptions, + backgroundImage: backgroundImageOptions, + video: videoOptions, + audio: audioOptions, + transitionVideo: transitionVideoOptions, + icon: iconOptions, + }), + [ + imageOptions, + backgroundImageOptions, + videoOptions, + audioOptions, + transitionVideoOptions, + iconOptions, + ], + ); +} + +export default useAssetOptions; diff --git a/frontend/src/hooks/useConstructorData.ts b/frontend/src/hooks/useConstructorData.ts new file mode 100644 index 0000000..d82dec9 --- /dev/null +++ b/frontend/src/hooks/useConstructorData.ts @@ -0,0 +1,139 @@ +/** + * useConstructorData Hook + * + * Orchestrates all data fetching for the constructor page using React Query. + * Replaces the manual loadData function with cached, deduplicated queries. + */ + +import { useMemo } from 'react'; +import { useProjectQuery } from './queries/useProjectQuery'; +import { usePagesQuery } from './queries/usePagesQuery'; +import { useAssetsQuery } from './queries/useAssetsQuery'; +import { useElementDefaultsQuery } from './queries/useElementDefaultsQuery'; +import { extractPageLinksAndElements } from '../lib/extractPageLinks'; +import type { TourPage, Asset } from '../types/entities'; +import type { CanvasElementType, CanvasElement } from '../types/constructor'; +import type { PreloadPageLink, PreloadElement } from '../types/preload'; + +interface UseConstructorDataParams { + projectId: string | undefined; + isAuthReady: boolean; +} + +// Stable empty references to prevent infinite loops from identity changes +const EMPTY_ELEMENT_DEFAULTS: Partial>> = {}; +const EMPTY_PAGES: TourPage[] = []; +const EMPTY_ASSETS: Asset[] = []; + +interface UseConstructorDataResult { + // Project + project: { name: string } | null; + projectName: string; + + // Pages + pages: TourPage[]; + pageLinks: PreloadPageLink[]; + allPagesPreloadElements: PreloadElement[]; + + // Assets + assets: Asset[]; + + // Element Defaults + uiElementDefaultsByType: Partial>>; + + // Loading state + isLoading: boolean; + isError: boolean; + error: Error | null; + + // Refetch function + refetch: () => Promise; +} + +export function useConstructorData({ + projectId, + isAuthReady, +}: UseConstructorDataParams): UseConstructorDataResult { + // Enable queries only when we have projectId and auth is ready + const enabled = Boolean(projectId) && isAuthReady; + + // Fetch project + const projectQuery = useProjectQuery(enabled ? projectId : undefined); + + // Fetch pages (dev environment for constructor) + const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev'); + + // Fetch assets + const assetsQuery = useAssetsQuery(enabled ? projectId : undefined, { limit: 500 }); + + // Fetch element defaults + const elementDefaultsQuery = useElementDefaultsQuery(enabled ? projectId : undefined); + + // Extract page links and preload elements from pages + const { pageLinks, allPagesPreloadElements } = useMemo(() => { + if (!pagesQuery.data || pagesQuery.data.length === 0) { + return { pageLinks: [] as PreloadPageLink[], allPagesPreloadElements: [] as PreloadElement[] }; + } + const { pageLinks: links, preloadElements: elements } = extractPageLinksAndElements( + pagesQuery.data as TourPage[], + ); + return { pageLinks: links, allPagesPreloadElements: elements }; + }, [pagesQuery.data]); + + // Combine loading states + const isLoading = + projectQuery.isLoading || + pagesQuery.isLoading || + assetsQuery.isLoading || + elementDefaultsQuery.isLoading; + + // Combine error states + const isError = + projectQuery.isError || + pagesQuery.isError || + assetsQuery.isError || + elementDefaultsQuery.isError; + + const error = + projectQuery.error || + pagesQuery.error || + assetsQuery.error || + elementDefaultsQuery.error; + + // Refetch all queries + const refetch = async () => { + await Promise.all([ + projectQuery.refetch(), + pagesQuery.refetch(), + assetsQuery.refetch(), + elementDefaultsQuery.refetch(), + ]); + }; + + return { + // Project + project: projectQuery.data || null, + projectName: projectQuery.data?.name || '', + + // Pages + pages: (pagesQuery.data as TourPage[]) || EMPTY_PAGES, + pageLinks, + allPagesPreloadElements, + + // Assets + assets: (assetsQuery.data as Asset[]) || EMPTY_ASSETS, + + // Element Defaults + uiElementDefaultsByType: elementDefaultsQuery.data || EMPTY_ELEMENT_DEFAULTS, + + // Loading state + isLoading, + isError, + error: error instanceof Error ? error : null, + + // Refetch + refetch, + }; +} + +export default useConstructorData; diff --git a/frontend/src/hooks/useConstructorPageActions.ts b/frontend/src/hooks/useConstructorPageActions.ts index 800d2a5..bac3aac 100644 --- a/frontend/src/hooks/useConstructorPageActions.ts +++ b/frontend/src/hooks/useConstructorPageActions.ts @@ -7,7 +7,7 @@ import { useState, useCallback } from 'react'; import axios from 'axios'; -import type { CanvasElement } from '../types/constructor'; +import type { CanvasElement, PageBackgroundState } from '../types/constructor'; import { createLocalId } from '../lib/elementDefaults'; import { parseJsonObject } from '../lib/parseJson'; import { logger } from '../lib/logger'; @@ -44,16 +44,8 @@ interface UseConstructorPageActionsOptions { activePageId: string; /** Current elements array */ elements: CanvasElement[]; - /** Current background URLs */ - backgroundImageUrl: string; - backgroundVideoUrl: string; - backgroundAudioUrl: string; - /** Background video playback settings */ - backgroundVideoAutoplay: boolean; - backgroundVideoLoop: boolean; - backgroundVideoMuted: boolean; - backgroundVideoStartTime: number | null; - backgroundVideoEndTime: number | null; + /** Consolidated page background state */ + pageBackground: PageBackgroundState; /** Callback to reload data after operations */ onReload: (preservePageId?: string) => Promise; /** Callback to set active page ID */ @@ -105,9 +97,7 @@ interface UseConstructorPageActionsResult { * activePage, * activePageId, * elements, - * backgroundImageUrl, - * backgroundVideoUrl, - * backgroundAudioUrl, + * pageBackground: background, * onReload: loadData, * onSetActivePageId: setActivePageId, * onError: setErrorMessage, @@ -120,20 +110,26 @@ export function useConstructorPageActions({ activePage, activePageId, elements, - backgroundImageUrl, - backgroundVideoUrl, - backgroundAudioUrl, - backgroundVideoAutoplay, - backgroundVideoLoop, - backgroundVideoMuted, - backgroundVideoStartTime, - backgroundVideoEndTime, + pageBackground, onReload, onSetActivePageId, onSetMenuOpen, onError, onSuccess, }: UseConstructorPageActionsOptions): UseConstructorPageActionsResult { + // Destructure pageBackground for backward compatibility in the save logic + const { + imageUrl: backgroundImageUrl, + videoUrl: backgroundVideoUrl, + audioUrl: backgroundAudioUrl, + videoSettings: { + autoplay: backgroundVideoAutoplay, + loop: backgroundVideoLoop, + muted: backgroundVideoMuted, + startTime: backgroundVideoStartTime, + endTime: backgroundVideoEndTime, + }, + } = pageBackground; const [isSaving, setIsSaving] = useState(false); const [isSavingToStage, setIsSavingToStage] = useState(false); const [isCreatingPage, setIsCreatingPage] = useState(false); @@ -183,10 +179,11 @@ export function useConstructorPageActions({ 'Constructor settings saved. Element positions are stored in percentages.', ); await onReload(activePageId); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to save constructor changes.'; logger.error( 'Failed to save constructor changes:', @@ -205,14 +202,7 @@ export function useConstructorPageActions({ activePage?.source_key, activePage?.ui_schema_json, activePageId, - backgroundAudioUrl, - backgroundImageUrl, - backgroundVideoUrl, - backgroundVideoAutoplay, - backgroundVideoLoop, - backgroundVideoMuted, - backgroundVideoStartTime, - backgroundVideoEndTime, + pageBackground, elements, onError, onReload, @@ -236,10 +226,11 @@ export function useConstructorPageActions({ onSuccess?.( 'Successfully saved dev content to stage environment. All pages, elements, and transitions have been copied.', ); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to save to stage.'; logger.error( 'Failed to save to stage:', @@ -291,10 +282,11 @@ export function useConstructorPageActions({ onSetMenuOpen?.(true); onSuccess?.('New page created. You can now configure it in constructor.'); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to create page.'; logger.error( 'Failed to create page from constructor:', @@ -348,10 +340,11 @@ export function useConstructorPageActions({ onSuccess?.( 'Transition video can be set directly on navigation elements.', ); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to create transition.'; logger.error( 'Failed to create transition from constructor:', diff --git a/frontend/src/hooks/useEditPageSync.ts b/frontend/src/hooks/useEditPageSync.ts index 6515716..754649f 100644 --- a/frontend/src/hooks/useEditPageSync.ts +++ b/frontend/src/hooks/useEditPageSync.ts @@ -48,7 +48,7 @@ interface UseEditPageSyncReturn { * * const EditRolesPage = () => { * const { values, id, isLoading } = useEditPageSync({ - * entitySelector: (state) => state.roles.roles, + * entitySelector: (state) => state.roles.data, * fetchAction: fetch, * initialValues: initVals, * }); @@ -151,7 +151,7 @@ export function useEditPageSync>( * * @example * const [initialValues, setInitialValues] = useEditPageSyncSimple({ - * entitySelector: (state) => state.roles.roles, + * entitySelector: (state) => state.roles.data, * fetchAction: fetch, * initialValues: initVals, * }); diff --git a/frontend/src/hooks/useEntityTable.ts b/frontend/src/hooks/useEntityTable.ts index e937afb..d4d401b 100644 --- a/frontend/src/hooks/useEntityTable.ts +++ b/frontend/src/hooks/useEntityTable.ts @@ -9,17 +9,9 @@ import type { AsyncThunk } from '@reduxjs/toolkit'; import type { GridColDef, GridSortModel } from '@mui/x-data-grid'; import type { BaseEntity } from '../types/entities'; import type { FetchParams } from '../types/api'; -import type { NotificationState } from '../types/redux'; +import type { NotificationState, EntitySliceState } from '../types/redux'; import type { Filter, FilterItem } from '../types/filters'; -interface EntitySliceState { - loading: boolean; - count: number; - refetch: boolean; - notify: NotificationState; - [entityName: string]: T[] | boolean | number | NotificationState | unknown; -} - interface UseEntityTableOptions { entityName: string; sliceSelector: (state: RootState) => EntitySliceState; diff --git a/frontend/src/hooks/useFormSync.ts b/frontend/src/hooks/useFormSync.ts index ccc4202..b15a5e9 100644 --- a/frontend/src/hooks/useFormSync.ts +++ b/frontend/src/hooks/useFormSync.ts @@ -35,7 +35,7 @@ interface UseFormSyncReturn { * @example * ```typescript * const { formValues, isLoading, entityId } = useFormSync({ - * entitySelector: (state) => state.users.users, + * entitySelector: (state) => state.users.data, * fetchAction: fetch, * initialValues: { firstName: '', lastName: '', email: '' }, * }) diff --git a/frontend/src/hooks/useMediaDurationProbe.ts b/frontend/src/hooks/useMediaDurationProbe.ts index 4c46cef..8fecbbf 100644 --- a/frontend/src/hooks/useMediaDurationProbe.ts +++ b/frontend/src/hooks/useMediaDurationProbe.ts @@ -55,6 +55,9 @@ export function useMediaDurationProbe({ >({}); const [isProbing, setIsProbing] = useState(false); const inFlightRef = useRef>(new Set()); + // Ref to access latest durationBySource without changing callback identity + const durationBySourceRef = useRef(durationBySource); + durationBySourceRef.current = durationBySource; // Probe targets for duration useEffect(() => { @@ -67,8 +70,8 @@ export function useMediaDurationProbe({ const normalizedSource = String(source || '').trim(); if (!normalizedSource) return; - // Skip if already resolved - if (durationBySource[normalizedSource] !== undefined) return; + // Skip if already resolved (use ref to avoid dependency) + if (durationBySourceRef.current[normalizedSource] !== undefined) return; // Skip if already in flight const probeKey = `${mediaType}:${normalizedSource}`; @@ -112,27 +115,26 @@ export function useMediaDurationProbe({ return () => { isCancelled = true; }; - }, [targets, durationBySource]); + }, [targets]); - const getDuration = useCallback( - (source: string): number | null => { - const normalizedSource = String(source || '').trim(); - if (!normalizedSource) return null; + const getDuration = useCallback((source: string): number | null => { + const normalizedSource = String(source || '').trim(); + if (!normalizedSource) return null; - const duration = durationBySource[normalizedSource]; - if (Number.isFinite(duration) && Number(duration) > 0) { - return Number(duration); - } + // Use ref to access latest state without changing callback identity + const duration = durationBySourceRef.current[normalizedSource]; + if (Number.isFinite(duration) && Number(duration) > 0) { + return Number(duration); + } - return null; - }, - [durationBySource], - ); + return null; + }, []); const getDurationNote = useCallback( (source: string): string => { return formatDurationNote(getDuration(source)); }, + // getDuration is now stable (uses ref internally) [getDuration], ); diff --git a/frontend/src/hooks/useOfflineMode.ts b/frontend/src/hooks/useOfflineMode.ts index d49904f..03a6f4b 100644 --- a/frontend/src/hooks/useOfflineMode.ts +++ b/frontend/src/hooks/useOfflineMode.ts @@ -4,19 +4,22 @@ * Manages offline mode state and project download functionality. */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import axios from 'axios'; import { downloadManager } from '../lib/offline/DownloadManager'; import { StorageManager } from '../lib/offline/StorageManager'; import { OfflineDbManager } from '../lib/offlineDb/OfflineDbManager'; import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { OFFLINE_CONFIG } from '../config/offline.config'; +import { extractStoragePath } from '../lib/assetUrl'; import { logger } from '../lib/logger'; import type { OfflineProject, OfflineManifest, ProjectOfflineStatus, ProjectDownloadProgressEvent, + PreloadCompleteEvent, + PreloadErrorEvent, } from '../types/offline'; interface UseOfflineModeOptions { @@ -82,6 +85,11 @@ export function useOfflineMode( const [error, setError] = useState(null); const [isPaused, setIsPaused] = useState(false); + // Track manifest for event-driven progress + const manifestRef = useRef(null); + const downloadedCountRef = useRef(0); + const downloadedBytesRef = useRef(0); + // Check if offline mode is supported const isOfflineCapable = useMemo(() => { if (typeof window === 'undefined') return false; @@ -113,7 +121,85 @@ export function useOfflineMode( loadProjectInfo(); }, [projectId, enabled]); - // Listen for progress events + // Listen for individual asset complete events for progress tracking + useEffect(() => { + if (!projectId) return; + + const handleComplete = (data: PreloadCompleteEvent) => { + // Only track if we have manifest data + if (!manifestRef.current) return; + + // Find the asset in manifest to get its size + const asset = manifestRef.current.assets.find( + (a) => a.id === data.assetId, + ); + const assetSize = asset?.sizeBytes || 0; + + // Update counters + downloadedCountRef.current += 1; + downloadedBytesRef.current += assetSize; + + const downloaded = downloadedCountRef.current; + const dlBytes = downloadedBytesRef.current; + const total = manifestRef.current.assets.length; + const totalSize = manifestRef.current.totalSizeBytes; + + // Update state + setDownloadedAssets(downloaded); + setDownloadedBytes(dlBytes); + + const prog = Math.round((downloaded / total) * 100); + setProgress(prog); + + // Emit project progress event + downloadEventBus.emitProjectProgress({ + projectId, + progress: prog, + downloadedAssets: downloaded, + totalAssets: total, + downloadedBytes: dlBytes, + totalBytes: totalSize, + }); + + // Update IndexedDB + OfflineDbManager.updateProjectProgress(projectId, downloaded, dlBytes); + + // Check if complete + if (downloaded >= total) { + setStatus('downloaded'); + OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); + downloadEventBus.emitProjectComplete({ projectId }); + logger.info('[useOfflineMode] Download complete', { projectId }); + } + }; + + const handleError = (data: PreloadErrorEvent) => { + logger.error('[useOfflineMode] Asset download error', { + assetId: data.assetId, + error: data.error, + }); + }; + + const unsubComplete = downloadEventBus.on( + OFFLINE_CONFIG.events.preloadComplete as Parameters< + typeof downloadEventBus.on + >[0], + handleComplete as Parameters[1], + ); + const unsubError = downloadEventBus.on( + OFFLINE_CONFIG.events.preloadError as Parameters< + typeof downloadEventBus.on + >[0], + handleError as Parameters[1], + ); + + return () => { + unsubComplete(); + unsubError(); + }; + }, [projectId]); + + // Also listen for legacy project progress events (for compatibility) useEffect(() => { if (!projectId) return; @@ -142,7 +228,7 @@ export function useOfflineMode( try { const response = await axios.get( - `/api/projects/${projectId}/offline-manifest`, + `/projects/${projectId}/offline-manifest`, ); return response.data; } catch (err) { @@ -169,6 +255,8 @@ export function useOfflineMode( throw new Error('Failed to fetch offline manifest'); } + // Store manifest for event-driven progress tracking + manifestRef.current = manifestData; setManifest(manifestData); setTotalAssets(manifestData.assets.length); setTotalBytes(manifestData.totalSizeBytes); @@ -194,35 +282,34 @@ export function useOfflineMode( throw new Error('Insufficient storage space'); } - // Add all assets to download queue + // Reset progress counters let downloadedCount = 0; let downloadedSize = 0; + // First, check which assets are already cached + const assetsToDownload: typeof manifestData.assets = []; for (const asset of manifestData.assets) { - // Check if already downloaded - const hasAsset = await StorageManager.hasAsset(asset.url); + // Use canonical storage key for checking + const storageKey = extractStoragePath(asset.url); + const hasAsset = await StorageManager.hasAsset(storageKey); if (hasAsset) { downloadedCount++; downloadedSize += asset.sizeBytes; - continue; + } else { + assetsToDownload.push(asset); } - - await downloadManager.addJob({ - assetId: asset.id, - projectId, - url: asset.url, - filename: asset.filename, - variantType: asset.variantType, - assetType: asset.assetType, - priority: - asset.assetType === 'image' - ? 100 - : asset.assetType === 'video' - ? 50 - : 75, - }); } + // Initialize counters for event-driven progress + downloadedCountRef.current = downloadedCount; + downloadedBytesRef.current = downloadedSize; + + logger.info('[useOfflineMode] Assets to download:', { + total: manifestData.assets.length, + alreadyCached: downloadedCount, + toDownload: assetsToDownload.length, + }); + // Update initial progress setDownloadedAssets(downloadedCount); setDownloadedBytes(downloadedSize); @@ -232,60 +319,47 @@ export function useOfflineMode( setStatus('downloaded'); setProgress(100); await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); - } else { - // Track progress - const trackProgress = async () => { - const projectAssets = - await OfflineDbManager.getProjectAssets(projectId); - const downloaded = projectAssets.length; - const dlBytes = projectAssets.reduce( - (sum, a) => sum + a.sizeBytes, - 0, - ); - - setDownloadedAssets(downloaded); - setDownloadedBytes(dlBytes); - - const prog = Math.round( - (downloaded / manifestData.assets.length) * 100, - ); - setProgress(prog); - - await OfflineDbManager.updateProjectProgress( - projectId, - downloaded, - dlBytes, - ); - - downloadEventBus.emitProjectProgress({ - projectId, - progress: prog, - downloadedAssets: downloaded, - totalAssets: manifestData.assets.length, - downloadedBytes: dlBytes, - totalBytes: manifestData.totalSizeBytes, - }); - - if (downloaded === manifestData.assets.length) { - setStatus('downloaded'); - await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); - downloadEventBus.emitProjectComplete({ projectId }); - } - }; - - // Poll for progress updates - const progressInterval = setInterval(trackProgress, 1000); - - // Store cleanup reference (could be used for unmount) - // Note: This is fire-and-forget, caller should use cancelDownload for cleanup - setTimeout( - () => { - // Auto-stop polling after 10 minutes max - clearInterval(progressInterval); - }, - 10 * 60 * 1000, - ); + logger.info('[useOfflineMode] All assets already cached', { projectId }); + return; } + + // Queue all remaining assets for parallel download + // DownloadManager handles concurrency internally + // Progress is tracked via event subscriptions (see useEffect above) + for (const asset of assetsToDownload) { + const storageKey = extractStoragePath(asset.url); + + downloadManager + .addJob({ + assetId: asset.id, + projectId, + url: asset.url, + filename: asset.filename, + variantType: asset.variantType, + assetType: asset.assetType, + priority: + asset.assetType === 'image' + ? 100 + : asset.assetType === 'video' + ? 50 + : 75, + storageKey, + createBlobUrl: true, // Create blob URL for instant display + persist: true, // Persist for resume after page refresh + }) + .catch((err) => { + // Errors handled by DownloadManager retry logic and events + logger.error('[useOfflineMode] Asset download failed', { + assetId: asset.id, + error: err?.message, + }); + }); + } + + logger.info('[useOfflineMode] Downloads queued, progress via events', { + projectId, + queued: assetsToDownload.length, + }); } catch (err) { const message = err instanceof Error ? err.message : 'Download failed'; setError(message); @@ -318,6 +392,11 @@ export function useOfflineMode( setIsPaused(false); setError(null); + // Reset refs + manifestRef.current = null; + downloadedCountRef.current = 0; + downloadedBytesRef.current = 0; + OfflineDbManager.deleteProject(projectId); setProjectInfo(null); }, [projectId]); diff --git a/frontend/src/hooks/usePageBackground.ts b/frontend/src/hooks/usePageBackground.ts new file mode 100644 index 0000000..72dfb17 --- /dev/null +++ b/frontend/src/hooks/usePageBackground.ts @@ -0,0 +1,178 @@ +/** + * usePageBackground Hook + * + * Consolidates 8 separate useState hooks for page background management into + * a single state object with convenient update functions. + * + * Replaces: + * - backgroundImageUrl, setBackgroundImageUrl + * - backgroundVideoUrl, setBackgroundVideoUrl + * - backgroundAudioUrl, setBackgroundAudioUrl + * - backgroundVideoAutoplay, setBackgroundVideoAutoplay + * - backgroundVideoLoop, setBackgroundVideoLoop + * - backgroundVideoMuted, setBackgroundVideoMuted + * - backgroundVideoStartTime, setBackgroundVideoStartTime + * - backgroundVideoEndTime, setBackgroundVideoEndTime + */ + +import { useState, useCallback } from 'react'; +import type { + PageBackgroundState, + PageBackgroundVideoSettings, +} from '../types/constructor'; +import { + DEFAULT_PAGE_BACKGROUND, + createPageBackgroundFromPage, +} from '../types/constructor'; + +interface TourPageData { + background_image_url?: string; + background_video_url?: string; + background_audio_url?: string; + background_video_autoplay?: boolean; + background_video_loop?: boolean; + background_video_muted?: boolean; + background_video_start_time?: number | null; + background_video_end_time?: number | null; +} + +export interface UsePageBackgroundOptions { + /** Initial page to derive background state from */ + initialPage?: TourPageData | null; +} + +export interface UsePageBackgroundResult { + /** Current background state */ + background: PageBackgroundState; + + /** Set entire background state */ + setBackground: React.Dispatch>; + + /** Update background from page data */ + updateFromPage: (page: TourPageData | null) => void; + + /** Update individual URL */ + setImageUrl: (url: string) => void; + setVideoUrl: (url: string) => void; + setAudioUrl: (url: string) => void; + + /** Update video settings */ + setVideoSettings: ( + settings: Partial, + ) => void; + + /** Reset to default state */ + reset: () => void; + + // Legacy compatibility: individual values for backward compatibility + // These allow gradual migration of components + backgroundImageUrl: string; + backgroundVideoUrl: string; + backgroundAudioUrl: string; + backgroundVideoAutoplay: boolean; + backgroundVideoLoop: boolean; + backgroundVideoMuted: boolean; + backgroundVideoStartTime: number | null; + backgroundVideoEndTime: number | null; +} + +/** + * Hook for managing consolidated page background state. + * + * @example + * ```typescript + * const { + * background, + * updateFromPage, + * setImageUrl, + * setVideoSettings, + * // Legacy compatibility + * backgroundImageUrl, + * backgroundVideoAutoplay, + * } = usePageBackground(); + * + * // Update from page data + * updateFromPage(activePage); + * + * // Update individual values + * setImageUrl('path/to/image.jpg'); + * setVideoSettings({ autoplay: false, loop: true }); + * ``` + */ +export function usePageBackground( + options: UsePageBackgroundOptions = {}, +): UsePageBackgroundResult { + const { initialPage } = options; + + const [background, setBackground] = useState(() => + initialPage + ? createPageBackgroundFromPage(initialPage) + : { ...DEFAULT_PAGE_BACKGROUND }, + ); + + const updateFromPage = useCallback((page: TourPageData | null) => { + setBackground(createPageBackgroundFromPage(page)); + }, []); + + const setImageUrl = useCallback((url: string) => { + setBackground((prev) => ({ + ...prev, + imageUrl: url, + })); + }, []); + + const setVideoUrl = useCallback((url: string) => { + setBackground((prev) => ({ + ...prev, + videoUrl: url, + })); + }, []); + + const setAudioUrl = useCallback((url: string) => { + setBackground((prev) => ({ + ...prev, + audioUrl: url, + })); + }, []); + + const setVideoSettings = useCallback( + (settings: Partial) => { + setBackground((prev) => ({ + ...prev, + videoSettings: { + ...prev.videoSettings, + ...settings, + }, + })); + }, + [], + ); + + const reset = useCallback(() => { + setBackground({ ...DEFAULT_PAGE_BACKGROUND }); + }, []); + + return { + // New consolidated state + background, + setBackground, + updateFromPage, + setImageUrl, + setVideoUrl, + setAudioUrl, + setVideoSettings, + reset, + + // Legacy compatibility: flat values for gradual migration + backgroundImageUrl: background.imageUrl, + backgroundVideoUrl: background.videoUrl, + backgroundAudioUrl: background.audioUrl, + backgroundVideoAutoplay: background.videoSettings.autoplay, + backgroundVideoLoop: background.videoSettings.loop, + backgroundVideoMuted: background.videoSettings.muted, + backgroundVideoStartTime: background.videoSettings.startTime, + backgroundVideoEndTime: background.videoSettings.endTime, + }; +} + +export default usePageBackground; diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index 110c4fd..9650db0 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -9,19 +9,26 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { useNeighborGraph } from './useNeighborGraph'; import { useNetworkAware } from './useNetworkAware'; import { downloadEventBus } from '../lib/offline/DownloadEventBus'; +import { downloadManager } from '../lib/offline/DownloadManager'; import { StorageManager } from '../lib/offline/StorageManager'; import { PRELOAD_CONFIG } from '../config/preload.config'; import { OFFLINE_CONFIG } from '../config/offline.config'; import { resolveAssetPlaybackUrl, + extractStoragePath, queuePresignedUrls, isRelativeStoragePath, markPresignedUrlFailed, markPresignedUrlsVerified, - getPresignedUrl, } from '../lib/assetUrl'; import { baseURLApi } from '../config'; import { logger } from '../lib/logger'; +import type { BlobUrlReadyEvent } from '../types/offline'; +import type { + PreloadPage, + PreloadPageLink, + PreloadElement, +} from '../types/preload'; /** * Check if URL is a presigned S3 URL @@ -37,11 +44,6 @@ const buildProxyUrl = (storageKey: string): string => { const normalizedPath = storageKey.replace(/^\/+/, ''); return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`; }; -import type { - PreloadPage, - PreloadPageLink, - PreloadElement, -} from '../types/preload'; interface UsePreloadOrchestratorOptions { pages: PreloadPage[]; @@ -66,6 +68,8 @@ interface UsePreloadOrchestratorResult { isPreloading: boolean; preloadedUrls: Set; queueLength: number; + /** Version counter that increments when blob URLs become ready (triggers re-renders) */ + readyUrlsVersion: number; preloadAsset: (url: string, priority?: number) => void; clearQueue: () => void; getCachedBlobUrl: (url: string) => Promise; @@ -82,155 +86,12 @@ const generateJobId = (): string => { }; /** - * Check if a URL is already cached (checks both IndexedDB and Cache API) + * Map asset type string to AssetType enum expected by DownloadManager */ -const isUrlCached = async (url: string): Promise => { - try { - // StorageManager.hasAsset checks both IndexedDB (large files) and Cache API (small files) - return StorageManager.hasAsset(url); - } catch { - return false; - } -}; - -/** - * Decode an image so it's ready to paint (no white flash) - */ -const decodeImage = async (url: string): Promise => { - return new Promise((resolve) => { - const img = new Image(); - img.src = url; - - if (typeof img.decode === 'function') { - img - .decode() - .then(() => { - logger.info('[PRELOAD] Image decoded', { url: url.slice(-50) }); - resolve(); - }) - .catch(() => resolve()); // Resolve even on error - } else { - // Fallback: wait for load event - img.onload = () => resolve(); - img.onerror = () => resolve(); - } - }); -}; - -/** - * Check if URL is an image based on extension or content type - */ -const isImageUrl = (url: string): boolean => { - const imageExtensions = [ - '.jpg', - '.jpeg', - '.png', - '.gif', - '.webp', - '.avif', - '.svg', - ]; - const lowerUrl = url.toLowerCase(); - return imageExtensions.some((ext) => lowerUrl.includes(ext)); -}; - -/** - * Preload a single asset with progress tracking and Cache API storage - */ -const preloadWithProgress = async ( - url: string, - jobId: string, - assetId: string, -): Promise => { - // Emit start event - downloadEventBus.emitPreloadStart({ jobId, assetId, url }); - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const contentLength = response.headers.get('content-length'); - const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; - - // Clone response for caching (original will be consumed for progress tracking) - const responseToCache = response.clone(); - - if (!response.body) { - // No streaming, just wait for response and cache it - await response.blob(); - downloadEventBus.emitPreloadProgress({ - jobId, - progress: 100, - bytesLoaded: totalBytes, - totalBytes, - }); - - // Store in Cache API - if (typeof caches !== 'undefined') { - const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); - await cache.put(url, responseToCache); - } - - downloadEventBus.emitPreloadComplete({ jobId, assetId }); - return; - } - - // Stream with progress and collect chunks for caching - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytesLoaded = 0; - - while (true) { - const { done, value } = await reader.read(); - - if (done) break; - - chunks.push(value); - bytesLoaded += value.length; - const progress = totalBytes > 0 ? (bytesLoaded / totalBytes) * 100 : 0; - - downloadEventBus.emitPreloadProgress({ - jobId, - progress: Math.round(progress), - bytesLoaded, - totalBytes, - }); - } - - // Store in Cache API after successful download - if (typeof caches !== 'undefined') { - const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); - // Create a new response from collected chunks - const blob = new Blob(chunks as BlobPart[], { - type: - responseToCache.headers.get('content-type') || - 'application/octet-stream', - }); - const cachedResponse = new Response(blob, { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': - responseToCache.headers.get('content-type') || - 'application/octet-stream', - 'Content-Length': String(bytesLoaded), - }, - }); - await cache.put(url, cachedResponse); - } - - downloadEventBus.emitPreloadComplete({ jobId, assetId }); - } catch (error) { - downloadEventBus.emitPreloadError({ - jobId, - assetId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw error; - } +const mapAssetType = ( + assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', +): 'image' | 'video' | 'audio' | 'transition' | 'other' => { + return assetType; }; export function usePreloadOrchestrator( @@ -248,18 +109,14 @@ export function usePreloadOrchestrator( const [isPreloading, setIsPreloading] = useState(false); const [preloadedUrls] = useState(() => new Set()); const [queueLength, setQueueLength] = useState(0); + // Version counter to trigger re-renders when blob URLs become ready + const [readyUrlsVersion, setReadyUrlsVersion] = useState(0); const queueRef = useRef([]); - const activeDownloadsRef = useRef(0); const isProcessingRef = useRef(false); const lastPreloadedPageRef = useRef(null); const lastPreloadedLinksCountRef = useRef(0); - // Map of original URL → decoded blob URL (ready to display instantly) - const readyBlobUrlsRef = useRef>(new Map()); - // Set of URLs that failed cache lookup (prevents infinite retry loops) - const failedCacheLookupRef = useRef>(new Set()); - // Use neighbor graph for determining what to preload const neighborGraph = useNeighborGraph({ pages, @@ -269,89 +126,33 @@ export function usePreloadOrchestrator( }); // Use network info for adaptive preloading - const { networkInfo, recommendedConcurrency, shouldPreloadAggressively } = - useNetworkAware(); + const { networkInfo } = useNetworkAware(); - /** - * Create a blob URL from cache and decode if image. - * Stores the ready-to-display blob URL in readyBlobUrlsRef. - * If storageKey is provided, also maps the storage key to the blob URL for canonical lookup. - */ - const createReadyBlobUrl = useCallback( - async (url: string, storageKey?: string): Promise => { - try { - // Skip if we already know this URL is not in cache (prevents infinite loops) - if (failedCacheLookupRef.current.has(url)) { - return; - } - if (storageKey && failedCacheLookupRef.current.has(storageKey)) { - return; - } - - // Try multiple URL formats to handle key mismatches - // (presigned URL vs proxy URL vs storage key) - let blob = await StorageManager.getAsset(url); - - // If not found and we have a storage key, try that too - if (!blob && storageKey && storageKey !== url) { - blob = await StorageManager.getAsset(storageKey); - } - - // Also try with the proxy URL format as fallback - if (!blob && storageKey) { - const proxyUrl = `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(storageKey.replace(/^\/+/, ''))}`; - if (proxyUrl !== url) { - blob = await StorageManager.getAsset(proxyUrl); - } - } - - if (!blob) { - // Mark as failed to prevent repeated lookups - failedCacheLookupRef.current.add(url); - if (storageKey) { - failedCacheLookupRef.current.add(storageKey); - } - logger.info('[PRELOAD] No blob found in cache', { - url: url.slice(-50), - storageKey: storageKey?.slice(-50), - }); - return; - } - - // Create blob URL - const blobUrl = URL.createObjectURL(blob); - - // Decode if image (blob URL decode is fast - local data) - if (isImageUrl(url)) { - await decodeImage(blobUrl); - } - - // Store ready blob URL keyed by download URL - readyBlobUrlsRef.current.set(url, blobUrl); - preloadedUrls.add(url); - - // Also map storage key for canonical lookup (most reliable) - if (storageKey) { - readyBlobUrlsRef.current.set(storageKey, blobUrl); - preloadedUrls.add(storageKey); - } - - logger.info('[PRELOAD] Asset ready', { - url: url.slice(-50), - storageKey: storageKey?.slice(-50), - blobUrl: blobUrl.slice(0, 30), + // Subscribe to blob URL ready events from DownloadManager + useEffect(() => { + const unsubscribe = downloadEventBus.on( + OFFLINE_CONFIG.events.blobUrlReady as Parameters< + typeof downloadEventBus.on + >[0], + (data: BlobUrlReadyEvent) => { + logger.info('[PRELOAD] Blob URL ready from DownloadManager', { + storageKey: data.storageKey.slice(-50), }); - } catch (error) { - logger.error('[PRELOAD] Failed to create ready blob URL', { - url: url.slice(-50), - error: error instanceof Error ? error.message : 'unknown', - }); - } - }, - [preloadedUrls], - ); + preloadedUrls.add(data.storageKey); + setReadyUrlsVersion((v) => v + 1); + }, + ); + return unsubscribe; + }, [preloadedUrls]); - // Process the queue + // Cleanup blob URLs on unmount + useEffect(() => { + return () => { + downloadManager.clearBlobUrls(); + }; + }, []); + + // Process the queue using DownloadManager const processQueue = useCallback(async () => { if (isProcessingRef.current) return; if (!networkInfo.isOnline) return; @@ -363,80 +164,47 @@ export function usePreloadOrchestrator( isProcessingRef.current = true; setIsPreloading(true); - const maxConcurrent = recommendedConcurrency; - - while ( - queueRef.current.length > 0 && - activeDownloadsRef.current < maxConcurrent - ) { + // Process all items in queue + while (queueRef.current.length > 0) { const item = queueRef.current.shift(); if (!item) break; setQueueLength(queueRef.current.length); + // Get canonical storage key + const storageKey = item.storageKey || extractStoragePath(item.url); + // Skip if already preloaded - if (preloadedUrls.has(item.url)) { + if (preloadedUrls.has(storageKey)) { continue; } - // Skip download if already cached, create blob URL and decode - const cached = await isUrlCached(item.url); - if (cached) { - logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) }); - // Create blob URL and decode - makes asset ready to display instantly - await createReadyBlobUrl(item.url, item.storageKey); - continue; - } - - activeDownloadsRef.current++; - - const jobId = generateJobId(); - logger.info('[PRELOAD] Starting download', { + logger.info('[PRELOAD] Queuing with DownloadManager', { url: item.url.slice(-50), + storageKey: storageKey.slice(-50), assetType: item.assetType, + priority: item.priority, }); - preloadWithProgress(item.url, jobId, item.id) - .then(async () => { - logger.info('[PRELOAD] Download complete', { - url: item.url.slice(-50), - }); - // Clear failed cache lookup status since we just downloaded fresh - failedCacheLookupRef.current.delete(item.url); - if (item.storageKey) { - failedCacheLookupRef.current.delete(item.storageKey); - } - await createReadyBlobUrl(item.url, item.storageKey); + // Use DownloadManager for unified download and blob URL creation + // Mark presigned URL as verified if download succeeds + downloadManager + .addJob({ + assetId: item.id, + projectId: '', // Not needed for online preload + url: item.url, + filename: item.url.split('/').pop() || 'asset', + variantType: 'original', + assetType: mapAssetType(item.assetType), + priority: item.priority, + storageKey, + createBlobUrl: true, // Create blob URL for instant display + persist: false, // Don't persist for online preload (in-memory only) + }) + .then(() => { if (isPresignedUrl(item.url)) { markPresignedUrlsVerified(); } - // Map proxy URL and storage key to the same blob URL for fallback lookup - if (item.storageKey) { - const blobUrl = readyBlobUrlsRef.current.get(item.url); - if (blobUrl) { - // Map proxy URL for fallback compatibility - const proxyUrl = buildProxyUrl(item.storageKey); - readyBlobUrlsRef.current.set(proxyUrl, blobUrl); - preloadedUrls.add(proxyUrl); - } - - // Store in Cache API under storage key for post-refresh lookups - if (typeof caches !== 'undefined') { - try { - const cache = await caches.open( - OFFLINE_CONFIG.cacheNames.assets, - ); - const existingResponse = await cache.match(item.url); - if (existingResponse) { - await cache.put(item.storageKey, existingResponse.clone()); - } - } catch (e) { - logger.warn('[PRELOAD] Failed to store under storage key', { - storageKey: item.storageKey, - }); - } - } - } }) .catch(async (err) => { logger.error('[PRELOAD] Download failed', { @@ -445,20 +213,30 @@ export function usePreloadOrchestrator( }); // If presigned URL failed (e.g., CORS), retry with proxy URL - if (item.storageKey && isPresignedUrl(item.url)) { - markPresignedUrlFailed(item.storageKey); - const proxyUrl = buildProxyUrl(item.storageKey); + if (storageKey && isPresignedUrl(item.url)) { + markPresignedUrlFailed(storageKey); + const proxyUrl = buildProxyUrl(storageKey); logger.info('[PRELOAD] Retrying with proxy URL', { - storageKey: item.storageKey.slice(-50), + storageKey: storageKey.slice(-50), proxyUrl: proxyUrl.slice(-60), }); try { - await preloadWithProgress(proxyUrl, generateJobId(), item.id); + await downloadManager.addJob({ + assetId: item.id, + projectId: '', + url: proxyUrl, + filename: storageKey.split('/').pop() || 'asset', + variantType: 'original', + assetType: mapAssetType(item.assetType), + priority: item.priority, + storageKey, + createBlobUrl: true, + persist: false, + }); logger.info('[PRELOAD] Proxy download complete', { url: proxyUrl.slice(-60), }); - await createReadyBlobUrl(proxyUrl, item.storageKey); } catch (retryErr) { logger.error('[PRELOAD] Proxy download also failed', { url: proxyUrl.slice(-60), @@ -466,47 +244,33 @@ export function usePreloadOrchestrator( }); } } - }) - .finally(() => { - activeDownloadsRef.current--; - // Process more items - if (queueRef.current.length > 0) { - processQueue(); - } else if (activeDownloadsRef.current === 0) { - setIsPreloading(false); - isProcessingRef.current = false; - } }); + + preloadedUrls.add(storageKey); } - if (activeDownloadsRef.current === 0) { - setIsPreloading(false); - isProcessingRef.current = false; - } - }, [ - networkInfo.isOnline, - preloadedUrls, - recommendedConcurrency, - createReadyBlobUrl, - ]); + setIsPreloading(false); + isProcessingRef.current = false; + }, [networkInfo.isOnline, preloadedUrls]); // Add item to queue with priority sorting const addToQueue = useCallback( (item: PreloadQueueItem) => { + const storageKey = item.storageKey || extractStoragePath(item.url); + // Skip if already in queue or preloaded if ( - preloadedUrls.has(item.url) || - queueRef.current.some((q) => q.url === item.url) + preloadedUrls.has(storageKey) || + queueRef.current.some( + (q) => (q.storageKey || extractStoragePath(q.url)) === storageKey, + ) ) { - logger.info('[PRELOAD] Skipping (already queued/preloaded)', { - url: item.url.slice(-50), - assetType: item.assetType, - }); return; } logger.info('[PRELOAD] Adding to queue', { url: item.url.slice(-60), + storageKey: storageKey.slice(-50), assetType: item.assetType, priority: item.priority, queueLength: queueRef.current.length + 1, @@ -569,34 +333,23 @@ export function usePreloadOrchestrator( // Check if URL is preloaded (in cache) const isUrlPreloaded = useCallback( async (url: string): Promise => { + const storageKey = extractStoragePath(url); // First check in-memory set - if (preloadedUrls.has(url)) return true; + if (preloadedUrls.has(storageKey)) return true; - // Then check Cache API - return isUrlCached(url); + // Then check via StorageManager + return StorageManager.hasAsset(storageKey); }, [preloadedUrls], ); // Instant lookup - returns decoded blob URL or null (O(1) Map lookup) + // Uses DownloadManager's unified blob URL cache const getReadyBlobUrl = useCallback((url: string): string | null => { - return readyBlobUrlsRef.current.get(url) || null; + return downloadManager.getReadyBlobUrl(url); }, []); - // Cleanup blob URLs to prevent memory leaks - const clearReadyBlobUrls = useCallback(() => { - readyBlobUrlsRef.current.forEach((blobUrl) => { - URL.revokeObjectURL(blobUrl); - }); - readyBlobUrlsRef.current.clear(); - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => clearReadyBlobUrls(); - }, [clearReadyBlobUrls]); - - // Initialize ready blob URLs from Cache API for current page's assets + // Initialize ready blob URLs from cache for current page's assets // This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons useEffect(() => { if (!currentPageId) return; @@ -647,23 +400,37 @@ export function usePreloadOrchestrator( } }); - // Initialize all URLs from cache + // Initialize all URLs from cache via DownloadManager const allUrls = [...bgUrls, ...elementAssetUrls]; for (const storagePath of allUrls) { - // Skip if already in memory - if (readyBlobUrlsRef.current.has(storagePath)) continue; + const storageKey = extractStoragePath(storagePath); + // Skip if already ready + if (downloadManager.getReadyBlobUrl(storageKey)) continue; + + // Check if cached and create blob URL if so const fullUrl = resolveAssetPlaybackUrl(storagePath); - if (readyBlobUrlsRef.current.has(fullUrl)) continue; - - // Try to load from Cache API - await createReadyBlobUrl(fullUrl, storagePath); + const hasAsset = await StorageManager.hasAsset(storageKey); + if (hasAsset) { + // Use DownloadManager.addJob with createBlobUrl to create the blob URL + await downloadManager.addJob({ + assetId: `init-${storageKey}`, + projectId: '', + url: fullUrl, + filename: storageKey.split('/').pop() || 'asset', + variantType: 'original', + assetType: 'other', + storageKey, + createBlobUrl: true, + persist: false, + }); + } } }; initializeFromCache(); - }, [currentPageId, pages, elements, createReadyBlobUrl]); + }, [currentPageId, pages, elements]); // React to page changes - preload neighbors useEffect(() => { @@ -770,132 +537,193 @@ export function usePreloadOrchestrator( return resolveAssetPlaybackUrl(storageKey); }; - const addAssetsToQueue = (presignedUrls: Record = {}) => { - // Add background assets from current page - if (currentPage?.background_image_url) { - const storageKey = currentPage.background_image_url; + // Two-phase preloading: current page first, then neighbors + const addAssetsToQueue = async ( + presignedUrls: Record = {}, + ) => { + // Helper to create download job + const createDownloadJob = ( + id: string, + storageKey: string, + priority: number, + assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', + pageId: string, + ): Promise | null => { const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-img-${currentPageId}`, + if (!resolvedUrl) return null; + + const normalizedKey = isRelativeStoragePath(storageKey) + ? storageKey + : extractStoragePath(resolvedUrl); + + // Skip if already preloaded + if (preloadedUrls.has(normalizedKey)) return null; + + preloadedUrls.add(normalizedKey); + + return downloadManager + .addJob({ + assetId: id, + projectId: '', url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: PRELOAD_CONFIG.priority.currentPage + 200, - assetType: 'image', - pageId: currentPageId, + filename: resolvedUrl.split('/').pop() || 'asset', + variantType: 'original', + assetType: mapAssetType(assetType), + priority, + storageKey: normalizedKey, + createBlobUrl: true, + persist: false, + }) + .then(() => { + if (isPresignedUrl(resolvedUrl)) { + markPresignedUrlsVerified(); + } + }) + .catch(async (err) => { + logger.error('[PRELOAD] Download failed', { + url: resolvedUrl.slice(-50), + error: err?.message, + }); + // Retry with proxy if presigned URL failed + if (isPresignedUrl(resolvedUrl)) { + markPresignedUrlFailed(normalizedKey); + const proxyUrl = buildProxyUrl(normalizedKey); + try { + await downloadManager.addJob({ + assetId: id, + projectId: '', + url: proxyUrl, + filename: normalizedKey.split('/').pop() || 'asset', + variantType: 'original', + assetType: mapAssetType(assetType), + priority, + storageKey: normalizedKey, + createBlobUrl: true, + persist: false, + }); + } catch { + // Ignore retry failures + } + } }); - } + }; + + // ============================================ + // PHASE 1: Load current page assets and WAIT + // ============================================ + logger.info('[PRELOAD] Phase 1: Loading current page assets'); + + const currentPageJobs: Promise[] = []; + + // Current page background assets + if (currentPage?.background_image_url) { + const job = createDownloadJob( + `bg-img-${currentPageId}`, + currentPage.background_image_url, + PRELOAD_CONFIG.priority.currentPage + 200, + 'image', + currentPageId, + ); + if (job) currentPageJobs.push(job); } if (currentPage?.background_video_url) { - const storageKey = currentPage.background_video_url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-vid-${currentPageId}`, - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: PRELOAD_CONFIG.priority.currentPage + 150, - assetType: 'video', - pageId: currentPageId, - }); - } + const job = createDownloadJob( + `bg-vid-${currentPageId}`, + currentPage.background_video_url, + PRELOAD_CONFIG.priority.currentPage + 150, + 'video', + currentPageId, + ); + if (job) currentPageJobs.push(job); } if (currentPage?.background_audio_url) { - const storageKey = currentPage.background_audio_url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-aud-${currentPageId}`, - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: PRELOAD_CONFIG.priority.currentPage + 100, - assetType: 'audio', - pageId: currentPageId, - }); - } + const job = createDownloadJob( + `bg-aud-${currentPageId}`, + currentPage.background_audio_url, + PRELOAD_CONFIG.priority.currentPage + 100, + 'audio', + currentPageId, + ); + if (job) currentPageJobs.push(job); } - // Add element assets - assets.forEach((asset) => { - const storageKey = asset.url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: generateJobId(), - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: asset.priority, - assetType: asset.assetType, - pageId: asset.pageId, - }); - } + // Current page element assets (from neighbor graph with pageId === currentPageId) + const currentPageAssets = assets.filter( + (asset) => asset.pageId === currentPageId, + ); + currentPageAssets.forEach((asset) => { + const job = createDownloadJob( + generateJobId(), + asset.url, + asset.priority, + asset.assetType, + asset.pageId, + ); + if (job) currentPageJobs.push(job); }); - // Always preload immediate neighbor backgrounds for smooth navigation - // This is critical for instant page switches without white flash + // Wait for all current page assets to complete + if (currentPageJobs.length > 0) { + logger.info('[PRELOAD] Waiting for current page assets', { + count: currentPageJobs.length, + }); + await Promise.all(currentPageJobs); + logger.info('[PRELOAD] Current page assets ready'); + } + + // ============================================ + // PHASE 2: Preload neighbor assets (don't wait) + // ============================================ + logger.info('[PRELOAD] Phase 2: Preloading neighbor assets'); + + // Neighbor page element assets + const neighborAssets = assets.filter( + (asset) => asset.pageId !== currentPageId, + ); + neighborAssets.forEach((asset) => { + createDownloadJob( + generateJobId(), + asset.url, + asset.priority, + asset.assetType, + asset.pageId, + ); + }); + + // Neighbor background assets const neighbors = neighborGraph.getNeighbors(currentPageId, 1); neighbors.forEach(({ pageId }) => { const page = pages.find((p) => p.id === pageId); if (page?.background_image_url) { - const storageKey = page.background_image_url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-img-${pageId}`, - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - // Neighbor backgrounds get high priority (just below current page) - priority: PRELOAD_CONFIG.priority.neighborBase + 100, - assetType: 'image', - pageId, - }); - } + createDownloadJob( + `bg-img-${pageId}`, + page.background_image_url, + PRELOAD_CONFIG.priority.neighborBase + 100, + 'image', + pageId, + ); } - // Always preload neighbor videos for smooth page transitions if (page?.background_video_url) { - const storageKey = page.background_video_url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-vid-${pageId}`, - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: PRELOAD_CONFIG.priority.neighborBase + 50, - assetType: 'video', - pageId, - }); - } + createDownloadJob( + `bg-vid-${pageId}`, + page.background_video_url, + PRELOAD_CONFIG.priority.neighborBase + 50, + 'video', + pageId, + ); } - // Always preload neighbor audio for seamless playback if (page?.background_audio_url) { - const storageKey = page.background_audio_url; - const resolvedUrl = resolveUrl(storageKey, presignedUrls); - if (resolvedUrl) { - addToQueue({ - id: `bg-aud-${pageId}`, - url: resolvedUrl, - storageKey: isRelativeStoragePath(storageKey) - ? storageKey - : undefined, - priority: PRELOAD_CONFIG.priority.neighborBase + 30, - assetType: 'audio', - pageId, - }); - } + createDownloadJob( + `bg-aud-${pageId}`, + page.background_audio_url, + PRELOAD_CONFIG.priority.neighborBase + 30, + 'audio', + pageId, + ); } }); + + logger.info('[PRELOAD] Phase 2: Neighbor assets queued'); }; // If there are storage paths to presign, fetch them first @@ -904,11 +732,11 @@ export function usePreloadOrchestrator( count: storagePaths.length, }); queuePresignedUrls(storagePaths) - .then(() => { + .then(async () => { logger.info('[PRELOAD] Presigned URLs fetched, adding to queue'); - addAssetsToQueue(); + await addAssetsToQueue(); }) - .catch((error) => { + .catch(async (error) => { logger.error( '[PRELOAD] Failed to fetch presigned URLs, falling back to proxy', { @@ -916,7 +744,7 @@ export function usePreloadOrchestrator( }, ); // Fallback: add to queue without presigned URLs (will use backend proxy) - addAssetsToQueue(); + await addAssetsToQueue(); }); } else { // No storage paths to presign, add directly to queue @@ -937,6 +765,7 @@ export function usePreloadOrchestrator( isPreloading, preloadedUrls, queueLength, + readyUrlsVersion, preloadAsset, clearQueue, getCachedBlobUrl, diff --git a/frontend/src/hooks/useTransitionCreation.ts b/frontend/src/hooks/useTransitionCreation.ts new file mode 100644 index 0000000..ce44da1 --- /dev/null +++ b/frontend/src/hooks/useTransitionCreation.ts @@ -0,0 +1,134 @@ +/** + * useTransitionCreation Hook + * + * Encapsulates transition creation form state and actions. + * Used in the constructor to manage the "Create Transition" form. + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { AssetOption } from '../types/constructor'; + +export interface TransitionCreationState { + /** Transition name */ + name: string; + /** Video URL/storage key */ + videoUrl: string; + /** Whether transition supports reverse playback */ + supportsReverse: boolean; + /** Whether transition is being created */ + isCreating: boolean; +} + +export interface TransitionCreationActions { + /** Set transition name */ + setName: (name: string) => void; + /** Set video URL */ + setVideoUrl: (url: string) => void; + /** Set supports reverse flag */ + setSupportsReverse: (value: boolean) => void; + /** Create the transition */ + create: () => void; + /** Reset form to initial state */ + reset: () => void; +} + +export interface UseTransitionCreationOptions { + /** Available transition video options */ + videoOptions: AssetOption[]; + /** Callback to create transition */ + onCreate: (params: { + name: string; + videoUrl: string; + supportsReverse: boolean; + durationSec?: number; + }) => Promise; + /** Get duration of video URL */ + getDuration?: (url: string) => number | undefined; +} + +export interface UseTransitionCreationResult + extends TransitionCreationState, + TransitionCreationActions {} + +/** + * Hook to manage transition creation form state. + * + * @example + * ```typescript + * const { + * name, + * videoUrl, + * supportsReverse, + * isCreating, + * setName, + * setVideoUrl, + * setSupportsReverse, + * create, + * } = useTransitionCreation({ + * videoOptions: transitionVideoAssetOptions, + * onCreate: handleCreateTransition, + * getDuration, + * }); + * ``` + */ +export function useTransitionCreation({ + videoOptions, + onCreate, + getDuration, +}: UseTransitionCreationOptions): UseTransitionCreationResult { + const [name, setName] = useState(''); + const [videoUrl, setVideoUrl] = useState(''); + const [supportsReverse, setSupportsReverse] = useState(true); + const [isCreating, setIsCreating] = useState(false); + + // Auto-select first video option when available + useEffect(() => { + if (videoUrl) return; + if (!videoOptions.length) return; + setVideoUrl(videoOptions[0].value); + }, [videoUrl, videoOptions]); + + const create = useCallback(async () => { + if (isCreating) return; + if (!videoUrl) return; + + setIsCreating(true); + try { + const durationSec = getDuration?.(videoUrl); + await onCreate({ + name, + videoUrl, + supportsReverse, + durationSec, + }); + // Reset form on success + setName(''); + setSupportsReverse(true); + } finally { + setIsCreating(false); + } + }, [isCreating, name, videoUrl, supportsReverse, getDuration, onCreate]); + + const reset = useCallback(() => { + setName(''); + setVideoUrl(videoOptions[0]?.value || ''); + setSupportsReverse(true); + setIsCreating(false); + }, [videoOptions]); + + return { + // State + name, + videoUrl, + supportsReverse, + isCreating, + // Actions + setName, + setVideoUrl, + setSupportsReverse, + create, + reset, + }; +} + +export default useTransitionCreation; diff --git a/frontend/src/hooks/useTransitionPreview.ts b/frontend/src/hooks/useTransitionPreview.ts index d5d9e38..3773d09 100644 --- a/frontend/src/hooks/useTransitionPreview.ts +++ b/frontend/src/hooks/useTransitionPreview.ts @@ -6,26 +6,9 @@ */ import { useState, useCallback, useMemo } from 'react'; +import type { TransitionPreviewState } from '../types/presentation'; -/** - * Transition preview state - */ -export interface TransitionPreviewState { - /** Resolved video URL for playback */ - videoUrl: string; - /** Raw storage path for cache lookup */ - storageKey: string; - /** Playback mode: none (forward), reverse (auto-reverse), separate (use reverseVideoUrl) */ - reverseMode: 'none' | 'reverse' | 'separate'; - /** Resolved URL for separate reverse video */ - reverseVideoUrl?: string; - /** Raw storage path for reverse video cache lookup */ - reverseStorageKey?: string; - /** Duration in seconds */ - durationSec?: number; - /** Display title for the preview */ - title: string; -} +export type { TransitionPreviewState }; /** * Navigation element with transition configuration diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f0cea06..72a826c 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -9,6 +9,9 @@ i18n .use(initReactI18next) .init({ fallbackLng: 'en', + ns: ['common'], + defaultNS: 'common', + load: 'languageOnly', detection: { order: ['localStorage', 'navigator'], lookupLocalStorage: 'app_lang_', diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts deleted file mode 100644 index 75048f3..0000000 --- a/frontend/src/interfaces/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -export type UserPayloadObject = { - name: string; - email: string; - avatar: string; -}; - -export type MenuAsideItem = { - label: string; - icon?: string; - href?: string; - target?: string; - color?: ColorButtonKey; - isLogout?: boolean; - withDevider?: boolean; - menu?: MenuAsideItem[]; - permissions?: string | string[]; -}; - -export type MenuNavBarItem = { - label?: string; - icon?: string; - href?: string; - target?: string; - isDivider?: boolean; - isLogout?: boolean; - isDesktopNoLabel?: boolean; - isToggleLightDark?: boolean; - isCurrentUser?: boolean; - menu?: MenuNavBarItem[]; -}; - -export type ColorKey = - | 'white' - | 'light' - | 'contrast' - | 'success' - | 'danger' - | 'warning' - | 'info'; - -export type ColorButtonKey = - | 'white' - | 'whiteDark' - | 'lightDark' - | 'contrast' - | 'success' - | 'danger' - | 'warning' - | 'info' - | 'void'; - -export type BgKey = 'purplePink' | 'pinkRed' | 'violet'; - -export type TrendType = - | 'up' - | 'down' - | 'success' - | 'danger' - | 'warning' - | 'info'; - -export type TransactionType = 'withdraw' | 'deposit' | 'invoice' | 'payment'; - -export type Transaction = { - id: number; - amount: number; - account: string; - name: string; - date: string; - type: TransactionType; - business: string; -}; - -export type Client = { - id: number; - avatar: string; - login: string; - name: string; - city: string; - company: string; - firstName: string; - lastName: string; - phoneNumber: string; - email: string; - progress: number; - role: string; - disabled: boolean; - created: string; - created_mm_dd_yyyy: string; -}; - -export interface User { - id: string; - firstName: string; - lastName?: any; - phoneNumber?: any; - email: string; - role: string; - disabled: boolean; - password: string; - emailVerified: boolean; - emailVerificationToken?: any; - emailVerificationTokenExpiresAt?: any; - passwordResetToken?: any; - passwordResetTokenExpiresAt?: any; - provider: string; - importHash?: any; - createdAt: Date; - updatedAt: Date; - deletedAt?: any; - createdById?: any; - updatedById?: any; - avatar: any[]; - notes: any[]; -} - -export type StyleKey = 'white' | 'basic'; - -export type UserForm = { - name: string; - email: string; -}; diff --git a/frontend/src/lib/assetUrl.ts b/frontend/src/lib/assetUrl.ts index ab50738..a61dd37 100644 --- a/frontend/src/lib/assetUrl.ts +++ b/frontend/src/lib/assetUrl.ts @@ -296,21 +296,61 @@ export const isRelativeStoragePath = (url: string): boolean => { }; /** - * Extract relative storage path from a full S3 URL. - * Converts: https://bucket.s3.region.amazonaws.com/prefix/assets/projectId/file.ext - * To: assets/projectId/file.ext - * Returns original if already a relative path or not an S3 URL. + * Extract relative storage path from various URL formats. + * Handles: S3 URLs, presigned URLs, proxy URLs, relative paths. + * + * Converts: + * - https://bucket.s3.region.amazonaws.com/prefix/assets/projectId/file.ext → assets/projectId/file.ext + * - /api/file/download?privateUrl=assets%2FprojectId%2Ffile.ext → assets/projectId/file.ext + * - Presigned S3 URL with X-Amz-Signature → assets/projectId/file.ext + * + * Returns original if already a relative path or not a recognized format. */ export const extractStoragePath = (url: string): string => { const normalized = url?.trim() || ''; if (!normalized) return ''; + + // Already a relative storage path if (isRelativeStoragePath(normalized)) return normalized; - // Extract path starting from 'assets/' + // Proxy URL format: /api/file/download?privateUrl=assets%2F... or /file/download?privateUrl=... + if (normalized.includes('/file/download?privateUrl=')) { + const match = normalized.match(/privateUrl=([^&]+)/); + if (match) { + return decodeURIComponent(match[1]).replace(/^\/+/, ''); + } + } + + // Presigned S3 URL: extract path before query params + if ( + normalized.includes('X-Amz-Signature=') || + normalized.includes('x-amz-signature=') + ) { + try { + const urlObj = new URL(normalized); + // Path: /bucket-hash/assets/project-id/file.jpg → assets/project-id/file.jpg + const pathParts = urlObj.pathname.split('/').filter(Boolean); + // Find 'assets' in path and return everything from there + const assetsIndex = pathParts.findIndex((part) => part === 'assets'); + if (assetsIndex !== -1) { + return pathParts.slice(assetsIndex).join('/'); + } + // Fallback: skip first part (bucket hash) if path has multiple parts + if (pathParts.length > 1) { + return pathParts.slice(1).join('/'); + } + } catch { + /* fall through */ + } + } + + // Full S3 URL (non-presigned): extract path starting from 'assets/' const s3Match = normalized.match( /^https?:\/\/[^/]+\.s3\.[^/]+\.amazonaws\.com\/[^/]+\/(assets\/.+)$/, ); - return s3Match ? s3Match[1] : normalized; + if (s3Match) return s3Match[1]; + + return normalized; }; /** diff --git a/frontend/src/lib/constructorHelpers.ts b/frontend/src/lib/constructorHelpers.ts index 05bc3e8..6e2571b 100644 --- a/frontend/src/lib/constructorHelpers.ts +++ b/frontend/src/lib/constructorHelpers.ts @@ -20,12 +20,6 @@ import { getNavigationButtonLabel, } from './elementDefaults'; -/** - * Clamp a number between min and max - */ -export const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max); - /** * Get trimmed CSS value as string. * Handles null/undefined gracefully. diff --git a/frontend/src/lib/imagePreDecode.ts b/frontend/src/lib/imagePreDecode.ts index d9b0a45..1429bc0 100644 --- a/frontend/src/lib/imagePreDecode.ts +++ b/frontend/src/lib/imagePreDecode.ts @@ -129,8 +129,16 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => { ? uiSchema.elements : []; - const { images: imageFields, nested: nestedFields } = - PRELOAD_CONFIG.assetFields; + const { + images: imageFields, + nested: nestedFields, + nestedUrlFields, + } = PRELOAD_CONFIG.assetFields; + + // Filter nestedUrlFields to only image fields (exclude videoUrl, audioUrl, etc.) + const nestedImageFields = nestedUrlFields.filter((field) => + (imageFields as readonly string[]).includes(field), + ); pageElements.forEach((el: Record) => { // Direct image fields @@ -142,15 +150,19 @@ export const extractPageImageUrls = (page: PageWithImages | null): string[] => { } }); - // Nested arrays (galleryCards, carouselSlides) + // Nested arrays (galleryCards, carouselSlides, galleryInfoSpans) nestedFields.forEach((nestedField) => { const items = el[nestedField]; if (Array.isArray(items)) { items.forEach((item: Record) => { - if (typeof item.imageUrl === 'string' && item.imageUrl) { - const url = resolveAssetPlaybackUrl(item.imageUrl); - if (url && !imageUrls.includes(url)) imageUrls.push(url); - } + // Check all image fields in nested items (imageUrl, iconUrl) + nestedImageFields.forEach((field) => { + const value = item[field]; + if (typeof value === 'string' && value) { + const url = resolveAssetPlaybackUrl(value); + if (url && !imageUrls.includes(url)) imageUrls.push(url); + } + }); }); } }); diff --git a/frontend/src/lib/offline/DownloadEventBus.ts b/frontend/src/lib/offline/DownloadEventBus.ts index 83447bf..57ee904 100644 --- a/frontend/src/lib/offline/DownloadEventBus.ts +++ b/frontend/src/lib/offline/DownloadEventBus.ts @@ -14,6 +14,7 @@ import type { PreloadErrorEvent, ProjectDownloadProgressEvent, ProjectDownloadCompleteEvent, + BlobUrlReadyEvent, } from '../../types/offline'; type EventMap = { @@ -24,6 +25,7 @@ type EventMap = { [OFFLINE_CONFIG.events.projectDownloadProgress]: ProjectDownloadProgressEvent; [OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent; [OFFLINE_CONFIG.events.queueUpdate]: void; + [OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent; }; type EventCallback = (data: T) => void; @@ -173,6 +175,13 @@ class DownloadEventBusClass { undefined as never, ); } + + /** + * Emit blob URL ready event + */ + emitBlobUrlReady(data: BlobUrlReadyEvent): void { + this.emit(OFFLINE_CONFIG.events.blobUrlReady as keyof EventMap, data); + } } // Singleton instance diff --git a/frontend/src/lib/offline/DownloadManager.ts b/frontend/src/lib/offline/DownloadManager.ts index e45819b..f3a6871 100644 --- a/frontend/src/lib/offline/DownloadManager.ts +++ b/frontend/src/lib/offline/DownloadManager.ts @@ -10,6 +10,8 @@ import { OFFLINE_CONFIG } from '../../config/offline.config'; import { downloadEventBus } from './DownloadEventBus'; import { StorageManager } from './StorageManager'; import { OfflineDbManager } from '../offlineDb/OfflineDbManager'; +import { extractStoragePath } from '../assetUrl'; +import { logger } from '../logger'; import type { PreloadJobStatus, AssetVariantType, @@ -32,6 +34,9 @@ interface DownloadJob { totalBytes: number; retryCount: number; addedAt: number; + storageKey: string; // Canonical storage key for consistent caching + createBlobUrl?: boolean; // Create decoded blob URL after download + persist?: boolean; // Persist to IndexedDB for resume (default: true) abortController?: AbortController; resolve?: () => void; reject?: (error: Error) => void; @@ -43,6 +48,9 @@ class DownloadManagerClass { private isPaused = false; private isProcessing = false; + // Blob URL cache for instant lookup (storageKey → blobUrl) + private readyBlobUrls: Map = new Map(); + private config = { maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, chunkSize: PRELOAD_CONFIG.videoChunkSize, @@ -62,17 +70,28 @@ class DownloadManagerClass { variantType: AssetVariantType; assetType: AssetType; priority?: number; + storageKey?: string; // Optional, will extract if not provided + createBlobUrl?: boolean; // Create blob URL after download + persist?: boolean; // Persist to IndexedDB for resume (default: true) }): Promise { - // Check if already downloaded - const hasAsset = await StorageManager.hasAsset(params.url); + const storageKey = params.storageKey || extractStoragePath(params.url); + + // Check if already downloaded using canonical key + const hasAsset = await StorageManager.hasAsset(storageKey); if (hasAsset) { + // Already cached - create blob URL if requested + if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) { + await this.createBlobUrlFromCache(storageKey); + } return; } - // Check if already in queue + // Check if already in queue (use storageKey for deduplication) if ( - this.queue.some((j) => j.url === params.url) || - this.activeDownloads.has(params.url) + this.queue.some((j) => j.storageKey === storageKey) || + Array.from(this.activeDownloads.values()).some( + (j) => j.storageKey === storageKey, + ) ) { return; } @@ -95,12 +114,17 @@ class DownloadManagerClass { totalBytes: 0, retryCount: 0, addedAt: Date.now(), + storageKey, + createBlobUrl: params.createBlobUrl ?? false, + persist: params.persist ?? true, resolve, reject, }; - // Persist to IndexedDB for resume capability - this.persistQueueItem(job); + // Persist to IndexedDB for resume capability (default true) + if (job.persist !== false) { + this.persistQueueItem(job); + } // Insert in priority order (higher priority first) const insertIndex = this.queue.findIndex( @@ -244,8 +268,8 @@ class DownloadManagerClass { job.progress = 100; } - // Store the asset - await StorageManager.storeAsset(job.url, blob, { + // Store the asset using canonical storage key + await StorageManager.storeAsset(job.storageKey, blob, { id: job.assetId, projectId: job.projectId, filename: job.filename, @@ -253,9 +277,16 @@ class DownloadManagerClass { assetType: job.assetType, }); + // Create blob URL if requested + if (job.createBlobUrl) { + await this.createBlobUrlFromCache(job.storageKey); + } + // Mark as completed job.status = 'completed'; - await OfflineDbManager.removeFromQueue(job.id); + if (job.persist !== false) { + await OfflineDbManager.removeFromQueue(job.id); + } downloadEventBus.emitPreloadComplete({ jobId: job.id, @@ -420,8 +451,11 @@ class DownloadManagerClass { const pendingItems = await OfflineDbManager.getPendingQueue(); for (const item of pendingItems) { + // Get canonical storage key + const storageKey = extractStoragePath(item.url); + // Skip if already downloaded - const hasAsset = await StorageManager.hasAsset(item.url); + const hasAsset = await StorageManager.hasAsset(storageKey); if (hasAsset) { await OfflineDbManager.removeFromQueue(item.id); continue; @@ -443,6 +477,9 @@ class DownloadManagerClass { totalBytes: item.totalBytes, retryCount: item.retryCount, addedAt: item.addedAt, + storageKey, + createBlobUrl: true, // Create blob URL for resumed downloads + persist: true, }; this.queue.push(job); @@ -456,6 +493,98 @@ class DownloadManagerClass { this.processQueue(); } } + + /** + * Get a ready blob URL for instant display (O(1) lookup) + */ + getReadyBlobUrl(url: string): string | null { + const storageKey = extractStoragePath(url); + return this.readyBlobUrls.get(storageKey) || null; + } + + /** + * Create blob URL from cached asset and store in readyBlobUrls map + */ + private async createBlobUrlFromCache(storageKey: string): Promise { + try { + const blob = await StorageManager.getAsset(storageKey); + if (!blob) { + logger.info('[DownloadManager] No blob found for', { + storageKey: storageKey.slice(-50), + }); + return; + } + + const blobUrl = URL.createObjectURL(blob); + + // Decode images to prevent white flash + if (this.isImageUrl(storageKey)) { + await this.decodeImage(blobUrl); + } + + this.readyBlobUrls.set(storageKey, blobUrl); + + // Emit event for consumers + downloadEventBus.emitBlobUrlReady({ + storageKey, + blobUrl, + }); + + logger.info('[DownloadManager] Blob URL ready', { + storageKey: storageKey.slice(-50), + blobUrl: blobUrl.slice(0, 30), + }); + } catch (error) { + logger.error('[DownloadManager] Failed to create blob URL', { + storageKey: storageKey.slice(-50), + error: error instanceof Error ? error.message : 'unknown', + }); + } + } + + /** + * Clear blob URLs (call on unmount to prevent memory leaks) + */ + clearBlobUrls(): void { + this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl)); + this.readyBlobUrls.clear(); + } + + /** + * Check if URL is an image based on extension + */ + private isImageUrl(url: string): boolean { + const imageExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.avif', + '.svg', + ]; + const lowerUrl = url.toLowerCase(); + return imageExtensions.some((ext) => lowerUrl.includes(ext)); + } + + /** + * Decode image for instant display (prevents white flash) + */ + private decodeImage(blobUrl: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.src = blobUrl; + if (typeof img.decode === 'function') { + img + .decode() + .then(() => resolve()) + .catch(() => resolve()); + } else { + img.onload = () => resolve(); + img.onerror = () => resolve(); + } + }); + } } // Singleton instance diff --git a/frontend/src/lib/offline/StorageManager.ts b/frontend/src/lib/offline/StorageManager.ts index 3fc2197..ca4051c 100644 --- a/frontend/src/lib/offline/StorageManager.ts +++ b/frontend/src/lib/offline/StorageManager.ts @@ -8,6 +8,7 @@ import { OFFLINE_CONFIG } from '../../config/offline.config'; import { PRELOAD_CONFIG } from '../../config/preload.config'; import { OfflineDbManager } from '../offlineDb/OfflineDbManager'; +import { extractStoragePath } from '../assetUrl'; import type { OfflineAsset, AssetVariantType, @@ -84,7 +85,8 @@ export class StorageManager { } /** - * Store an asset (auto-selects Cache API or IndexedDB) + * Store an asset under its canonical storage key (auto-selects Cache API or IndexedDB) + * Normalizes the URL to a storage path for consistent key management. */ static async storeAsset( url: string, @@ -97,6 +99,8 @@ export class StorageManager { assetType: AssetType; }, ): Promise { + // Normalize URL to canonical storage key + const storageKey = extractStoragePath(url); const sizeBytes = blob.size; if (this.shouldUseIndexedDB(sizeBytes)) { @@ -104,7 +108,7 @@ export class StorageManager { const asset: OfflineAsset = { id: metadata.id, projectId: metadata.projectId, - url, + url: storageKey, // Use normalized storage key filename: metadata.filename, variantType: metadata.variantType, assetType: metadata.assetType, @@ -125,16 +129,19 @@ export class StorageManager { 'X-Project-Id': metadata.projectId, }, }); - await cache.put(url, response); + await cache.put(storageKey, response); // Use normalized storage key } } /** - * Get an asset (checks both Cache API and IndexedDB) + * Get an asset by URL (normalizes to canonical storage key) */ static async getAsset(url: string): Promise { + // Normalize URL to canonical storage key + const storageKey = extractStoragePath(url); + // Check IndexedDB first (for large files) - const indexedAsset = await OfflineDbManager.getAssetByUrl(url); + const indexedAsset = await OfflineDbManager.getAssetByUrl(storageKey); if (indexedAsset) { return indexedAsset.blob; } @@ -142,7 +149,7 @@ export class StorageManager { // Check Cache API if (typeof caches !== 'undefined') { const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); - const response = await cache.match(url); + const response = await cache.match(storageKey); if (response) { return response.blob(); } @@ -152,17 +159,20 @@ export class StorageManager { } /** - * Check if an asset exists + * Check if an asset exists (by normalized storage key) */ static async hasAsset(url: string): Promise { + // Normalize URL to canonical storage key + const storageKey = extractStoragePath(url); + // Check IndexedDB - const hasInDb = await OfflineDbManager.hasAssetByUrl(url); + const hasInDb = await OfflineDbManager.hasAssetByUrl(storageKey); if (hasInDb) return true; // Check Cache API if (typeof caches !== 'undefined') { const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); - const response = await cache.match(url); + const response = await cache.match(storageKey); if (response) return true; } @@ -170,9 +180,12 @@ export class StorageManager { } /** - * Delete an asset from all storage locations + * Delete an asset from all storage locations (by normalized storage key) */ static async deleteAsset(url: string, assetId?: string): Promise { + // Normalize URL to canonical storage key + const storageKey = extractStoragePath(url); + // Delete from IndexedDB if (assetId) { await OfflineDbManager.deleteAsset(assetId); @@ -181,7 +194,7 @@ export class StorageManager { // Delete from Cache API if (typeof caches !== 'undefined') { const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); - await cache.delete(url); + await cache.delete(storageKey); } } diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..a197d5a --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,155 @@ +/** + * React Query (TanStack Query) Client Configuration + * + * Centralized query client with optimized defaults for the Tour Builder app. + * This replaces direct axios calls with cached, deduplicated queries. + */ + +import { QueryClient } from '@tanstack/react-query'; + +/** + * Default stale time: 5 minutes + * Data is considered fresh for 5 minutes before background refetching + */ +const DEFAULT_STALE_TIME = 5 * 60 * 1000; + +/** + * Default garbage collection time: 10 minutes + * Inactive queries are garbage collected after 10 minutes + */ +const DEFAULT_GC_TIME = 10 * 60 * 1000; + +/** + * Singleton query client instance + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Data is fresh for 5 minutes + staleTime: DEFAULT_STALE_TIME, + // Keep inactive data for 10 minutes (was cacheTime in v4) + gcTime: DEFAULT_GC_TIME, + // Only retry once on failure + retry: 1, + // Don't refetch on window focus (prevents interruption during editing) + refetchOnWindowFocus: false, + // Don't refetch on reconnect by default + refetchOnReconnect: false, + }, + mutations: { + // Mutations don't retry by default + retry: 0, + }, + }, +}); + +/** + * Query key factories for consistent query key generation + * Use these to ensure cache invalidation works correctly + */ +export const queryKeys = { + // Projects + projects: { + all: ['projects'] as const, + list: (filters?: unknown) => + [...queryKeys.projects.all, 'list', filters] as const, + detail: (id: string) => [...queryKeys.projects.all, 'detail', id] as const, + }, + + // Tour Pages + tourPages: { + all: ['tourPages'] as const, + list: (projectId: string, environment?: string) => + [...queryKeys.tourPages.all, 'list', { projectId, environment }] as const, + detail: (id: string) => [...queryKeys.tourPages.all, 'detail', id] as const, + byProject: (projectId: string) => + [...queryKeys.tourPages.all, 'byProject', projectId] as const, + }, + + // Assets + assets: { + all: ['assets'] as const, + list: (projectId: string, filters?: unknown) => + [...queryKeys.assets.all, 'list', { projectId, filters }] as const, + detail: (id: string) => [...queryKeys.assets.all, 'detail', id] as const, + byProject: (projectId: string) => + [...queryKeys.assets.all, 'byProject', projectId] as const, + }, + + // Element Defaults + elementDefaults: { + all: ['elementDefaults'] as const, + global: () => [...queryKeys.elementDefaults.all, 'global'] as const, + project: (projectId: string) => + [...queryKeys.elementDefaults.all, 'project', projectId] as const, + }, + + // Users + users: { + all: ['users'] as const, + list: (filters?: unknown) => + [...queryKeys.users.all, 'list', filters] as const, + current: () => [...queryKeys.users.all, 'current'] as const, + detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const, + }, + + // Roles + roles: { + all: ['roles'] as const, + list: (filters?: unknown) => + [...queryKeys.roles.all, 'list', filters] as const, + detail: (id: string) => [...queryKeys.roles.all, 'detail', id] as const, + }, + + // Permissions + permissions: { + all: ['permissions'] as const, + list: () => [...queryKeys.permissions.all, 'list'] as const, + }, + + // Access Logs + accessLogs: { + all: ['accessLogs'] as const, + list: (filters?: unknown) => + [...queryKeys.accessLogs.all, 'list', filters] as const, + }, + + // Asset Variants + assetVariants: { + all: ['assetVariants'] as const, + list: (assetId: string) => + [...queryKeys.assetVariants.all, 'list', assetId] as const, + }, + + // Project Memberships + projectMemberships: { + all: ['projectMemberships'] as const, + list: (projectId: string) => + [...queryKeys.projectMemberships.all, 'list', projectId] as const, + }, + + // Project Audio Tracks + projectAudioTracks: { + all: ['projectAudioTracks'] as const, + list: (projectId: string) => + [...queryKeys.projectAudioTracks.all, 'list', projectId] as const, + detail: (id: string) => + [...queryKeys.projectAudioTracks.all, 'detail', id] as const, + }, + + // Publish Events + publishEvents: { + all: ['publishEvents'] as const, + list: (projectId: string) => + [...queryKeys.publishEvents.all, 'list', projectId] as const, + }, + + // PWA Caches + pwaCaches: { + all: ['pwaCaches'] as const, + list: (projectId: string) => + [...queryKeys.pwaCaches.all, 'list', projectId] as const, + }, +}; + +export default queryClient; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index ed4f941..9cce3f3 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,5 +1,5 @@ import * as icon from '@mdi/js'; -import { MenuAsideItem } from './interfaces'; +import { MenuAsideItem } from './types/menu'; const menuAside: MenuAsideItem[] = [ { diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index 940a495..00e4bb8 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -11,7 +11,7 @@ import { mdiGithub, mdiVuejs, } from '@mdi/js'; -import { MenuNavBarItem } from './interfaces'; +import { MenuNavBarItem } from './types/menu'; const menuNavBar: MenuNavBarItem[] = [ { diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 6e6ec78..f466015 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -5,6 +5,8 @@ import type { NextPage } from 'next'; import Head from 'next/head'; import { store } from '../stores/store'; import { Provider } from 'react-redux'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '../lib/queryClient'; // Import Instrument Sans font (self-hosted via Fontsource) import '@fontsource-variable/instrument-sans'; @@ -150,76 +152,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { } }, []); - // TODO: Remove this code in future releases - React.useEffect(() => { - const trustedOrigins = new Set([window.location.origin]); - if (document.referrer) { - try { - trustedOrigins.add(new URL(document.referrer).origin); - } catch (error) { - logger.warn( - '[postMessage] Failed to parse parent origin from referrer', - error instanceof Error ? error : { error }, - ); - } - } - - const isTrustedOrigin = (origin: string) => trustedOrigins.has(origin); - - const handleMessage = async (event: MessageEvent) => { - if (!isTrustedOrigin(event.origin)) { - logger.warn('[postMessage] Blocked message from untrusted origin', { - origin: event.origin, - }); - return; - } - - if (event.data === 'getLocation') { - (event.source as WindowProxy)?.postMessage( - { iframeLocation: window.location.pathname }, - { targetOrigin: event.origin }, - ); - return; - } - - if (event.data === 'getAuthToken') { - const token = - sessionStorage.getItem('token') || localStorage.getItem('token'); - const user = - sessionStorage.getItem('user') || localStorage.getItem('user'); - (event.source as WindowProxy)?.postMessage( - { iframeAuthToken: token, iframeAuthUser: user }, - { targetOrigin: event.origin }, - ); - return; - } - - if (event.data === 'getScreenshot') { - try { - const html2canvas = (await import('html2canvas')).default; - const canvas = await html2canvas(document.body, { useCORS: true }); - const url = canvas.toDataURL('image/jpeg', 0.8); - (event.source as WindowProxy)?.postMessage( - { iframeScreenshot: url }, - { targetOrigin: event.origin }, - ); - } catch (e) { - logger.error( - 'html2canvas failed', - e instanceof Error ? e : { error: e }, - ); - (event.source as WindowProxy)?.postMessage( - { iframeScreenshot: null }, - { targetOrigin: event.origin }, - ); - } - } - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, []); - React.useEffect(() => { // Tour is disabled by default in generated projects. return; @@ -274,11 +206,12 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { const imageHeight = '960'; return ( - - - {getLayout( - <> - + + + + {getLayout( + <> + @@ -332,8 +265,9 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { /> , )} - - + + + ); } diff --git a/frontend/src/pages/access_logs/[access_logsId].tsx b/frontend/src/pages/access_logs/[access_logsId].tsx index 3680897..0ec6d17 100644 --- a/frontend/src/pages/access_logs/[access_logsId].tsx +++ b/frontend/src/pages/access_logs/[access_logsId].tsx @@ -52,7 +52,7 @@ const EditAccess_logs = () => { }; const [initialValues, setInitialValues] = useState(initVals); - const { access_logs } = useAppSelector((state) => state.access_logs); + const access_logs = useAppSelector((state) => state.access_logs.data); const { access_logsId } = router.query; const accessLogIdStr = Array.isArray(access_logsId) @@ -66,17 +66,18 @@ const EditAccess_logs = () => { }, [accessLogIdStr, dispatch]); useEffect(() => { - if ( - access_logs && - typeof access_logs === 'object' && - !Array.isArray(access_logs) - ) { + // access_logs is now always an array; get the first element for single entity view + const entity = Array.isArray(access_logs) + ? access_logs[0] + : access_logs; + + if (entity && typeof entity === 'object') { const newInitialVal = { ...initVals }; Object.keys(initVals).forEach( (el) => (newInitialVal[el as keyof typeof initVals] = ( - access_logs as Record + entity as unknown as Record )[el] as never), ); diff --git a/frontend/src/pages/access_logs/access_logs-edit.tsx b/frontend/src/pages/access_logs/access_logs-edit.tsx index d32c292..8fb466f 100644 --- a/frontend/src/pages/access_logs/access_logs-edit.tsx +++ b/frontend/src/pages/access_logs/access_logs-edit.tsx @@ -47,7 +47,7 @@ const EditAccess_logsPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => state.access_logs.access_logs, + entitySelector: (state) => state.access_logs.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/access_logs/access_logs-view.tsx b/frontend/src/pages/access_logs/access_logs-view.tsx index 552b423..41f5cff 100644 --- a/frontend/src/pages/access_logs/access_logs-view.tsx +++ b/frontend/src/pages/access_logs/access_logs-view.tsx @@ -21,12 +21,9 @@ const Access_logsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const accessLogsState = useAppSelector((state) => state.access_logs); - // When fetching single item, it's stored as the entity object (not array) - const access_logs = accessLogsState.access_logs as - | AccessLog - | AccessLog[] - | undefined; - const accessLog = Array.isArray(access_logs) ? access_logs[0] : access_logs; + // When fetching single item, it's stored in the data array + const access_logs = accessLogsState.data; + const accessLog = Array.isArray(access_logs) ? access_logs[0] : undefined; const { id } = router.query; const idStr = Array.isArray(id) ? id[0] : id; diff --git a/frontend/src/pages/asset_variants/[asset_variantsId].tsx b/frontend/src/pages/asset_variants/[asset_variantsId].tsx index 765d647..9f0481c 100644 --- a/frontend/src/pages/asset_variants/[asset_variantsId].tsx +++ b/frontend/src/pages/asset_variants/[asset_variantsId].tsx @@ -47,13 +47,8 @@ const EditAsset_variants = () => { 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 asset_variants = assetVariantsState.data; + const assetVariant = asset_variants[0]; const asset_variantsId = Array.isArray(router.query.asset_variantsId) ? router.query.asset_variantsId[0] diff --git a/frontend/src/pages/asset_variants/asset_variants-edit.tsx b/frontend/src/pages/asset_variants/asset_variants-edit.tsx index c610f07..e2ead1c 100644 --- a/frontend/src/pages/asset_variants/asset_variants-edit.tsx +++ b/frontend/src/pages/asset_variants/asset_variants-edit.tsx @@ -35,7 +35,7 @@ const EditAsset_variantsPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.asset_variants.asset_variants, + entitySelector: (state) => state.asset_variants.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/asset_variants/asset_variants-view.tsx b/frontend/src/pages/asset_variants/asset_variants-view.tsx index 81dc423..5cf9b0b 100644 --- a/frontend/src/pages/asset_variants/asset_variants-view.tsx +++ b/frontend/src/pages/asset_variants/asset_variants-view.tsx @@ -34,15 +34,10 @@ const Asset_variantsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const assetVariantsState = useAppSelector((state) => state.asset_variants); - const asset_variants = assetVariantsState.asset_variants as - | AssetVariantData - | AssetVariantData[] - | undefined; + const asset_variants = assetVariantsState.data as AssetVariantData[]; // Get the single entity (not array) for view page - const entity = Array.isArray(asset_variants) - ? asset_variants[0] - : asset_variants; + const entity = asset_variants[0]; const id = Array.isArray(router.query.id) ? router.query.id[0] diff --git a/frontend/src/pages/assets/[assetsId].tsx b/frontend/src/pages/assets/[assetsId].tsx index 36842cf..ade840b 100644 --- a/frontend/src/pages/assets/[assetsId].tsx +++ b/frontend/src/pages/assets/[assetsId].tsx @@ -56,8 +56,8 @@ const EditAssets = () => { 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 assets = assetsState.data; + const asset = assets[0]; const { assetsId } = router.query; const idStr = Array.isArray(assetsId) ? assetsId[0] : assetsId; diff --git a/frontend/src/pages/assets/assets-edit.tsx b/frontend/src/pages/assets/assets-edit.tsx index b217678..8bf5be3 100644 --- a/frontend/src/pages/assets/assets-edit.tsx +++ b/frontend/src/pages/assets/assets-edit.tsx @@ -52,7 +52,7 @@ const EditAssetsPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => state.assets.assets, + entitySelector: (state) => state.assets.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/assets/assets-list.tsx b/frontend/src/pages/assets/assets-list.tsx index 0411784..84b8396 100644 --- a/frontend/src/pages/assets/assets-list.tsx +++ b/frontend/src/pages/assets/assets-list.tsx @@ -82,7 +82,7 @@ const ASSET_SECTIONS: AssetSection[] = [ const AssetsTablesPage = () => { const dispatch = useAppDispatch(); const { currentUser } = useAppSelector((state) => state.auth); - const assets = useAppSelector((state) => state.assets.assets) as Asset[]; + const assets = useAppSelector((state) => state.assets.data) as Asset[]; const isLoadingAssets = useAppSelector((state) => state.assets.loading); const [deletingAssetId, setDeletingAssetId] = useState(''); diff --git a/frontend/src/pages/assets/assets-view.tsx b/frontend/src/pages/assets/assets-view.tsx index 7a3b2d5..e17516f 100644 --- a/frontend/src/pages/assets/assets-view.tsx +++ b/frontend/src/pages/assets/assets-view.tsx @@ -35,11 +35,8 @@ const AssetsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const assetsState = useAppSelector((state) => state.assets); - const assets = assetsState.assets as - | AssetWithVariants - | AssetWithVariants[] - | undefined; - const asset = Array.isArray(assets) ? assets[0] : assets; + const assets = assetsState.data as AssetWithVariants[]; + const asset = assets[0]; const { id } = router.query; const idStr = Array.isArray(id) ? id[0] : id; diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index f1ae2c7..b1d2b4c 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -1,5 +1,4 @@ import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js'; -import axios from 'axios'; import Head from 'next/head'; import { useRouter } from 'next/router'; import React, { @@ -22,7 +21,6 @@ import { BackdropPortalProvider } from '../components/BackdropPortal'; import { getPageTitle } from '../config'; import LayoutAuthenticated from '../layouts/Authenticated'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; -import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; @@ -46,19 +44,19 @@ import { isDescriptionElementType, isMediaElementType, isVideoPlayerElementType, + clamp, } from '../lib/elementDefaults'; -import type { PreloadPageLink, PreloadElement } from '../types/preload'; import type { CanvasElementType, CanvasElement, ConstructorSchema, ConstructorAsset as ProjectAsset, - NormalizedElementDefault, -} from '../types/constructor'; -import { - normalizeElementDefault, - buildElementDefaultsMap, + EditorMenuItem, + GalleryCard, + GalleryInfoSpan, + CarouselSlide, } from '../types/constructor'; +import type { TourPage } from '../types/entities'; // Constructor-specific hooks import { @@ -76,47 +74,26 @@ import { useCanvasElementDrag } from '../hooks/useCanvasElementDrag'; import { useTransitionPreview } from '../hooks/useTransitionPreview'; import { useConstructorPageActions } from '../hooks/useConstructorPageActions'; import { useConstructorElements } from '../hooks/useConstructorElements'; +import { usePageBackground } from '../hooks/usePageBackground'; +import { useConstructorData } from '../hooks/useConstructorData'; +import { useAssetOptions } from '../hooks/useAssetOptions'; +import { useTransitionCreation } from '../hooks/useTransitionCreation'; +import { + ConstructorProvider, + type ConstructorContextValue, + type NavigationElementType, +} from '../context/ConstructorContext'; // Constructor helpers (extracted utilities) import { - clamp, getAssetLabel, getAssetSourceValue, isBackgroundImageAsset, } from '../lib/constructorHelpers'; -type TourPage = { - id: string; - name?: string; - slug?: string; - sort_order?: number; - environment?: 'dev' | 'stage' | 'production'; - source_key?: string; - requires_auth?: boolean; - ui_schema_json?: string; - background_image_url?: string; - background_video_url?: string; - background_audio_url?: string; - background_loop?: boolean; - // Background video playback settings - background_video_autoplay?: boolean; - background_video_loop?: boolean; - background_video_muted?: boolean; - background_video_start_time?: number | null; - background_video_end_time?: number | null; -}; +// TourPage type is imported from '../types/entities' +// NavigationElementType is imported from '../context/ConstructorContext' -type NavigationElementType = Extract< - CanvasElementType, - 'navigation_next' | 'navigation_prev' ->; - -type EditorMenuItem = - | 'none' - | 'background_image' - | 'background_video' - | 'background_audio' - | 'create_transition'; type ConstructorPageProps = { mode?: 'constructor' | 'element_edit'; @@ -131,6 +108,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const router = useRouter(); const canvasRef = useRef(null); const elementEditorRef = useRef(null); + const menuRef = useRef(null); const [isAuthReady, setIsAuthReady] = useState(false); const isElementEditMode = mode === 'element_edit'; @@ -156,36 +134,52 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { return String(value || ''); }, [router.query.elementId]); - const [pages, setPages] = useState([]); - const [pageLinks, setPageLinks] = useState([]); - const [allPagesPreloadElements, setAllPagesPreloadElements] = useState< - PreloadElement[] - >([]); - const [assets, setAssets] = useState([]); - const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState< - Partial>> - >({}); - const [activePageId, setActivePageId] = useState(''); - const [projectName, setProjectName] = useState(''); + // Use React Query for data fetching (replaces manual loadData) + const { + pages, + pageLinks, + allPagesPreloadElements, + assets, + uiElementDefaultsByType, + projectName, + isLoading: isDataLoading, + isError: isDataError, + error: dataError, + refetch: refetchData, + } = useConstructorData({ + projectId, + isAuthReady, + }); + + const [activePageId, setActivePageId] = useState(''); + + // Consolidated page background state (replaces 8 separate useState hooks) + const { + background: pageBackground, + setBackground: setPageBackground, + updateFromPage: updateBackgroundFromPage, + setImageUrl: setBackgroundImageUrl, + setVideoUrl: setBackgroundVideoUrl, + setAudioUrl: setBackgroundAudioUrl, + setVideoSettings: setBackgroundVideoSettings, + // Legacy compatibility values for components that expect flat props + backgroundImageUrl, + backgroundVideoUrl, + backgroundAudioUrl, + backgroundVideoAutoplay, + backgroundVideoLoop, + backgroundVideoMuted, + backgroundVideoStartTime, + backgroundVideoEndTime, + } = usePageBackground(); - const [backgroundImageUrl, setBackgroundImageUrl] = useState(''); - const [backgroundVideoUrl, setBackgroundVideoUrl] = useState(''); - const [backgroundAudioUrl, setBackgroundAudioUrl] = useState(''); - // Background video playback settings - const [backgroundVideoAutoplay, setBackgroundVideoAutoplay] = useState(true); - const [backgroundVideoLoop, setBackgroundVideoLoop] = useState(true); - const [backgroundVideoMuted, setBackgroundVideoMuted] = useState(true); - const [backgroundVideoStartTime, setBackgroundVideoStartTime] = useState< - number | null - >(null); - const [backgroundVideoEndTime, setBackgroundVideoEndTime] = useState< - number | null - >(null); const [selectedMenuItem, setSelectedMenuItem] = useState('none'); // Transition preview state managed by useTransitionPreview hook (below) - const [isLoading, setIsLoading] = useState(true); + // Combined loading state: initial auth check + data loading + const [isInitializing, setIsInitializing] = useState(true); + const isLoading = isInitializing || isDataLoading; // isSaving, isSavingToStage, isCreatingPage are managed by useConstructorPageActions hook const [newTransitionName, setNewTransitionName] = useState(''); const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState(''); @@ -341,6 +335,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { } : undefined, }); + // Destructure stable callback reference to avoid infinite loops in useEffect deps + const pageSwitchToPage = pageSwitch.switchToPage; // Helper to switch pages without flash // Uses usePageSwitch hook to resolve blob URLs from preload cache @@ -352,28 +348,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { lastInitializedPageIdRef.current = page.id; } - // Update storage path state (for editing and saving) - setBackgroundImageUrl(page?.background_image_url || ''); - setBackgroundVideoUrl(page?.background_video_url || ''); - setBackgroundAudioUrl(page?.background_audio_url || ''); - // Set background video playback settings - setBackgroundVideoAutoplay(page?.background_video_autoplay ?? true); - setBackgroundVideoLoop(page?.background_video_loop ?? true); - setBackgroundVideoMuted(page?.background_video_muted ?? true); - // Parse DECIMAL strings from database to numbers - setBackgroundVideoStartTime( - page?.background_video_start_time != null - ? parseFloat(String(page.background_video_start_time)) - : null, - ); - setBackgroundVideoEndTime( - page?.background_video_end_time != null - ? parseFloat(String(page.background_video_end_time)) - : null, - ); + // Update consolidated background state (replaces 8 separate setters) + updateBackgroundFromPage(page); // Use hook to resolve and set blob URLs for display - await pageSwitch.switchToPage( + await pageSwitchToPage( page ? { id: page.id, @@ -389,7 +368,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }, ); }, - [pageSwitch], + [pageSwitchToPage, updateBackgroundFromPage], ); const { isBuffering: isReverseBuffering } = useTransitionPlayback({ @@ -472,104 +451,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { enabled: !isLoading, }); - const imageAssetOptions = useMemo( - () => - assets - .filter( - (asset) => asset.asset_type === 'image' && getAssetSourceValue(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })), - [assets], - ); - const backgroundImageAssetOptions = useMemo( - () => - assets - .filter( - (asset) => - asset.asset_type === 'image' && - getAssetSourceValue(asset) && - isBackgroundImageAsset(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })), - [assets], - ); - const videoAssetOptions = useMemo( - () => - assets - .filter( - (asset) => - asset.asset_type === 'video' && - asset.type !== 'transition' && - getAssetSourceValue(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })), - [assets], - ); - const audioAssetOptions = useMemo( - () => - assets - .filter( - (asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })), - [assets], - ); - const transitionVideoAssetOptions = useMemo(() => { - const typedAssets = assets - .filter( - (asset) => - asset.type === 'transition' && - asset.asset_type === 'video' && - getAssetSourceValue(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })); - - if (typedAssets.length > 0) return typedAssets; - - const taggedAssets = assets - .filter( - (asset) => - asset.asset_type === 'video' && - getAssetSourceValue(asset) && - /\[TRANSITION\]/i.test(String(asset.name || '')), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })); - - return taggedAssets; - }, [assets]); - const iconAssetOptions = useMemo( - () => - assets - .filter( - (asset) => - asset.type === 'icon' && - asset.asset_type === 'image' && - getAssetSourceValue(asset), - ) - .map((asset) => ({ - value: getAssetSourceValue(asset), - label: getAssetLabel(asset), - })), - [assets], - ); + // Use the useAssetOptions hook to derive memoized asset options + const assetOptions = useAssetOptions({ assets }); // Media duration probing with caching const durationProbeTargets = useMemo( () => @@ -592,7 +475,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ], ); - const { getDuration, getDurationNote } = useMediaDurationProbe({ + const { getDuration, getDurationNote, durationBySource } = useMediaDurationProbe({ targets: durationProbeTargets, }); @@ -616,9 +499,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { useEffect(() => { if (newTransitionVideoUrl) return; - if (!transitionVideoAssetOptions.length) return; - setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value); - }, [newTransitionVideoUrl, transitionVideoAssetOptions]); + if (!assetOptions.transitionVideo.length) return; + setNewTransitionVideoUrl(assetOptions.transitionVideo[0].value); + }, [newTransitionVideoUrl, assetOptions.transitionVideo]); useEffect(() => { setElements((prev) => { @@ -644,108 +527,41 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }); }, [getDuration]); - const loadData = useCallback( - async (preservePageId?: string) => { - if (!projectId || !router.isReady || !isAuthReady) return; + // Handle initial page selection when pages are loaded + const prevPagesLengthRef = useRef(0); + useEffect(() => { + // Only set initial page when pages first load (not on every change) + if (pages.length > 0 && prevPagesLengthRef.current === 0) { + const defaultPageId = pageIdFromRoute || pages[0]?.id || ''; + setActivePageId(defaultPageId); + setIsMenuOpen(false); + setIsInitializing(false); + } + prevPagesLengthRef.current = pages.length; + }, [pages, pageIdFromRoute]); - try { - setIsLoading(true); - setErrorMessage(''); - setSuccessMessage(''); + // Handle query errors + useEffect(() => { + if (isDataError && dataError) { + const message = dataError.message || 'Failed to load constructor data.'; + logger.error( + 'Failed to load constructor data:', + dataError, + ); + setErrorMessage(message); + } + }, [isDataError, dataError]); - const [ - projectResponse, - pagesResponse, - assetsResponse, - uiElementsResponse, - ] = await Promise.all([ - axios.get(`/projects/${projectId}`), - axios.get( - `/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`, - ), - axios.get( - `/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${projectId}`, - ), - axios.get( - `/project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`, - ), - ]); - - const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows) - ? pagesResponse.data.rows - : []; - const assetRows: ProjectAsset[] = Array.isArray( - assetsResponse?.data?.rows, - ) - ? assetsResponse.data.rows - : []; - setProjectName(projectResponse?.data?.name || ''); - setPages(pageRows); - - // Extract page links and preload elements using shared utility - const { - pageLinks: syntheticPageLinks, - preloadElements: allPreloadElements, - } = extractPageLinksAndElements(pageRows); - - setPageLinks(syntheticPageLinks); - setAllPagesPreloadElements(allPreloadElements); - setAssets(assetRows); - - // Process project element defaults using shared utilities - const uiElementRows = Array.isArray(uiElementsResponse?.data?.rows) - ? uiElementsResponse.data.rows - : []; - const normalizedDefaults = uiElementRows - .map((row: Record) => normalizeElementDefault(row)) - .filter( - ( - d: NormalizedElementDefault | null, - ): d is NormalizedElementDefault => d !== null, - ); - const defaultsByType = buildElementDefaultsMap(normalizedDefaults); - setUiElementDefaultsByType(defaultsByType); - - // Preserve current page if specified and it still exists, otherwise use route or first page - const preservedPageExists = - preservePageId && pageRows.some((p: any) => p.id === preservePageId); - const defaultPageId = preservedPageExists - ? preservePageId - : pageIdFromRoute || pageRows[0]?.id || ''; - setActivePageId(defaultPageId); - setIsMenuOpen(false); - } catch (error: any) { - if (error?.response?.status === 401) { - const message = 'Your session has expired. Please sign in again.'; - logger.error( - 'Unauthorized constructor request:', - error instanceof Error ? error : { error }, - ); - setErrorMessage(message); - setPages([]); - setAssets([]); - router.replace('/login'); - return; - } - - const message = - error?.response?.data?.message || - error?.message || - 'Failed to load constructor data.'; - logger.error( - 'Failed to load constructor data:', - error instanceof Error ? error : { error }, - ); - setErrorMessage(message); - setPages([]); - setAssets([]); - setUiElementDefaultsByType({}); - } finally { - setIsLoading(false); - } - }, - [isAuthReady, pageIdFromRoute, projectId, router], - ); + // Refetch wrapper that preserves current page + const handleReload = useCallback(async () => { + const currentPageId = activePageId; + await refetchData(); + // After refetch, restore the active page if it still exists + // This is handled by the pages effect above + if (currentPageId && pages.some((p) => p.id === currentPageId)) { + setActivePageId(currentPageId); + } + }, [activePageId, pages, refetchData]); // Page actions (save, create page, save to stage) const { @@ -763,15 +579,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { activePage, activePageId, elements, - backgroundImageUrl, - backgroundVideoUrl, - backgroundAudioUrl, - backgroundVideoAutoplay, - backgroundVideoLoop, - backgroundVideoMuted, - backgroundVideoStartTime, - backgroundVideoEndTime, - onReload: loadData, + pageBackground, + onReload: handleReload, onSetActivePageId: setActivePageId, onSetMenuOpen: setIsMenuOpen, onError: setErrorMessage, @@ -803,9 +612,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ); }, [isElementEditMode, projectId, router]); - useEffect(() => { - loadData(); - }, [loadData]); + // React Query handles data fetching automatically based on projectId and isAuthReady // Panel initial positions are handled by useDraggable hooks @@ -824,9 +631,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { if (!activePage) { setElements([]); clearSelection(); - setBackgroundImageUrl(''); - setBackgroundVideoUrl(''); - setBackgroundAudioUrl(''); + updateBackgroundFromPage(null); return; } @@ -856,7 +661,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { item.appearDurationSec, ), galleryCards: Array.isArray(item.galleryCards) - ? item.galleryCards.map((card: any) => ({ + ? item.galleryCards.map((card: Partial) => ({ id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl ?? ''), title: String(card?.title ?? ''), @@ -872,7 +677,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ? item.galleryTitle : undefined, galleryInfoSpans: Array.isArray(item.galleryInfoSpans) - ? item.galleryInfoSpans.map((span: any) => ({ + ? item.galleryInfoSpans.map((span: Partial) => ({ id: String(span?.id || createLocalId()), text: String(span?.text ?? ''), iconUrl: span?.iconUrl ? String(span.iconUrl) : undefined, @@ -883,7 +688,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ? item.galleryColumns : undefined, carouselSlides: Array.isArray(item.carouselSlides) - ? item.carouselSlides.map((slide: any) => ({ + ? item.carouselSlides.map((slide: Partial) => ({ id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl ?? ''), caption: String(slide?.caption ?? ''), @@ -1056,30 +861,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { clearSelection(); } // If current selection is still valid, do nothing (keep current) - // Set storage paths for editing/saving - setBackgroundImageUrl(activePage.background_image_url || ''); - setBackgroundVideoUrl(activePage.background_video_url || ''); - setBackgroundAudioUrl(activePage.background_audio_url || ''); - // Set background video playback settings - setBackgroundVideoAutoplay(activePage.background_video_autoplay ?? true); - setBackgroundVideoLoop(activePage.background_video_loop ?? true); - setBackgroundVideoMuted(activePage.background_video_muted ?? true); - // Parse DECIMAL strings from database to numbers - setBackgroundVideoStartTime( - activePage.background_video_start_time != null - ? parseFloat(String(activePage.background_video_start_time)) - : null, - ); - setBackgroundVideoEndTime( - activePage.background_video_end_time != null - ? parseFloat(String(activePage.background_video_end_time)) - : null, - ); + // Update consolidated background state (replaces 8 separate setters) + updateBackgroundFromPage(activePage); + // Resolve blob URLs via hook for display (handles initial load and route changes) // Only call if this page wasn't already initialized via switchToPage function if (lastInitializedPageIdRef.current !== activePage.id) { lastInitializedPageIdRef.current = activePage.id; - pageSwitch.switchToPage({ + pageSwitchToPage({ id: activePage.id, background_image_url: activePage.background_image_url, background_video_url: activePage.background_video_url, @@ -1090,10 +879,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { activePage, elementIdFromRoute, uiElementDefaultsByType, - pageSwitch.switchToPage, + pageSwitchToPage, clearSelection, selectElement, setElements, + updateBackgroundFromPage, ]); useEffect(() => { @@ -1125,8 +915,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }, [isConstructorEditMode, cancelElementDrag, clearSelection]); // Outside click detection to clear element/menu selection + // Ignore clicks on menu to allow menu item selection useOutsideClick({ containerRef: elementEditorRef, + ignoreRefs: [menuRef], ignoreDataAttribute: 'data-constructor-element-id', selectedValue: selectedElementId, onOutsideClick: useCallback(() => { @@ -1305,6 +1097,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }; // URL resolver that uses preloaded blob URLs when available + // Depends on readyUrlsVersion to re-render when blob URLs become ready after preload const resolveUrlWithBlob = useCallback( (url: string | undefined): string => { if (!url) return ''; @@ -1319,17 +1112,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { // Fall back to standard resolution return resolveAssetPlaybackUrl(url); }, - [preloadOrchestrator], + [preloadOrchestrator, preloadOrchestrator.readyUrlsVersion], ); const canvasBackgroundStyle: React.CSSProperties = {}; - // Prefer hook's blob URLs, then try cached blob URLs, finally fall back to direct URLs - const backgroundImageSrc = - pageSwitch.currentBgImageUrl || resolveUrlWithBlob(backgroundImageUrl); - const backgroundVideoSrc = - pageSwitch.currentBgVideoUrl || resolveUrlWithBlob(backgroundVideoUrl); - const backgroundAudioSrc = - pageSwitch.currentBgAudioUrl || resolveUrlWithBlob(backgroundAudioUrl); + // Use user's selection (backgroundImageUrl etc) as source of truth for display. + // Resolve via blob cache when available, fall back to pageSwitch state during transitions. + const backgroundImageSrc = backgroundImageUrl + ? resolveUrlWithBlob(backgroundImageUrl) + : pageSwitch.currentBgImageUrl; + const backgroundVideoSrc = backgroundVideoUrl + ? resolveUrlWithBlob(backgroundVideoUrl) + : pageSwitch.currentBgVideoUrl; + const backgroundAudioSrc = backgroundAudioUrl + ? resolveUrlWithBlob(backgroundAudioUrl) + : pageSwitch.currentBgAudioUrl; const hasEditorSelection = isConstructorEditMode && @@ -1351,8 +1148,181 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { canvasBackgroundStyle.backgroundPosition = 'center'; } + // Duration notes for UI display + const durationNotes = useMemo( + () => ({ + backgroundVideo: backgroundVideoDurationNote, + backgroundAudio: backgroundAudioDurationNote, + selectedMedia: selectedMediaDurationNote, + selectedTransition: selectedTransitionDurationNote, + newTransition: newTransitionDurationNote, + }), + [ + backgroundVideoDurationNote, + backgroundAudioDurationNote, + selectedMediaDurationNote, + selectedTransitionDurationNote, + newTransitionDurationNote, + ], + ); + + // Transition creation state for context + const transitionCreationState = useMemo( + () => ({ + name: newTransitionName, + videoUrl: newTransitionVideoUrl, + supportsReverse: newTransitionSupportsReverse, + isCreating: isCreatingTransition, + setName: setNewTransitionName, + setVideoUrl: setNewTransitionVideoUrl, + setSupportsReverse: setNewTransitionSupportsReverse, + create: () => + createTransition({ + name: newTransitionName, + videoUrl: newTransitionVideoUrl, + supportsReverse: newTransitionSupportsReverse, + durationSec: getDuration(newTransitionVideoUrl), + }), + }), + [ + newTransitionName, + newTransitionVideoUrl, + newTransitionSupportsReverse, + isCreatingTransition, + createTransition, + getDuration, + ], + ); + + // Build context value for ConstructorProvider + // This allows child components to access constructor state without prop drilling + const constructorContextValue: ConstructorContextValue = useMemo( + () => ({ + // Project state + projectId, + + // Page state + pages, + activePageId, + activePage, + setActivePageId, + + // Background state + pageBackground, + setPageBackground, + updateBackgroundFromPage, + + // Background convenience setters + setBackgroundImageUrl, + setBackgroundVideoUrl, + setBackgroundAudioUrl, + setBackgroundVideoSettings, + + // Element state + elements, + setElements, + selectedElementId, + selectedElement, + selectElement, + clearSelection, + updateElement: (id: string, patch: Partial) => { + setElements((prev) => + prev.map((el) => (el.id === id ? { ...el, ...patch } : el)), + ); + }, + removeElement: (id: string) => { + setElements((prev) => prev.filter((el) => el.id !== id)); + if (selectedElementId === id) { + clearSelection(); + } + }, + updateSelectedElement, + removeSelectedElement, + + // Menu state + selectedMenuItem, + setSelectedMenuItem, + isMenuOpen, + setIsMenuOpen, + + // Editor state + elementEditorTab, + setElementEditorTab, + + // Assets + assets, + isLoadingAssets: isDataLoading, + + // Asset options (derived from assets) + assetOptions, + + // Gallery/Carousel operations + galleryCards, + galleryInfoSpans, + carouselSlides, + + // Duration resolver + getDuration, + + // Duration notes + durationNotes, + + // Transition preview + onPreviewTransition: openTransitionPreview, + + // Transition creation + transitionCreation: transitionCreationState, + + // Navigation settings + allowedNavigationTypes, + normalizeNavigationType, + + // Actions + save: saveConstructor, + isSaving, + }), + [ + projectId, + pages, + activePageId, + activePage, + pageBackground, + setPageBackground, + updateBackgroundFromPage, + setBackgroundImageUrl, + setBackgroundVideoUrl, + setBackgroundAudioUrl, + setBackgroundVideoSettings, + elements, + setElements, + selectedElementId, + selectedElement, + selectElement, + clearSelection, + updateSelectedElement, + removeSelectedElement, + selectedMenuItem, + isMenuOpen, + elementEditorTab, + assets, + isDataLoading, + assetOptions, + galleryCards, + galleryInfoSpans, + carouselSlides, + getDuration, + durationNotes, + openTransitionPreview, + transitionCreationState, + allowedNavigationTypes, + normalizeNavigationType, + saveConstructor, + isSaving, + ], + ); + return ( - <> + {getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')} @@ -1494,6 +1464,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { </BackdropPortalProvider> </div> + {/* ElementEditorPanel now uses ConstructorContext for all state */} {pages.length > 0 && hasEditorSelection && ( <ElementEditorPanel elementEditorRef={elementEditorRef} @@ -1502,77 +1473,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { onToggleCollapse={() => setIsEditorCollapsed((prev) => !prev)} onDragStart={onElementEditorDragStart} title={editorTitle} - activeTab={elementEditorTab} - onTabChange={setElementEditorTab} - selectedElement={selectedElement} - selectedMenuItem={selectedMenuItem} - onRemoveElement={removeSelectedElement} - onUpdateElement={updateSelectedElement} - backgroundImageUrl={backgroundImageUrl} - backgroundVideoUrl={backgroundVideoUrl} - backgroundAudioUrl={backgroundAudioUrl} - onBackgroundImageChange={setBackgroundImageUrl} - onBackgroundVideoChange={setBackgroundVideoUrl} - onBackgroundAudioChange={setBackgroundAudioUrl} - backgroundVideoAutoplay={backgroundVideoAutoplay} - backgroundVideoLoop={backgroundVideoLoop} - backgroundVideoMuted={backgroundVideoMuted} - backgroundVideoStartTime={backgroundVideoStartTime} - backgroundVideoEndTime={backgroundVideoEndTime} - onBackgroundVideoSettingsChange={(settings) => { - if (settings.autoplay !== undefined) - setBackgroundVideoAutoplay(settings.autoplay); - if (settings.loop !== undefined) - setBackgroundVideoLoop(settings.loop); - if (settings.muted !== undefined) - setBackgroundVideoMuted(settings.muted); - if (settings.startTime !== undefined) - setBackgroundVideoStartTime(settings.startTime); - if (settings.endTime !== undefined) - setBackgroundVideoEndTime(settings.endTime); - }} - newTransitionName={newTransitionName} - newTransitionVideoUrl={newTransitionVideoUrl} - newTransitionSupportsReverse={newTransitionSupportsReverse} - isCreatingTransition={isCreatingTransition} - onNewTransitionNameChange={setNewTransitionName} - onNewTransitionVideoUrlChange={setNewTransitionVideoUrl} - onNewTransitionSupportsReverseChange={ - setNewTransitionSupportsReverse - } - onCreateTransition={() => - createTransition({ - name: newTransitionName, - videoUrl: newTransitionVideoUrl, - supportsReverse: newTransitionSupportsReverse, - durationSec: getDuration(newTransitionVideoUrl), - }) - } - backgroundVideoDurationNote={backgroundVideoDurationNote} - backgroundAudioDurationNote={backgroundAudioDurationNote} - newTransitionDurationNote={newTransitionDurationNote} - selectedMediaDurationNote={selectedMediaDurationNote} - selectedTransitionDurationNote={selectedTransitionDurationNote} - backgroundImageAssetOptions={backgroundImageAssetOptions} - videoAssetOptions={videoAssetOptions} - audioAssetOptions={audioAssetOptions} - transitionVideoAssetOptions={transitionVideoAssetOptions} - iconAssetOptions={iconAssetOptions} - imageAssetOptions={imageAssetOptions} - allowedNavigationTypes={allowedNavigationTypes} - pages={pages} - activePageId={activePageId} - onPreviewTransition={openTransitionPreview} - galleryCards={galleryCards} - galleryInfoSpans={galleryInfoSpans} - carouselSlides={carouselSlides} - normalizeNavigationType={normalizeNavigationType} - getDuration={getDuration} /> )} {pages.length > 0 && !isElementEditMode && ( <ConstructorMenu + ref={menuRef} position={menuPosition} isOpen={isMenuOpen} allowedNavigationTypes={allowedNavigationTypes} @@ -1650,7 +1556,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { background: #f3f4f6; } `}</style> - </> + </ConstructorProvider> ); }; diff --git a/frontend/src/pages/element-type-defaults.tsx b/frontend/src/pages/element-type-defaults.tsx index ec55f0d..e627cb1 100644 --- a/frontend/src/pages/element-type-defaults.tsx +++ b/frontend/src/pages/element-type-defaults.tsx @@ -41,10 +41,11 @@ const ElementTypeDefaultsPage = () => { ? response.data.rows : []; setRows(nextRows); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to load element type defaults.'; logger.error( 'Failed to load element type defaults:', diff --git a/frontend/src/pages/permissions/[permissionsId].tsx b/frontend/src/pages/permissions/[permissionsId].tsx index f33a9a6..ab58527 100644 --- a/frontend/src/pages/permissions/[permissionsId].tsx +++ b/frontend/src/pages/permissions/[permissionsId].tsx @@ -42,11 +42,8 @@ const EditPermissions = () => { const [initialValues, setInitialValues] = useState(initVals); const permissionsState = useAppSelector((state) => state.permissions); - const permissions = permissionsState.permissions as - | PermissionEntity - | PermissionEntity[] - | undefined; - const permission = Array.isArray(permissions) ? permissions[0] : permissions; + const permissions = permissionsState.data; + const permission = permissions[0]; const { permissionsId } = router.query; const idStr = Array.isArray(permissionsId) ? permissionsId[0] : permissionsId; diff --git a/frontend/src/pages/permissions/permissions-edit.tsx b/frontend/src/pages/permissions/permissions-edit.tsx index 34e4878..ff9a19c 100644 --- a/frontend/src/pages/permissions/permissions-edit.tsx +++ b/frontend/src/pages/permissions/permissions-edit.tsx @@ -28,7 +28,7 @@ const EditPermissionsPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.permissions.permissions, + entitySelector: (state) => state.permissions.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/permissions/permissions-view.tsx b/frontend/src/pages/permissions/permissions-view.tsx index 62335af..8b41650 100644 --- a/frontend/src/pages/permissions/permissions-view.tsx +++ b/frontend/src/pages/permissions/permissions-view.tsx @@ -25,11 +25,8 @@ const PermissionsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const permissionsState = useAppSelector((state) => state.permissions); - const permissions = permissionsState.permissions as - | PermissionEntity - | PermissionEntity[] - | undefined; - const permission = Array.isArray(permissions) ? permissions[0] : permissions; + const permissions = permissionsState.data; + const permission = permissions[0]; const { id } = router.query; const idStr = Array.isArray(id) ? id[0] : id; diff --git a/frontend/src/pages/presigned_url_requests/[presigned_url_requestsId].tsx b/frontend/src/pages/presigned_url_requests/[presigned_url_requestsId].tsx index 6b8fb6e..6703396 100644 --- a/frontend/src/pages/presigned_url_requests/[presigned_url_requestsId].tsx +++ b/frontend/src/pages/presigned_url_requests/[presigned_url_requestsId].tsx @@ -46,13 +46,8 @@ const EditPresigned_url_requests = () => { 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 presigned_url_requests = presignedState.data; + const presignedRequest = presigned_url_requests[0]; const { presigned_url_requestsId } = router.query as { presigned_url_requestsId?: string; 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 8d0a752..8f119ff 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 @@ -52,8 +52,7 @@ const EditPresigned_url_requestsPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => - state.presigned_url_requests.presigned_url_requests, + entitySelector: (state) => state.presigned_url_requests.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/presigned_url_requests/presigned_url_requests-view.tsx b/frontend/src/pages/presigned_url_requests/presigned_url_requests-view.tsx index 6f33b87..3cfac0f 100644 --- a/frontend/src/pages/presigned_url_requests/presigned_url_requests-view.tsx +++ b/frontend/src/pages/presigned_url_requests/presigned_url_requests-view.tsx @@ -23,13 +23,8 @@ const Presigned_url_requestsView = () => { 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 presigned_url_requests = presignedState.data; + const presignedRequest = presigned_url_requests[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index 2fc8a7b..41cafa3 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -52,7 +52,7 @@ const EditUsers = () => { const newInitialVal = { ...initVals }; Object.keys(initVals).forEach( - (el) => (newInitialVal[el] = currentUser[el]), + (el) => (newInitialVal[el] = currentUser[el] ?? initVals[el]), ); setInitialValues(newInitialVal); diff --git a/frontend/src/pages/project-element-defaults.tsx b/frontend/src/pages/project-element-defaults.tsx index 964d3a3..20eee47 100644 --- a/frontend/src/pages/project-element-defaults.tsx +++ b/frontend/src/pages/project-element-defaults.tsx @@ -49,8 +49,8 @@ const ProjectElementDefaultsPage = () => { try { const response = await axios.get(`/projects/${projectId}`); setProject(response?.data || null); - } catch (error: any) { - logger.error('Failed to load project:', error); + } catch (error: unknown) { + logger.error('Failed to load project:', error instanceof Error ? error : { error }); } }, [projectId]); @@ -70,10 +70,11 @@ const ProjectElementDefaultsPage = () => { ? response.data.rows : []; setRows(nextRows); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; const message = - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to load project element defaults.'; logger.error( 'Failed to load project element defaults:', diff --git a/frontend/src/pages/project_audio_tracks/[project_audio_tracksId].tsx b/frontend/src/pages/project_audio_tracks/[project_audio_tracksId].tsx index a3fba3c..fdcab90 100644 --- a/frontend/src/pages/project_audio_tracks/[project_audio_tracksId].tsx +++ b/frontend/src/pages/project_audio_tracks/[project_audio_tracksId].tsx @@ -45,14 +45,8 @@ const EditProject_audio_tracks = () => { 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 project_audio_tracks = project_audio_tracksState.data; + const item = project_audio_tracks[0]; const { project_audio_tracksId } = router.query as { project_audio_tracksId?: string; 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 816cf16..7ac5b52 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 @@ -43,7 +43,7 @@ const EditProject_audio_tracksPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.project_audio_tracks.project_audio_tracks, + entitySelector: (state) => state.project_audio_tracks.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/project_audio_tracks/project_audio_tracks-view.tsx b/frontend/src/pages/project_audio_tracks/project_audio_tracks-view.tsx index 5e7a6f1..ab86e6e 100644 --- a/frontend/src/pages/project_audio_tracks/project_audio_tracks-view.tsx +++ b/frontend/src/pages/project_audio_tracks/project_audio_tracks-view.tsx @@ -22,14 +22,8 @@ const Project_audio_tracksView = () => { 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 project_audio_tracks = project_audio_tracksState.data; + const item = project_audio_tracks[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/project_memberships/[project_membershipsId].tsx b/frontend/src/pages/project_memberships/[project_membershipsId].tsx index 69a92ee..51a2376 100644 --- a/frontend/src/pages/project_memberships/[project_membershipsId].tsx +++ b/frontend/src/pages/project_memberships/[project_membershipsId].tsx @@ -44,13 +44,8 @@ const EditProject_memberships = () => { 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 project_memberships = project_membershipsState.data; + const item = project_memberships[0]; const { project_membershipsId } = router.query as { project_membershipsId?: string; diff --git a/frontend/src/pages/project_memberships/project_memberships-edit.tsx b/frontend/src/pages/project_memberships/project_memberships-edit.tsx index 42c6f72..f7278da 100644 --- a/frontend/src/pages/project_memberships/project_memberships-edit.tsx +++ b/frontend/src/pages/project_memberships/project_memberships-edit.tsx @@ -50,7 +50,7 @@ const EditProject_membershipsPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => state.project_memberships.project_memberships, + entitySelector: (state) => state.project_memberships.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/project_memberships/project_memberships-view.tsx b/frontend/src/pages/project_memberships/project_memberships-view.tsx index 44a81db..971b879 100644 --- a/frontend/src/pages/project_memberships/project_memberships-view.tsx +++ b/frontend/src/pages/project_memberships/project_memberships-view.tsx @@ -25,13 +25,8 @@ const Project_membershipsView = () => { 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 project_memberships = project_membershipsState.data; + const item = project_memberships[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/projects/[projectsId].tsx b/frontend/src/pages/projects/[projectsId].tsx index c37f781..3d59cf6 100644 --- a/frontend/src/pages/projects/[projectsId].tsx +++ b/frontend/src/pages/projects/[projectsId].tsx @@ -58,10 +58,11 @@ const ProjectWorkspacePage = () => { try { const response = await axios.get(`/projects/${projectId}`); setProject(response?.data || null); - } catch (error: any) { + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; setErrorMessage( - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to load project', ); logger.error( @@ -113,15 +114,16 @@ const ProjectWorkspacePage = () => { setIsPublishModalActive(false); setPublishTitle(''); setPublishDescription(''); - } catch (error: any) { + } catch (error: unknown) { logger.error( 'Publish failed:', error instanceof Error ? error - : { error: error?.message || 'Unknown error' }, + : { error: error instanceof Error ? error.message : 'Unknown error' }, ); + const axiosError = error as { response?: { data?: string } }; const message = - error?.response?.data || error?.message || 'Publish failed'; + axiosError?.response?.data || (error instanceof Error ? error.message : null) || 'Publish failed'; toast(typeof message === 'string' ? message : 'Publish failed', { type: 'error', position: 'bottom-center', diff --git a/frontend/src/pages/projects/projects-edit.tsx b/frontend/src/pages/projects/projects-edit.tsx index 031e574..89da84e 100644 --- a/frontend/src/pages/projects/projects-edit.tsx +++ b/frontend/src/pages/projects/projects-edit.tsx @@ -49,8 +49,8 @@ const EditProjectsPage = () => { const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false); const projectsState = useAppSelector((state) => state.projects); - const projects = projectsState.projects as Project | Project[] | undefined; - const project = Array.isArray(projects) ? projects[0] : projects; + const projects = projectsState.data; + const project = projects[0]; const { id } = router.query as { id?: string }; // Fetch entity data diff --git a/frontend/src/pages/projects/projects-list.tsx b/frontend/src/pages/projects/projects-list.tsx index 9b2f122..ccef449 100644 --- a/frontend/src/pages/projects/projects-list.tsx +++ b/frontend/src/pages/projects/projects-list.tsx @@ -22,7 +22,7 @@ const ProjectsListPage = () => { const router = useRouter(); const dispatch = useAppDispatch(); - const projectsRaw = useAppSelector((state) => state.projects.projects) as + const projectsRaw = useAppSelector((state) => state.projects.data) as | Project[] | Project | undefined; diff --git a/frontend/src/pages/projects/projects-view.tsx b/frontend/src/pages/projects/projects-view.tsx index faa6b8a..5b3e935 100644 --- a/frontend/src/pages/projects/projects-view.tsx +++ b/frontend/src/pages/projects/projects-view.tsx @@ -122,11 +122,8 @@ const ProjectsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const projectsState = useAppSelector((state) => state.projects); - const projectsData = projectsState.projects as - | ProjectWithRelations - | ProjectWithRelations[] - | undefined; - const project = Array.isArray(projectsData) ? projectsData[0] : projectsData; + const projectsData = projectsState.data as ProjectWithRelations[]; + const project = projectsData[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/publish_events/[publish_eventsId].tsx b/frontend/src/pages/publish_events/[publish_eventsId].tsx index dd01c54..52602ff 100644 --- a/frontend/src/pages/publish_events/[publish_eventsId].tsx +++ b/frontend/src/pages/publish_events/[publish_eventsId].tsx @@ -43,13 +43,8 @@ const EditPublish_events = () => { 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 publish_events = publish_eventsState.data; + const item = publish_events[0]; const { publish_eventsId } = router.query as { publish_eventsId?: string }; diff --git a/frontend/src/pages/publish_events/publish_events-edit.tsx b/frontend/src/pages/publish_events/publish_events-edit.tsx index 22265d8..5ad9dd0 100644 --- a/frontend/src/pages/publish_events/publish_events-edit.tsx +++ b/frontend/src/pages/publish_events/publish_events-edit.tsx @@ -51,7 +51,7 @@ const EditPublish_eventsPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => state.publish_events.publish_events, + entitySelector: (state) => state.publish_events.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/publish_events/publish_events-list.tsx b/frontend/src/pages/publish_events/publish_events-list.tsx index 9d8be5f..12aef41 100644 --- a/frontend/src/pages/publish_events/publish_events-list.tsx +++ b/frontend/src/pages/publish_events/publish_events-list.tsx @@ -41,7 +41,7 @@ const PublishEventsHistoryPage = () => { }); router.replace('/projects/projects-new'); } - } catch (error) { + } catch (error: unknown) { logger.error( 'Failed to check projects:', error instanceof Error ? error : { error }, @@ -64,14 +64,15 @@ const PublishEventsHistoryPage = () => { ? response.data.rows : []; setItems(rows); - } catch (error: any) { + } catch (error: unknown) { logger.error( 'Failed to load publish history:', error instanceof Error ? error : { error }, ); + const axiosError = error as { response?: { data?: { message?: string } } }; setErrorMessage( - error?.response?.data?.message || - error?.message || + axiosError?.response?.data?.message || + (error instanceof Error ? error.message : null) || 'Failed to load publish history', ); } finally { diff --git a/frontend/src/pages/publish_events/publish_events-view.tsx b/frontend/src/pages/publish_events/publish_events-view.tsx index 17f52f2..fa507f8 100644 --- a/frontend/src/pages/publish_events/publish_events-view.tsx +++ b/frontend/src/pages/publish_events/publish_events-view.tsx @@ -22,13 +22,8 @@ const Publish_eventsView = () => { const dispatch = useAppDispatch(); 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 publish_events = publish_eventsState.data; + const item = publish_events[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/pwa_caches/[pwa_cachesId].tsx b/frontend/src/pages/pwa_caches/[pwa_cachesId].tsx index 3d18e58..4ff0f3a 100644 --- a/frontend/src/pages/pwa_caches/[pwa_cachesId].tsx +++ b/frontend/src/pages/pwa_caches/[pwa_cachesId].tsx @@ -40,11 +40,8 @@ const EditPwa_caches = () => { const [initialValues, setInitialValues] = useState(initVals); const pwaCachesState = useAppSelector((state) => state.pwa_caches); - const pwa_caches = pwaCachesState.pwa_caches as - | PwaCache - | PwaCache[] - | undefined; - const pwaCache = Array.isArray(pwa_caches) ? pwa_caches[0] : pwa_caches; + const pwa_caches = pwaCachesState.data; + const pwaCache = pwa_caches[0]; const { pwa_cachesId } = router.query as { pwa_cachesId?: string }; diff --git a/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx b/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx index e858d7d..a0065f8 100644 --- a/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx +++ b/frontend/src/pages/pwa_caches/pwa_caches-edit.tsx @@ -29,12 +29,12 @@ import { useRouter } from 'next/router'; import { useEditPageSync } from '../../hooks/useEditPageSync'; const initVals = { - project: null, - environment: '', + project: null as string | null, + environment: 'stage' as 'dev' | 'stage' | 'production', cache_version: '', manifest_json: '', asset_list_json: '', - generated_at: new Date(), + generated_at: new Date() as Date | string | null, is_active: false, }; @@ -47,7 +47,7 @@ const EditPwa_cachesPage = () => { setValues, id, } = useEditPageSync({ - entitySelector: (state) => state.pwa_caches.pwa_caches, + entitySelector: (state) => state.pwa_caches.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/pwa_caches/pwa_caches-new.tsx b/frontend/src/pages/pwa_caches/pwa_caches-new.tsx index 3344047..d98a46b 100644 --- a/frontend/src/pages/pwa_caches/pwa_caches-new.tsx +++ b/frontend/src/pages/pwa_caches/pwa_caches-new.tsx @@ -26,12 +26,12 @@ import { useAppDispatch } from '../../stores/hooks'; import { useRouter } from 'next/router'; const initialValues = { - project: '', - environment: 'stage', + project: '' as string | null, + environment: 'stage' as 'dev' | 'stage' | 'production', cache_version: '', manifest_json: '', asset_list_json: '', - generated_at: '', + generated_at: '' as string | Date | null, is_active: false, }; diff --git a/frontend/src/pages/pwa_caches/pwa_caches-view.tsx b/frontend/src/pages/pwa_caches/pwa_caches-view.tsx index c6969e6..59c92eb 100644 --- a/frontend/src/pages/pwa_caches/pwa_caches-view.tsx +++ b/frontend/src/pages/pwa_caches/pwa_caches-view.tsx @@ -22,11 +22,8 @@ const Pwa_cachesView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const pwaCachesState = useAppSelector((state) => state.pwa_caches); - const pwa_caches = pwaCachesState.pwa_caches as - | PwaCache - | PwaCache[] - | undefined; - const pwaCache = Array.isArray(pwa_caches) ? pwa_caches[0] : pwa_caches; + const pwa_caches = pwaCachesState.data; + const pwaCache = pwa_caches[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/roles/[rolesId].tsx b/frontend/src/pages/roles/[rolesId].tsx index b54d442..30d0c10 100644 --- a/frontend/src/pages/roles/[rolesId].tsx +++ b/frontend/src/pages/roles/[rolesId].tsx @@ -31,8 +31,8 @@ const EditRoles = () => { const [initialValues, setInitialValues] = useState(initVals); const rolesState = useAppSelector((state) => state.roles); - const roles = rolesState.roles as Role | Role[] | undefined; - const role = Array.isArray(roles) ? roles[0] : roles; + const roles = rolesState.data; + const role = roles[0]; const { rolesId } = router.query as { rolesId?: string }; diff --git a/frontend/src/pages/roles/roles-edit.tsx b/frontend/src/pages/roles/roles-edit.tsx index 38f4c40..06ae413 100644 --- a/frontend/src/pages/roles/roles-edit.tsx +++ b/frontend/src/pages/roles/roles-edit.tsx @@ -30,7 +30,7 @@ const EditRolesPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.roles.roles, + entitySelector: (state) => state.roles.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/roles/roles-view.tsx b/frontend/src/pages/roles/roles-view.tsx index f454b37..fb7eeac 100644 --- a/frontend/src/pages/roles/roles-view.tsx +++ b/frontend/src/pages/roles/roles-view.tsx @@ -31,11 +31,8 @@ const RolesView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const rolesState = useAppSelector((state) => state.roles); - const roles = rolesState.roles as - | RoleWithRelations - | RoleWithRelations[] - | undefined; - const role = Array.isArray(roles) ? roles[0] : roles; + const roles = rolesState.data as RoleWithRelations[]; + const role = roles[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/pages/tour_pages/[tour_pagesId].tsx b/frontend/src/pages/tour_pages/[tour_pagesId].tsx index 2c52bbc..1837ffc 100644 --- a/frontend/src/pages/tour_pages/[tour_pagesId].tsx +++ b/frontend/src/pages/tour_pages/[tour_pagesId].tsx @@ -42,11 +42,8 @@ const EditTour_pages = () => { 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 tour_pages = tourPagesState.data; + const tourPage = tour_pages[0]; const { tour_pagesId } = router.query as { tour_pagesId?: string }; const idStr = Array.isArray(tour_pagesId) ? tour_pagesId[0] : tour_pagesId; diff --git a/frontend/src/pages/tour_pages/tour_pages-edit.tsx b/frontend/src/pages/tour_pages/tour_pages-edit.tsx index ee2d5a6..e424857 100644 --- a/frontend/src/pages/tour_pages/tour_pages-edit.tsx +++ b/frontend/src/pages/tour_pages/tour_pages-edit.tsx @@ -42,7 +42,7 @@ const EditTour_pagesPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.tour_pages.tour_pages, + entitySelector: (state) => state.tour_pages.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/tour_pages/tour_pages-view.tsx b/frontend/src/pages/tour_pages/tour_pages-view.tsx index 3e4bb0c..7dcfb98 100644 --- a/frontend/src/pages/tour_pages/tour_pages-view.tsx +++ b/frontend/src/pages/tour_pages/tour_pages-view.tsx @@ -43,13 +43,8 @@ const Tour_pagesView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const tourPagesState = useAppSelector((state) => state.tour_pages); - const tour_pages_data = tourPagesState.tour_pages as - | TourPageWithRelations - | TourPageWithRelations[] - | undefined; - const tour_pages = Array.isArray(tour_pages_data) - ? tour_pages_data[0] - : tour_pages_data; + const tour_pages_data = tourPagesState.data as TourPageWithRelations[]; + const tour_pages = tour_pages_data[0]; const { id } = router.query as { id?: string }; const idStr = Array.isArray(id) ? id[0] : id; diff --git a/frontend/src/pages/users/[usersId].tsx b/frontend/src/pages/users/[usersId].tsx index 1105aff..4a84797 100644 --- a/frontend/src/pages/users/[usersId].tsx +++ b/frontend/src/pages/users/[usersId].tsx @@ -41,8 +41,8 @@ const EditUsers = () => { const [initialValues, setInitialValues] = useState(initVals); const usersState = useAppSelector((state) => state.users); - const users = usersState.users as User | User[] | undefined; - const user = Array.isArray(users) ? users[0] : users; + const users = usersState.data; + const user = users[0]; const { usersId } = router.query as { usersId?: string }; diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index fb9bdb1..8490992 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -40,7 +40,7 @@ const EditUsersPage = () => { const dispatch = useAppDispatch(); const { values: initialValues, id } = useEditPageSync({ - entitySelector: (state) => state.users.users, + entitySelector: (state) => state.users.data, fetchAction: fetch, initialValues: initVals, }); diff --git a/frontend/src/pages/users/users-view.tsx b/frontend/src/pages/users/users-view.tsx index 2b873dd..a039267 100644 --- a/frontend/src/pages/users/users-view.tsx +++ b/frontend/src/pages/users/users-view.tsx @@ -63,11 +63,8 @@ const UsersView = () => { const router = useRouter(); const dispatch = useAppDispatch(); const usersState = useAppSelector((state) => state.users); - const users = usersState.users as - | UserWithRelations - | UserWithRelations[] - | undefined; - const user = Array.isArray(users) ? users[0] : users; + const users = usersState.data as UserWithRelations[]; + const user = users[0]; const { id } = router.query as { id?: string }; diff --git a/frontend/src/stores/authSlice.ts b/frontend/src/stores/authSlice.ts index c787801..c0d0d70 100644 --- a/frontend/src/stores/authSlice.ts +++ b/frontend/src/stores/authSlice.ts @@ -1,17 +1,10 @@ import { createSlice, createAsyncThunk, createAction } from '@reduxjs/toolkit'; import axios from 'axios'; import jwt from 'jsonwebtoken'; +import type { User } from '../types/entities'; +import type { AuthState } from '../types/redux'; -interface MainState { - isFetching: boolean; - errorMessage: string; - currentUser: any; - notify: any; - token: string; -} - -const initialState: MainState = { - /* User */ +const initialState: AuthState = { isFetching: false, errorMessage: '', currentUser: null, diff --git a/frontend/src/stores/constructor/constructorSlice.ts b/frontend/src/stores/constructor/constructorSlice.ts new file mode 100644 index 0000000..f6bb092 --- /dev/null +++ b/frontend/src/stores/constructor/constructorSlice.ts @@ -0,0 +1,111 @@ +/** + * Constructor Redux Slice + * + * Manages persistent UI state for the tour constructor. + * This state persists across navigation, so users return to their + * previous editor configuration. + * + * Note: Page data and elements are NOT stored here - they use + * React Query for caching. This slice only handles UI preferences. + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { + EditorMenuItem, + EditorTab, + ConstructorState, +} from '../../types/constructor'; + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: ConstructorState = { + editorTab: 'general', + isMenuOpen: true, + isEditorCollapsed: false, + selectedMenuItem: 'none', + lastActivePageId: {}, + isSidebarCollapsed: false, +}; + +// ============================================================================ +// Slice +// ============================================================================ + +const constructorSlice = createSlice({ + name: 'constructor', + initialState, + reducers: { + /** + * Set the active editor tab + */ + setEditorTab: (state, action: PayloadAction<EditorTab>) => { + state.editorTab = action.payload; + }, + + /** + * Toggle or set menu open state + */ + setMenuOpen: (state, action: PayloadAction<boolean>) => { + state.isMenuOpen = action.payload; + }, + + /** + * Toggle or set editor collapsed state + */ + setEditorCollapsed: (state, action: PayloadAction<boolean>) => { + state.isEditorCollapsed = action.payload; + }, + + /** + * Set the selected menu item + */ + setSelectedMenuItem: (state, action: PayloadAction<EditorMenuItem>) => { + state.selectedMenuItem = action.payload; + }, + + /** + * Remember the last active page for a project + */ + setLastActivePageId: ( + state, + action: PayloadAction<{ projectId: string; pageId: string }>, + ) => { + state.lastActivePageId[action.payload.projectId] = + action.payload.pageId; + }, + + /** + * Toggle or set sidebar collapsed state + */ + setSidebarCollapsed: (state, action: PayloadAction<boolean>) => { + state.isSidebarCollapsed = action.payload; + }, + + /** + * Reset constructor state (e.g., when switching projects) + */ + resetConstructorUI: (state) => { + state.editorTab = 'general'; + state.selectedMenuItem = 'none'; + state.isEditorCollapsed = false; + }, + }, +}); + +// ============================================================================ +// Exports +// ============================================================================ + +export const { + setEditorTab, + setMenuOpen, + setEditorCollapsed, + setSelectedMenuItem, + setLastActivePageId, + setSidebarCollapsed, + resetConstructorUI, +} = constructorSlice.actions; + +export default constructorSlice.reducer; diff --git a/frontend/src/stores/createEntitySlice.ts b/frontend/src/stores/createEntitySlice.ts index 78906ba..09eedca 100644 --- a/frontend/src/stores/createEntitySlice.ts +++ b/frontend/src/stores/createEntitySlice.ts @@ -16,21 +16,14 @@ import { } from '../helpers/notifyStateHandler'; import type { EntitySliceConfig, - NotificationState, EntitySliceState, } from '../types/redux'; import type { PaginatedResponse, FetchParams, ApiError } from '../types/api'; import type { BaseEntity } from '../types/entities'; -// Internal state type that matches legacy structure for backward compatibility -interface InternalSliceState<T> { - [key: string]: T[] | boolean | number | NotificationState | unknown[]; - loading: boolean; - count: number; - refetch: boolean; - rolesWidgets: unknown[]; - notify: NotificationState; -} +// Use the standard EntitySliceState type to ensure type compatibility +// with components that expect EntitySliceState<T> +type InternalSliceState<T> = EntitySliceState<T>; // Type guard to check if response is paginated function isPaginatedResponse<T>( @@ -86,13 +79,12 @@ export function createEntitySlice<T extends BaseEntity>( const displayName = capitalize(singularName || getSingularName(name)); const pluralDisplayName = capitalize(name); - // Create initial state + // Create initial state using 'data' key for entity array const initialState: InternalSliceState<T> = { - [name]: [] as T[], + data: [], loading: false, count: 0, refetch: false, - rolesWidgets: [], notify: { showNotification: false, textNotification: '', @@ -228,7 +220,7 @@ export function createEntitySlice<T extends BaseEntity>( state.refetch = action.payload; }, clearState: (state) => { - (state as Record<string, unknown>)[name] = []; + state.data = []; state.count = 0; }, }, @@ -245,10 +237,12 @@ export function createEntitySlice<T extends BaseEntity>( builder.addCase(fetch.fulfilled, (state, action) => { const payload = action.payload; if (isPaginatedResponse<T>(payload)) { - (state as Record<string, unknown>)[name] = payload.rows; + // Use double assertion to bypass Draft<T> type incompatibility + state.data = payload.rows as unknown as typeof state.data; state.count = payload.count; } else { - (state as Record<string, unknown>)[name] = payload; + // Single entity fetched - store as array for consistency + state.data = [payload] as unknown as typeof state.data; } state.loading = false; }); diff --git a/frontend/src/stores/introSteps.ts b/frontend/src/stores/introSteps.ts index 70bb647..0a13d28 100644 --- a/frontend/src/stores/introSteps.ts +++ b/frontend/src/stores/introSteps.ts @@ -1,19 +1,6 @@ -interface Step { - element: string; - intro: string; - position?: string; - tooltipClass?: string; - highlightClass?: string; - disableInteraction?: boolean; -} +import type { IntroStep } from '../types/ui'; -interface Hint { - element: string; - hint: string; - hintPosition?: string; -} - -export const loginSteps: Step[] = [ +export const loginSteps: IntroStep[] = [ { element: '#elementId1', intro: ` @@ -33,7 +20,7 @@ export const loginSteps: Step[] = [ }, ]; -export const appSteps: Step[] = [ +export const appSteps: IntroStep[] = [ { element: '#profilEdit', intro: @@ -89,7 +76,7 @@ export const appSteps: Step[] = [ }, ]; -export const usersSteps: Step[] = [ +export const usersSteps: IntroStep[] = [ { element: '#usersList', intro: @@ -112,7 +99,7 @@ export const usersSteps: Step[] = [ }, ]; -export const rolesSteps: Step[] = [ +export const rolesSteps: IntroStep[] = [ { element: '#rolesTable', intro: diff --git a/frontend/src/stores/mainSlice.ts b/frontend/src/stores/mainSlice.ts index 92b8935..0540ba8 100644 --- a/frontend/src/stores/mainSlice.ts +++ b/frontend/src/stores/mainSlice.ts @@ -1,20 +1,18 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { UserPayloadObject } from '../interfaces'; + +/** + * Main slice - UI state that doesn't belong to other slices + * + * Note: User data is stored in authSlice.currentUser. + * This slice previously had duplicate user fields that were never populated. + */ interface MainState { - userName: string; - userEmail: null | string; - userAvatar: null | string; + /** Field focus with ctrl+k (to register only once) */ isFieldFocusRegistered: boolean; } const initialState: MainState = { - /* User */ - userName: '', - userEmail: null, - userAvatar: null, - - /* Field focus with ctrl+k (to register only once) */ isFieldFocusRegistered: false, }; @@ -22,15 +20,13 @@ export const mainSlice = createSlice({ name: 'main', initialState, reducers: { - setUser: (state, action: PayloadAction<UserPayloadObject>) => { - state.userName = action.payload.name; - state.userEmail = action.payload.email; - state.userAvatar = action.payload.avatar; + setFieldFocusRegistered: (state, action: PayloadAction<boolean>) => { + state.isFieldFocusRegistered = action.payload; }, }, }); // Action creators are generated for each case reducer function -export const { setUser } = mainSlice.actions; +export const { setFieldFocusRegistered } = mainSlice.actions; export default mainSlice.reducer; diff --git a/frontend/src/stores/openAiSlice.ts b/frontend/src/stores/openAiSlice.ts index be7cb2d..0fd90fa 100644 --- a/frontend/src/stores/openAiSlice.ts +++ b/frontend/src/stores/openAiSlice.ts @@ -1,21 +1,13 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import axios from 'axios'; - -interface MainState { - isFetchingQuery: boolean; - errorMessage: string; - smartWidgets: any[]; - gptResponse: string | null; - aiResponse: any | null; - isAskingQuestion: boolean; - isAskingResponse: boolean; - notify: { - showNotification: boolean; - textNotification: string; - typeNotification: string; - }; -} -const initialState: MainState = { +import type { + SmartWidget, + OpenAIState, + CreateWidgetRequest, + CreateWidgetResponse, + AIRequestPayload, +} from '../types/openai'; +const initialState: OpenAIState = { isFetchingQuery: false, errorMessage: '', smartWidgets: [], @@ -30,17 +22,24 @@ const initialState: MainState = { }, }; -const fulfilledNotify = (state, msg, type?: string) => { +type NotificationType = 'success' | 'error' | 'warn' | 'info' | ''; + +const fulfilledNotify = ( + state: OpenAIState, + msg: string, + type?: NotificationType, +) => { state.notify.textNotification = msg; state.notify.typeNotification = type || 'success'; state.notify.showNotification = true; }; -export const aiPrompt = createAsyncThunk( +export const aiPrompt = createAsyncThunk<CreateWidgetResponse, CreateWidgetRequest>( 'openai/aiPrompt', - async (data: any, { rejectWithValue }) => { + async (data, { rejectWithValue }) => { try { - return await axios.post('/openai/create_widget', data); + const response = await axios.post<CreateWidgetResponse>('/openai/create_widget', data); + return response.data; } catch (error) { if (!error.response) { throw error; @@ -67,13 +66,7 @@ export const askGpt = createAsyncThunk( export const aiResponse = createAsyncThunk( 'openai/aiResponse', - async ( - payload: { - input: Array<{ role: string; content: string }>; - options?: Record<string, any>; - }, - { rejectWithValue }, - ) => { + async (payload: AIRequestPayload, { rejectWithValue }) => { try { const response = await axios.post('/ai/response', payload); return response.data; @@ -103,16 +96,18 @@ export const openAiSlice = createSlice({ builder.addCase(aiPrompt.pending, (state) => { state.isFetchingQuery = true; }); - builder.addCase(aiPrompt.fulfilled, (state, action: Record<any, any>) => { + builder.addCase(aiPrompt.fulfilled, (state, action) => { state.isFetchingQuery = false; state.errorMessage = ''; - state.smartWidgets.unshift(action.payload.data); + if (action.payload?.data) { + state.smartWidgets.unshift(action.payload.data as SmartWidget); + } }); builder.addCase(aiPrompt.rejected, (state) => { state.errorMessage = 'Something was wrong. Try again'; state.isFetchingQuery = false; - state.smartWidgets = null; + state.smartWidgets = []; }); builder.addCase(askGpt.pending, (state) => { diff --git a/frontend/src/stores/roles/rolesSlice.ts b/frontend/src/stores/roles/rolesSlice.ts index c8d6fac..0a51081 100644 --- a/frontend/src/stores/roles/rolesSlice.ts +++ b/frontend/src/stores/roles/rolesSlice.ts @@ -1,11 +1,19 @@ /** * Roles Redux Slice + * + * Extends the base entity slice with custom widget management actions. */ -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { createAsyncThunk, createReducer } from '@reduxjs/toolkit'; import axios from 'axios'; import { createEntitySlice } from '../createEntitySlice'; import type { Role } from '../../types/entities'; +import type { EntitySliceState } from '../../types/redux'; + +// Extended state type for roles with widget support +interface RolesSliceState extends EntitySliceState<Role> { + rolesWidgets: Array<{ id: string; [key: string]: unknown }>; +} // Create base entity slice with standard CRUD operations const { @@ -43,6 +51,31 @@ export const fetchWidgets = createAsyncThunk( }, ); +// Initial state with widgets +const initialWidgetsState: { rolesWidgets: Array<{ id: string; [key: string]: unknown }> } = { + rolesWidgets: [], +}; + +// Combined reducer that extends base reducer with widget handling +const combinedReducer = createReducer( + { ...baseReducer(undefined, { type: '' }), ...initialWidgetsState } as RolesSliceState, + (builder) => { + // Handle fetchWidgets.fulfilled + builder.addCase(fetchWidgets.fulfilled, (state, action) => { + state.rolesWidgets = action.payload || []; + }); + // Handle removeWidget.fulfilled - refresh will be triggered by component + builder.addCase(removeWidget.fulfilled, () => { + // Widget removed, component will refetch + }); + // Pass all other actions to base reducer + builder.addDefaultCase((state, action) => { + const baseResult = baseReducer(state, action); + return { ...baseResult, rolesWidgets: state.rolesWidgets }; + }); + }, +); + // Export standard CRUD actions export const { fetch, @@ -55,4 +88,4 @@ export const { } = actions; export const rolesSlice = slice; -export default baseReducer; +export default combinedReducer; diff --git a/frontend/src/stores/selectors.ts b/frontend/src/stores/selectors.ts new file mode 100644 index 0000000..33e9952 --- /dev/null +++ b/frontend/src/stores/selectors.ts @@ -0,0 +1,181 @@ +/** + * Memoized Redux Selectors + * + * Centralized selectors using createSelector for performance optimization. + * These selectors prevent unnecessary re-renders by memoizing derived state. + */ + +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from './store'; + +// ============================================================================ +// Auth Selectors +// ============================================================================ + +/** Select auth slice */ +const selectAuthState = (state: RootState) => state.auth; + +/** Select current user with memoization */ +export const selectCurrentUser = createSelector( + [selectAuthState], + (auth) => auth.currentUser, +); + +/** Select if user is authenticated */ +export const selectIsAuthenticated = createSelector( + [selectCurrentUser], + (user) => !!user, +); + +/** Select user's role */ +export const selectUserRole = createSelector( + [selectCurrentUser], + (user) => user?.app_role, +); + +/** Select user's permissions */ +export const selectUserPermissions = createSelector( + [selectUserRole], + (role) => { + if (!role) return []; + return role.permissions || []; + }, +); + +// ============================================================================ +// Entity Selectors Factory +// ============================================================================ + +/** + * Create memoized selectors for an entity slice + * + * @example + * const { selectData, selectCount, selectLoading } = createEntitySelectors('assets'); + * const assets = useAppSelector(selectData); + */ +export function createEntitySelectors<T>(sliceName: keyof RootState) { + type EntityState = { + data: T[]; + loading: boolean; + count: number; + refetch: boolean; + }; + + const selectSlice = (state: RootState) => + state[sliceName] as unknown as EntityState; + + const selectData = createSelector([selectSlice], (slice) => slice.data); + + const selectLoading = createSelector([selectSlice], (slice) => slice.loading); + + const selectCount = createSelector([selectSlice], (slice) => slice.count); + + const selectRefetch = createSelector([selectSlice], (slice) => slice.refetch); + + const selectFirstItem = createSelector([selectData], (data) => data[0]); + + const selectById = (id: string) => + createSelector([selectData], (data) => + data.find((item: T & { id: string }) => item.id === id), + ); + + return { + selectSlice, + selectData, + selectLoading, + selectCount, + selectRefetch, + selectFirstItem, + selectById, + }; +} + +// ============================================================================ +// Pre-built Entity Selectors +// ============================================================================ + +import type { Asset, TourPage, Project, Role, User } from '../types/entities'; + +/** Asset selectors */ +export const assetSelectors = createEntitySelectors<Asset>('assets'); + +/** Tour page selectors */ +export const tourPageSelectors = createEntitySelectors<TourPage>('tour_pages'); + +/** Project selectors */ +export const projectSelectors = createEntitySelectors<Project>('projects'); + +/** Role selectors */ +export const roleSelectors = createEntitySelectors<Role>('roles'); + +/** User selectors */ +export const userSelectors = createEntitySelectors<User>('users'); + +// ============================================================================ +// Style Selectors +// ============================================================================ + +/** Select style slice */ +const selectStyleState = (state: RootState) => state.style; + +/** Select dark mode with memoization */ +export const selectDarkMode = createSelector( + [selectStyleState], + (style) => style.darkMode, +); + +/** Select background layout color */ +export const selectBgLayoutColor = createSelector( + [selectStyleState], + (style) => style.bgLayoutColor, +); + +/** Select focus ring color */ +export const selectFocusRingColor = createSelector( + [selectStyleState], + (style) => style.focusRingColor, +); + +/** Select corners style */ +export const selectCorners = createSelector( + [selectStyleState], + (style) => style.corners, +); + +/** Select cards color */ +export const selectCardsColor = createSelector( + [selectStyleState], + (style) => style.cardsColor, +); + +// ============================================================================ +// Constructor Selectors +// ============================================================================ + +/** Select constructor slice */ +const selectConstructorState = (state: RootState) => state.constructorUI; + +/** Select editor tab */ +export const selectEditorTab = createSelector( + [selectConstructorState], + (constructor) => constructor.editorTab, +); + +/** Select if menu is open */ +export const selectIsMenuOpen = createSelector( + [selectConstructorState], + (constructor) => constructor.isMenuOpen, +); + +/** Select if editor is collapsed */ +export const selectIsEditorCollapsed = createSelector( + [selectConstructorState], + (constructor) => constructor.isEditorCollapsed, +); + +/** Select last active page ID for a project */ +export const selectLastActivePageId = (projectId: string) => + createSelector( + [selectConstructorState], + (constructor) => constructor.lastActivePageId[projectId], + ); diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 94796f8..69d0b0d 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -3,6 +3,7 @@ import styleReducer from './styleSlice'; import mainReducer from './mainSlice'; import authSlice from './authSlice'; import openAiSlice from './openAiSlice'; +import constructorReducer from './constructor/constructorSlice'; import usersSlice from './users/usersSlice'; import rolesSlice from './roles/rolesSlice'; @@ -24,6 +25,7 @@ export const store = configureStore({ main: mainReducer, auth: authSlice, openAi: openAiSlice, + constructorUI: constructorReducer, users: usersSlice, roles: rolesSlice, diff --git a/frontend/src/stores/styleSlice.ts b/frontend/src/stores/styleSlice.ts index fe14744..c773d86 100644 --- a/frontend/src/stores/styleSlice.ts +++ b/frontend/src/stores/styleSlice.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import * as styles from '../styles'; import { localStorageDarkModeKey, localStorageStyleKey } from '../config'; -import { StyleKey } from '../interfaces'; +import { StyleKey } from '../types/ui'; interface StyleState { asideStyle: string; diff --git a/frontend/src/stores/usersSlice.ts b/frontend/src/stores/usersSlice.ts deleted file mode 100644 index 6eddcb8..0000000 --- a/frontend/src/stores/usersSlice.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -import axios from 'axios'; -import { logger } from '../lib/logger'; - -interface MainState { - users: any; - loading: boolean; - notify: { - showNotification: boolean; - textNotification: string; - typeNotification: string; - }; -} - -const initialState: MainState = { - users: [], - loading: false, - notify: { - showNotification: false, - textNotification: '', - typeNotification: 'warn', - }, -}; - -export const fetch = createAsyncThunk('users/fetch', async (data: any) => { - const { id, query } = data; - const result = await axios.get(`users${query || (id ? `/${id}` : '')}`); - return id ? result.data : result.data.rows; -}); - -export const deleteItem = createAsyncThunk( - 'users/deleteUser', - async (id: string, thunkAPI) => { - try { - await axios.delete(`users/${id}`); - thunkAPI.dispatch(fetch({ id: '', query: '' })); - } catch (error) { - logger.error( - 'Delete user failed:', - error instanceof Error ? error : { error }, - ); - throw error; - } - - // showNotification('Users has been deleted', 'success'); - }, -); - -export const create = createAsyncThunk( - 'users/createUser', - async (data: any) => { - const result = await axios.post('users', { data }); - // showNotification('Users has been created', 'success'); - return result.data; - }, -); - -export const update = createAsyncThunk( - 'users/updateUser', - async (payload: any) => { - const result = await axios.put(`users/${payload.id}`, { - id: payload.id, - data: payload.data, - }); - return result.data; - }, -); - -export const usersSlice = createSlice({ - name: 'users', - initialState, - reducers: {}, - extraReducers: (builder) => { - builder.addCase(fetch.pending, (state) => { - state.loading = true; - }); - builder.addCase(fetch.rejected, (state) => { - state.loading = false; - }); - - builder.addCase(fetch.fulfilled, (state, action) => { - state.users = action.payload; - state.loading = false; - }); - - builder.addCase(deleteItem.pending, (state) => { - state.loading = true; - }); - - builder.addCase(deleteItem.fulfilled, (state) => { - state.loading = false; - }); - - builder.addCase(deleteItem.rejected, (state) => { - state.loading = false; - }); - - builder.addCase(create.pending, (state) => { - state.loading = true; - }); - builder.addCase(create.rejected, (state) => { - state.loading = false; - }); - - builder.addCase(create.fulfilled, (state) => { - state.loading = false; - }); - - builder.addCase(update.pending, (state) => { - state.loading = true; - }); - builder.addCase(update.fulfilled, (state) => { - state.loading = false; - }); - builder.addCase(update.rejected, (state) => { - state.loading = false; - }); - }, -}); - -// Action creators are generated for each case reducer function -// export const { } = usersSlice.actions - -export default usersSlice.reducer; diff --git a/frontend/src/types/charts.ts b/frontend/src/types/charts.ts new file mode 100644 index 0000000..2c00aef --- /dev/null +++ b/frontend/src/types/charts.ts @@ -0,0 +1,62 @@ +/** + * Chart/Widget Data Types + */ + +/** + * Chart data point - dynamic key-value pairs for chart values + * Example: { name: "January", sales: 100 } + */ +export type ChartDataPoint = { [key: string]: string | number }; + +/** + * Chart value array (matches the locally-defined ValueType in chart components) + */ +export type ChartValueArray = ChartDataPoint[]; + +/** + * Valid ApexCharts chart types + */ +export type ApexChartType = + | 'area' + | 'line' + | 'bar' + | 'pie' + | 'scatter' + | 'bubble' + | 'polarArea' + | 'radar' + | 'donut' + | 'radialBar' + | 'heatmap' + | 'candlestick' + | 'boxPlot' + | 'rangeBar' + | 'rangeArea' + | 'treemap'; + +/** + * Widget result structure containing chart data + */ +export interface WidgetResult { + value: ChartValueArray; + label?: string; + options?: Record<string, unknown>; +} + +/** + * Chart widget data structure used by SmartWidget components + */ +export interface ChartWidget { + value: ChartValueArray; + color_array?: string[]; + currency?: boolean; + chart_type?: ApexChartType; + label?: string; +} + +/** + * Props for chart components + */ +export interface ChartComponentProps { + widget: ChartWidget; +} diff --git a/frontend/src/types/components.ts b/frontend/src/types/components.ts new file mode 100644 index 0000000..733e004 --- /dev/null +++ b/frontend/src/types/components.ts @@ -0,0 +1,37 @@ +/** + * Shared Component Props Types + */ +import type { FieldInputProps, FormikProps } from 'formik'; +import type { ImageFile } from './entities'; + +/** + * Base props for entity Card/List components + */ +export interface EntityCardListBaseProps { + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +} + +/** + * Pagination component props + */ +export interface PaginationProps { + currentPage: number; + numPages: number; + setCurrentPage: (page: number) => void; +} + +/** + * File picker field props (Formik integration) + */ +export interface FilePickerFieldProps { + label?: string; + field: FieldInputProps<ImageFile[] | null>; + form: FormikProps<Record<string, unknown>>; + path: string; + schema?: Record<string, unknown>; + accept?: string; +} diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index e4e4b04..f91bdfb 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -31,6 +31,33 @@ export type CanvasElementType = */ export type NavigationButtonKind = 'forward' | 'back'; +/** + * Editor menu item type for constructor sidebar + */ +export type EditorMenuItem = + | 'none' + | 'background_image' + | 'background_video' + | 'background_audio' + | 'create_transition'; + +/** + * Editor tab for element property editing + */ +export type EditorTab = 'general' | 'css' | 'effects'; + +/** + * Constructor Redux slice state + */ +export interface ConstructorState { + editorTab: EditorTab; + isMenuOpen: boolean; + isEditorCollapsed: boolean; + selectedMenuItem: EditorMenuItem; + lastActivePageId: Record<string, string>; + isSidebarCollapsed: boolean; +} + /** * Gallery card item */ @@ -71,6 +98,7 @@ export interface BaseCanvasElement label?: string; xPercent?: number; yPercent?: number; + rotation?: number; iconUrl?: string; appearDelaySec?: number; appearDurationSec?: number | null; @@ -488,3 +516,91 @@ export interface EditorCollectionOpsProps { export interface EditorMediaUtilsProps { getDuration: (url: string) => number | undefined; } + +// ============================================================================ +// Page Background State (Consolidated) +// ============================================================================ + +/** + * Video playback settings for page background + */ +export interface PageBackgroundVideoSettings { + autoplay: boolean; + loop: boolean; + muted: boolean; + startTime: number | null; + endTime: number | null; +} + +/** + * Consolidated page background state + * Replaces 8 separate useState hooks in constructor.tsx + */ +export interface PageBackgroundState { + /** Storage key or URL for background image */ + imageUrl: string; + /** Storage key or URL for background video */ + videoUrl: string; + /** Storage key or URL for background audio */ + audioUrl: string; + /** Video playback settings */ + videoSettings: PageBackgroundVideoSettings; +} + +/** + * Default video settings + */ +export const DEFAULT_VIDEO_SETTINGS: PageBackgroundVideoSettings = { + autoplay: true, + loop: true, + muted: true, + startTime: null, + endTime: null, +}; + +/** + * Default page background state + */ +export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = { + imageUrl: '', + videoUrl: '', + audioUrl: '', + videoSettings: { ...DEFAULT_VIDEO_SETTINGS }, +}; + +/** + * Create page background state from tour page data + */ +export function createPageBackgroundFromPage(page: { + background_image_url?: string; + background_video_url?: string; + background_audio_url?: string; + background_video_autoplay?: boolean; + background_video_loop?: boolean; + background_video_muted?: boolean; + background_video_start_time?: number | null; + background_video_end_time?: number | null; +} | null): PageBackgroundState { + if (!page) { + return { ...DEFAULT_PAGE_BACKGROUND }; + } + + return { + imageUrl: page.background_image_url || '', + videoUrl: page.background_video_url || '', + audioUrl: page.background_audio_url || '', + videoSettings: { + autoplay: page.background_video_autoplay ?? true, + loop: page.background_video_loop ?? true, + muted: page.background_video_muted ?? true, + startTime: + page.background_video_start_time != null + ? parseFloat(String(page.background_video_start_time)) + : null, + endTime: + page.background_video_end_time != null + ? parseFloat(String(page.background_video_end_time)) + : null, + }, + }; +} diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index 41ed080..ee54982 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -41,6 +41,8 @@ export interface Project extends BaseEntity { logo_url?: string; favicon_url?: string; og_image_url?: string; + is_deleted?: boolean; + deleted_at_time?: string | Date | null; } // Asset entity @@ -78,17 +80,34 @@ export interface AssetVariant extends BaseEntity { cdn_url?: string; width_px?: number; height_px?: number; + size_mb?: number; } // Tour Page entity export interface TourPage extends BaseEntity { project?: Project | string | null; + name?: string; title?: string; slug?: string; background_asset?: Asset | string | null; audio_asset?: Asset | string | null; is_start_page?: boolean; sort_order?: number; + environment?: 'dev' | 'stage' | 'production'; + source_key?: string; + requires_auth?: boolean; + ui_schema_json?: string; + // Background URL fields (direct storage paths) + background_image_url?: string; + background_video_url?: string; + background_audio_url?: string; + background_loop?: boolean; + // Background video playback settings + background_video_autoplay?: boolean; + background_video_loop?: boolean; + background_video_muted?: boolean; + background_video_start_time?: number | null; + background_video_end_time?: number | null; } // Page Element entity @@ -185,6 +204,12 @@ export interface PublishEvent extends BaseEntity { export interface PwaCache extends BaseEntity { project?: Project | string | null; cache_key?: string; + cache_version?: string; + environment?: 'dev' | 'stage' | 'production'; + manifest_json?: string; + asset_list_json?: string; + generated_at?: string | Date | null; + is_active?: boolean; last_updated?: string; is_valid?: boolean; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d63db6a..cdd2962 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -12,3 +12,9 @@ export * from './offline'; export * from './preload'; export * from './runtime'; export * from './constructor'; +export * from './presentation'; +export * from './menu'; +export * from './ui'; +export * from './openai'; +export * from './components'; +export * from './charts'; diff --git a/frontend/src/types/menu.ts b/frontend/src/types/menu.ts new file mode 100644 index 0000000..3717ccf --- /dev/null +++ b/frontend/src/types/menu.ts @@ -0,0 +1,38 @@ +/** + * Menu Types + * + * Types for navigation menu items. + */ + +import type { ColorButtonKey } from './ui'; + +/** + * Aside menu item type + */ +export interface MenuAsideItem { + label: string; + icon?: string; + href?: string; + target?: string; + color?: ColorButtonKey; + isLogout?: boolean; + withDevider?: boolean; + menu?: MenuAsideItem[]; + permissions?: string | string[]; +} + +/** + * Navigation bar menu item type + */ +export interface MenuNavBarItem { + label?: string; + icon?: string; + href?: string; + target?: string; + isDivider?: boolean; + isLogout?: boolean; + isDesktopNoLabel?: boolean; + isToggleLightDark?: boolean; + isCurrentUser?: boolean; + menu?: MenuNavBarItem[]; +} diff --git a/frontend/src/types/offline.ts b/frontend/src/types/offline.ts index b8d99e2..38def5a 100644 --- a/frontend/src/types/offline.ts +++ b/frontend/src/types/offline.ts @@ -192,3 +192,9 @@ export interface ProjectDownloadProgressEvent { export interface ProjectDownloadCompleteEvent { projectId: string; } + +// Blob URL ready event - emitted when an asset is decoded and ready for instant display +export interface BlobUrlReadyEvent { + storageKey: string; + blobUrl: string; +} diff --git a/frontend/src/types/openai.ts b/frontend/src/types/openai.ts new file mode 100644 index 0000000..3dbc57f --- /dev/null +++ b/frontend/src/types/openai.ts @@ -0,0 +1,73 @@ +/** + * OpenAI/AI Feature Types + */ + +import type { NotificationState } from './redux'; + +/** + * Smart widget data structure for AI-generated visualizations + */ +export interface SmartWidget { + id: string; + name: string; + description?: string; + query?: string; + result?: Record<string, unknown>; +} + +/** + * AI response data structure + */ +export interface AIResponseData { + content: string; + role: 'assistant' | 'user' | 'system'; + model?: string; +} + +/** + * OpenAI Redux slice state + */ +export interface OpenAIState { + isFetchingQuery: boolean; + errorMessage: string; + smartWidgets: SmartWidget[]; + gptResponse: string | null; + aiResponse: AIResponseData | null; + isAskingQuestion: boolean; + isAskingResponse: boolean; + notify: NotificationState; +} + +/** + * Request payload for creating a smart widget + */ +export interface CreateWidgetRequest { + description: string; + roleId?: string; + projectId?: string; + userId?: string; +} + +/** + * Response from widget creation endpoint + */ +export interface CreateWidgetResponse { + data?: SmartWidget; + error?: { message: string }; +} + +/** + * Single AI message in a conversation + */ +export interface AIMessage { + role: 'assistant' | 'user' | 'system'; + content: string; +} + +/** + * Request payload for AI response endpoint + */ +export interface AIRequestPayload { + input: AIMessage[]; + options?: Record<string, unknown>; +} diff --git a/frontend/src/types/presentation.ts b/frontend/src/types/presentation.ts index adfd51d..496887a 100644 --- a/frontend/src/types/presentation.ts +++ b/frontend/src/types/presentation.ts @@ -5,16 +5,26 @@ * These types facilitate code sharing between the two main presentation components. */ -import type { RuntimePage } from './runtime'; +import type { RuntimePage, RuntimeProject } from './runtime'; /** * Transition preview state for video transitions */ export interface TransitionPreviewState { - targetPageId: string; + /** Resolved video URL for playback */ videoUrl: string; + /** Raw storage path for cache lookup */ storageKey: string; - isReverse: boolean; + /** Playback mode: none (forward), reverse (auto-reverse), separate (use reverseVideoUrl) */ + reverseMode: 'none' | 'reverse' | 'separate'; + /** Resolved URL for separate reverse video */ + reverseVideoUrl?: string; + /** Raw storage path for reverse video cache lookup */ + reverseStorageKey?: string; + /** Duration in seconds */ + durationSec?: number; + /** Display title for the preview */ + title: string; } /** @@ -57,16 +67,6 @@ export interface PageDataLoaderResult { reload: (preservePageId?: string) => Promise<void>; } -/** - * Runtime project for page data loader - */ -export interface RuntimeProject { - id: string; - name?: string; - slug?: string; - description?: string; -} - /** * Canvas element with navigation properties (for click handling) */ diff --git a/frontend/src/types/redux.ts b/frontend/src/types/redux.ts index 5e9fd34..6d8e86f 100644 --- a/frontend/src/types/redux.ts +++ b/frontend/src/types/redux.ts @@ -16,16 +16,10 @@ export interface EntitySliceState<T> { count: number; refetch: boolean; notify: NotificationState; + // Index signature for dynamic entity name access (e.g., state[entityName]) + [entityName: string]: T[] | boolean | number | NotificationState | unknown; } -// Legacy entity slice state (for backward compatibility during migration) -export interface LegacyEntitySliceState<T> { - [entityName: string]: T[] | boolean | number | NotificationState; - loading: boolean; - count: number; - refetch: boolean; - notify: NotificationState; -} // Slice factory configuration export interface EntitySliceConfig { @@ -37,10 +31,9 @@ export interface EntitySliceConfig { // Auth state structure export interface AuthState { currentUser: import('./entities').User | null; - token: string | null; + token: string; isFetching: boolean; - errorMessage: string | null; - loadingMessage: string | null; + errorMessage: string; notify: NotificationState; } diff --git a/frontend/src/types/ui.ts b/frontend/src/types/ui.ts new file mode 100644 index 0000000..51e1ee6 --- /dev/null +++ b/frontend/src/types/ui.ts @@ -0,0 +1,62 @@ +/** + * UI Types + * + * Types for UI components - colors, styles, and visual elements. + */ + +/** + * Color keys for general UI elements + */ +export type ColorKey = + | 'white' + | 'light' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info'; + +/** + * Color keys for buttons + */ +export type ColorButtonKey = + | 'white' + | 'whiteDark' + | 'lightDark' + | 'contrast' + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'void'; + +/** + * Background gradient keys + */ +export type BgKey = 'purplePink' | 'pinkRed' | 'violet'; + +/** + * Style keys for theming + */ +export type StyleKey = 'white' | 'basic'; + +/** + * Intro.js step configuration + */ +export interface IntroStep { + element: string; + intro: string; + position?: string; + tooltipClass?: string; + highlightClass?: string; + disableInteraction?: boolean; +} + +/** + * Intro.js hint configuration + */ +export interface IntroHint { + element: string; + hint: string; + hintPosition?: string; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9bce80c..5a283eb 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -95,28 +95,6 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@emnapi/core@^1.4.3": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" - integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== - dependencies: - "@emnapi/wasi-threads" "1.2.0" - tslib "^2.4.0" - -"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" - integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz#a19d9772cc3d195370bf6e2a805eec40aa75e18e" - integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== - dependencies: - tslib "^2.4.0" - "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" @@ -162,7 +140,7 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@^11.11.3", "@emotion/react@^11.8.1": +"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.11.3", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.8.1", "@emotion/react@^11.9.0": version "11.14.0" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== @@ -192,7 +170,7 @@ resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@^11.11.0": +"@emotion/styled@^11.11.0", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1": version "11.14.0" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz" integrity sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA== @@ -316,141 +294,11 @@ optionalDependencies: "@img/sharp-libvips-darwin-arm64" "1.2.4" -"@img/sharp-darwin-x64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" - integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== - optionalDependencies: - "@img/sharp-libvips-darwin-x64" "1.2.4" - "@img/sharp-libvips-darwin-arm64@1.2.4": version "1.2.4" resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz" integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== -"@img/sharp-libvips-darwin-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" - integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== - -"@img/sharp-libvips-linux-arm64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" - integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== - -"@img/sharp-libvips-linux-arm@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" - integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== - -"@img/sharp-libvips-linux-ppc64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" - integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== - -"@img/sharp-libvips-linux-riscv64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" - integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== - -"@img/sharp-libvips-linux-s390x@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" - integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== - -"@img/sharp-libvips-linux-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" - integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== - -"@img/sharp-libvips-linuxmusl-arm64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" - integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== - -"@img/sharp-libvips-linuxmusl-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" - integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== - -"@img/sharp-linux-arm64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" - integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== - optionalDependencies: - "@img/sharp-libvips-linux-arm64" "1.2.4" - -"@img/sharp-linux-arm@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" - integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== - optionalDependencies: - "@img/sharp-libvips-linux-arm" "1.2.4" - -"@img/sharp-linux-ppc64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" - integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== - optionalDependencies: - "@img/sharp-libvips-linux-ppc64" "1.2.4" - -"@img/sharp-linux-riscv64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" - integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== - optionalDependencies: - "@img/sharp-libvips-linux-riscv64" "1.2.4" - -"@img/sharp-linux-s390x@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" - integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== - optionalDependencies: - "@img/sharp-libvips-linux-s390x" "1.2.4" - -"@img/sharp-linux-x64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" - integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== - optionalDependencies: - "@img/sharp-libvips-linux-x64" "1.2.4" - -"@img/sharp-linuxmusl-arm64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" - integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" - -"@img/sharp-linuxmusl-x64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" - integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64" "1.2.4" - -"@img/sharp-wasm32@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" - integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== - dependencies: - "@emnapi/runtime" "^1.7.0" - -"@img/sharp-win32-arm64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" - integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== - -"@img/sharp-win32-ia32@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" - integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== - -"@img/sharp-win32-x64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" - integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== - "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" @@ -517,7 +365,7 @@ resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz" integrity sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q== -"@mui/material@^6.3.0": +"@mui/material@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/material@^6.3.0": version "6.5.0" resolved "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz" integrity sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow== @@ -556,7 +404,7 @@ csstype "^3.1.3" prop-types "^15.8.1" -"@mui/system@^6.5.0": +"@mui/system@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system@^6.5.0": version "6.5.0" resolved "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz" integrity sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w== @@ -608,15 +456,6 @@ "@babel/runtime" "^7.25.7" "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" -"@napi-rs/wasm-runtime@^0.2.11": - version "0.2.12" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" - integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== - dependencies: - "@emnapi/core" "^1.4.3" - "@emnapi/runtime" "^1.4.3" - "@tybys/wasm-util" "^0.10.0" - "@next/env@15.5.13": version "15.5.13" resolved "https://registry.npmjs.org/@next/env/-/env-15.5.13.tgz" @@ -634,41 +473,6 @@ resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.13.tgz" integrity sha512-XrBbj2iY1mQSsJ8RoFClNpUB9uuZejP94v9pJuSAzdzwFVHeP+Vu2vzBCHwSObozgYNuTVwKhLukG1rGCgj8xA== -"@next/swc-darwin-x64@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.13.tgz#d97dd2c82d41f02030a98528596a3b69bdc3c588" - integrity sha512-Ey3fuUeWDWtVdgiLHajk2aJ74Y8EWLeqvfwlkB5RvWsN7F1caQ6TjifsQzrAcOuNSnogGvFNYzjQlu7tu0kyWg== - -"@next/swc-linux-arm64-gnu@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.13.tgz#4f446f13cb13ef00ff525ff66aa616ed88c00505" - integrity sha512-aLtu/WxDeL3188qx3zyB3+iw8nAB9F+2Mhyz9nNZpzsREc2t8jQTuiWY4+mtOgWp1d+/Q4eXuy9m3dwh3n1IyQ== - -"@next/swc-linux-arm64-musl@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.13.tgz#049346d542f1b8443f8e4db700d6b158ce9c54c8" - integrity sha512-9VZ0OsVx9PEL72W50QD15iwSCF3GD/dwj42knfF5C4aiBPXr95etGIOGhb8rU7kpnzZuPNL81CY4vIyUKa2xvg== - -"@next/swc-linux-x64-gnu@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.13.tgz#29295a474f6ffddd8435d7dd1696c832321fb50f" - integrity sha512-3knsu9H33e99ZfiWh0Bb04ymEO7YIiopOpXKX89ZZ/ER0iyfV1YLoJFxJJQNUD7OR8O7D7eiLI/TXPryPGv3+A== - -"@next/swc-linux-x64-musl@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.13.tgz#4c3c60afa1e1d8298f101732e1643bc809b768ff" - integrity sha512-AVPb6+QZ0pPanJFc1hpx81I5tTiBF4VITw5+PMaR1CrboAUUxtxn3IsV0h48xI7fzd6/zw9D9i6khRwME5NKUw== - -"@next/swc-win32-arm64-msvc@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.13.tgz#bae9743fa2b9e43d76edc827071becaa3a2c800d" - integrity sha512-FZ/HXuTxn+e5Lp6oRZMvHaMJx22gAySveJdJE0//91Nb9rMuh2ftgKlEwBFJxhkw5kAF/yIXz3iBf0tvDXRmCA== - -"@next/swc-win32-x64-msvc@15.5.13": - version "15.5.13" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.13.tgz#71e6c48d978d569a220f080ff832aec1a78d1d17" - integrity sha512-B5E82pX3VXu6Ib5mDuZEqGwT8asocZe3OMMnaM+Yfs0TRlmSQCBQUUXR9BkXQeGVboOWS1pTsRkS9wzFd8PABw== - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -677,7 +481,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -832,19 +636,24 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" -"@tybys/wasm-util@^0.10.0": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== +"@tanstack/query-core@5.96.2": + version "5.96.2" + resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz" + integrity sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA== + +"@tanstack/react-query@^5.96.2": + version "5.96.2" + resolved "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz" + integrity sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA== dependencies: - tslib "^2.4.0" + "@tanstack/query-core" "5.96.2" "@types/date-arithmetic@*": version "4.1.4" resolved "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz" integrity sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw== -"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1", "@types/hoist-non-react-statics@^3.3.6": +"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1", "@types/hoist-non-react-statics@^3.3.6", "@types/hoist-non-react-statics@>= 3.3.1": version "3.3.7" resolved "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz" integrity sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g== @@ -861,7 +670,7 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/node@18.7.16": +"@types/node@>= 12", "@types/node@18.7.16": version "18.7.16" resolved "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz" integrity sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg== @@ -905,7 +714,7 @@ resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*", "@types/react@>=16.9.11": +"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.2.25 || ^19", "@types/react@>= 16", "@types/react@>=16.9.11": version "19.2.14" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== @@ -942,7 +751,7 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.37.0", "@typescript-eslint/parser@^5.42.0": +"@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.37.0", "@typescript-eslint/parser@^5.42.0": version "5.43.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.43.0.tgz" integrity sha512-2iHUK2Lh7PwNUlhFxxLI2haSDNyXvebBO9izhjhMoDC+S3XI9qt2DGFUsiJ89m2k7gGYch2aEpYqV5F/+nwZug== @@ -1042,103 +851,11 @@ "@typescript-eslint/types" "5.43.0" eslint-visitor-keys "^3.3.0" -"@unrs/resolver-binding-android-arm-eabi@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" - integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== - -"@unrs/resolver-binding-android-arm64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" - integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== - "@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== -"@unrs/resolver-binding-darwin-x64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" - integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== - -"@unrs/resolver-binding-freebsd-x64@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" - integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== - -"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" - integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== - -"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" - integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== - -"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" - integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== - -"@unrs/resolver-binding-linux-arm64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" - integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== - -"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" - integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== - -"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" - integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== - -"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" - integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== - -"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" - integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== - -"@unrs/resolver-binding-linux-x64-gnu@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" - integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== - -"@unrs/resolver-binding-linux-x64-musl@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" - integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== - -"@unrs/resolver-binding-wasm32-wasi@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" - integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== - dependencies: - "@napi-rs/wasm-runtime" "^0.2.11" - -"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" - integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== - -"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" - integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== - -"@unrs/resolver-binding-win32-x64-msvc@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" - integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== - "@vtaits/use-lazy-ref@^0.1.4": version "0.1.4" resolved "https://registry.npmjs.org/@vtaits/use-lazy-ref/-/use-lazy-ref-0.1.4.tgz" @@ -1149,7 +866,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.8.0: +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.0: version "8.8.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== @@ -1199,7 +916,7 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -apexcharts@^5.0.0: +apexcharts@^5.0.0, apexcharts@>=5.10.1: version "5.10.4" resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz" integrity sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA== @@ -1385,6 +1102,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + brace-expansion@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" @@ -1399,7 +1123,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@4.28.1, browserslist@^4.21.3: +browserslist@^4.21.3, "browserslist@>= 4.21.0", browserslist@>=4, browserslist@4.28.1: version "4.28.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== @@ -1464,7 +1188,7 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chart.js@^4.4.1: +chart.js@^4.1.1, chart.js@^4.4.1: version "4.4.7" resolved "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz" integrity sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw== @@ -1980,7 +1704,7 @@ eslint-module-utils@^2.12.1: dependencies: debug "^3.2.7" -eslint-plugin-import@^2.26.0, eslint-plugin-import@^2.29.1: +eslint-plugin-import@*, eslint-plugin-import@^2.26.0, eslint-plugin-import@^2.29.1: version "2.32.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz" integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== @@ -2082,7 +1806,7 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@^8.23.1: +eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", "eslint@^7.23.0 || ^8.0.0", eslint@^8.23.1, eslint@>=5, eslint@>=7.0.0: version "8.23.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.23.1.tgz" integrity sha512-w7C1IXCc6fNqjpuYd0yPlcTKKmHlHHktRkzmBPZ+7cvNBQuiNjx0xaMTjAJGCafJhQkrFJooREv0CtrVzmHwqg== @@ -2392,17 +2116,24 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob-parent@^6.0.1, glob-parent@^6.0.2: +glob-parent@^6.0.1: version "6.0.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== dependencies: is-glob "^4.0.3" -glob@10.5.0: - version "10.5.0" - resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" - integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.3.10: + version "10.4.5" + resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" @@ -2411,7 +2142,7 @@ glob@10.5.0: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@7.1.7, glob@^7.1.3: +glob@^7.1.3, glob@7.1.7: version "7.1.7" resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== @@ -2423,10 +2154,10 @@ glob@7.1.7, glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^10.3.10: - version "10.4.5" - resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz" - integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== +glob@10.5.0: + version "10.5.0" + resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" @@ -2573,7 +2304,7 @@ i18next-http-backend@^3.0.2: dependencies: cross-fetch "4.0.0" -i18next@^25.1.2: +i18next@^25.1.2, "i18next@>= 23.4.0", "i18next@>= 23.7.13": version "25.8.18" resolved "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz" integrity sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA== @@ -2635,7 +2366,7 @@ intro.js-react@^1.0.0: resolved "https://registry.npmjs.org/intro.js-react/-/intro.js-react-1.0.0.tgz" integrity sha512-zR8pbTyX20RnCZpJMc0nuHBpsjcr1wFkj3ZookV6Ly4eE/LGpFTQwPsaA61Cryzwiy/tTFsusf4hPU9NpI9UOg== -intro.js@^7.2.0: +intro.js@^7.2.0, intro.js@>=2.5.0: version "7.2.0" resolved "https://registry.npmjs.org/intro.js/-/intro.js-7.2.0.tgz" integrity sha512-qbMfaB70rOXVBceIWNYnYTpVTiZsvQh/MIkfdQbpA9di9VBfj1GigUPfcCv3aOfsbrtPcri8vTLTA4FcEDcHSQ== @@ -3168,7 +2899,7 @@ moment@^2.29.4, moment@^2.30.1: resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== -ms@2.1.2, ms@^2.1.1: +ms@^2.1.1, ms@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -3213,7 +2944,7 @@ next-i18next@^15.4.2: hoist-non-react-statics "^3.3.2" i18next-fs-backend "^2.6.0" -next@^15.3.1: +next@^15.3.1, "next@>= 12.0.0", next@>=14.0.0: version "15.5.13" resolved "https://registry.npmjs.org/next/-/next-15.5.13.tgz" integrity sha512-n0AXf6vlTwGuM93Z++POtjMsRuQ9pT5v2URPciXKUQIl/EB2WjXF0YiIUxaa9AEMFaMpZlaG3KPK6i4UVnx9eQ== @@ -3446,7 +3177,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.3: +"picomatch@^3 || ^4", picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -3506,14 +3237,6 @@ postcss-nested@^6.2.0: dependencies: postcss-selector-parser "^6.1.1" -postcss-selector-parser@6.0.10: - version "6.0.10" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz" - integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: version "6.1.2" resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz" @@ -3522,11 +3245,28 @@ postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: cssesc "^3.0.0" util-deprecate "^1.0.2" +postcss-selector-parser@6.0.10: + version "6.0.10" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== +postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.4, postcss@^8.4.47, postcss@>=8.0.9: + version "8.4.49" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postcss@8.4.31: version "8.4.31" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" @@ -3536,15 +3276,6 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.4, postcss@^8.4.47: - version "8.4.49" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz" - integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== - dependencies: - nanoid "^3.3.7" - picocolors "^1.1.1" - source-map-js "^1.2.1" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -3654,7 +3385,7 @@ react-dnd@^16.0.1: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@^19.0.0: +"react-dom@^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.14.0 || ^17 || ^18 || ^19", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18 || ^19", "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@^19.0.0, react-dom@>=16.3.0, react-dom@>=16.6.0, react-dom@>=16.8.0, react-dom@>=17.0.0: version "19.0.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz" integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== @@ -3666,7 +3397,7 @@ react-fast-compare@^2.0.1: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-i18next@^15.5.1: +react-i18next@^15.5.1, "react-i18next@>= 13.5.0": version "15.7.4" resolved "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz" integrity sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw== @@ -3703,7 +3434,7 @@ react-overlays@^5.2.1: uncontrollable "^7.2.1" warning "^4.0.3" -react-redux@^9.0.0: +"react-redux@^7.2.1 || ^8.1.3 || ^9.0.0", react-redux@^9.0.0: version "9.2.0" resolved "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz" integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== @@ -3723,7 +3454,7 @@ react-select-async-paginate@^0.7.11: use-is-mounted-ref "^1.5.0" use-latest "^1.3.0" -react-select@^5.7.0: +react-select@^5.0.0, react-select@^5.7.0: version "5.9.0" resolved "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz" integrity sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw== @@ -3762,7 +3493,7 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -react@^19.0.0: +"react@^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.14.0 || ^17 || ^18 || ^19", "react@^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^17.0.0 || ^18.0.0 || ^19.0.0", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^19.0.0, "react@>= 16.14", "react@>= 16.8.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", "react@>= 17.0.2", react@>=0.14.0, react@>=15.0.0, react@>=16.0.0, react@>=16.3.0, react@>=16.6.0, react@>=16.8.0, react@>=17.0.0, react@>=18.0.0: version "19.0.0" resolved "https://registry.npmjs.org/react/-/react-19.0.0.tgz" integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== @@ -3800,7 +3531,7 @@ redux@^4.2.0: dependencies: "@babel/runtime" "^7.9.2" -redux@^5.0.1: +redux@^5.0.0, redux@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== @@ -3931,11 +3662,6 @@ scheduler@^0.25.0: resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz" integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== -semver@7.7.4, semver@^7.3.7, semver@^7.5.4, semver@^7.7.1, semver@^7.7.3: - version "7.7.4" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - semver@^6.3.0: version "6.3.0" resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" @@ -3946,7 +3672,12 @@ semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -serwist@9.5.7, serwist@^9.5.7: +semver@^7.3.7, semver@^7.5.4, semver@^7.7.1, semver@^7.7.3, semver@7.7.4: + version "7.7.4" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +serwist@^9.5.7, serwist@9.5.7: version "9.5.7" resolved "https://registry.npmjs.org/serwist/-/serwist-9.5.7.tgz" integrity sha512-4R3kezBK0YAwkU6kIKbJc1I7QmbDV+wauV6Rf2+PdEHN5tBFK+3S92JPgj+XAa1ZCtg55qJGyyzAQ+d0G5AjDg== @@ -4091,6 +3822,11 @@ source-map-js@^1.0.2, source-map-js@^1.2.1: resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + source-map@0.8.0-beta.0: version "0.8.0-beta.0" resolved "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz" @@ -4098,11 +3834,6 @@ source-map@0.8.0-beta.0: dependencies: whatwg-url "^7.0.0" -source-map@^0.5.7: - version "0.5.7" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" - integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== - split-on-first@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz" @@ -4275,7 +4006,7 @@ tabbable@^6.0.0: resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz" integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg== -tailwindcss@^3.4.1: +tailwindcss@^3.4.1, "tailwindcss@>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1", "tailwindcss@>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20", "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20": version "3.4.17" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz" integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og== @@ -4455,7 +4186,7 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript@^5.4.5: +typescript@^5, typescript@^5.4.5, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@>=3.3.1, typescript@>=5.0.0: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -4703,7 +4434,7 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@4.3.6, zod@^4.3.6: +zod@^4.3.6, zod@4.3.6: version "4.3.6" resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==