From 4c41205225b9c47f974bda9c0b65876c2d5aff83 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Tue, 24 Mar 2026 08:20:27 +0400 Subject: [PATCH] revers transitions and preloading --- backend/.eslintrc.cjs | 3 +- backend/src/config.js | 3 - backend/src/db/api/page_links.js | 21 + backend/src/index.js | 4 +- backend/src/routes/pexels.js | 104 -- backend/src/routes/projects.js | 53 +- backend/src/services/pwa_manifest.js | 273 +++++ frontend/.eslintrc.cjs | 25 + frontend/next.config.mjs | 26 +- frontend/package.json | 8 +- frontend/public/manifest.json | 37 + frontend/public/sw.js | 212 +--- .../Access_logs/configureAccess_logsCols.tsx | 32 +- .../configureAsset_variantsCols.tsx | 26 +- .../src/components/Assets/ProjectSelector.tsx | 3 +- .../components/Assets/configureAssetsCols.tsx | 28 +- .../src/components/Assets/useAssetUploader.ts | 6 +- frontend/src/components/DevModeBadge.tsx | 2 +- frontend/src/components/ErrorBoundary.tsx | 30 +- .../src/components/Generic/GenericTable.tsx | 428 +++++++ frontend/src/components/ImageField.tsx | 22 +- .../src/components/KanbanBoard/KanbanCard.tsx | 12 +- .../components/KanbanBoard/KanbanColumn.tsx | 11 +- .../Offline/DownloadProgressPanel.tsx | 193 +++ .../Offline/OfflineStatusIndicator.tsx | 61 + .../src/components/Offline/OfflineToggle.tsx | 149 +++ .../Offline/StorageUsageDisplay.tsx | 124 ++ frontend/src/components/Offline/index.ts | 10 + .../Page_elements/TablePage_elements.tsx | 10 +- .../configurePage_elementsCols.tsx | 30 +- .../Page_links/configurePage_linksCols.tsx | 42 +- .../Permissions/TablePermissions.tsx | 4 +- .../Permissions/configurePermissionsCols.tsx | 24 +- .../configurePresigned_url_requestsCols.tsx | 38 +- .../configureProject_audio_tracksCols.tsx | 30 +- .../configureProject_membershipsCols.tsx | 40 +- .../Projects/configureProjectsCols.tsx | 26 +- .../configurePublish_eventsCols.tsx | 40 +- .../Pwa_caches/configurePwa_cachesCols.tsx | 32 +- .../components/Roles/configureRolesCols.tsx | 24 +- .../components/BarChart/ChartJSBarChart.tsx | 3 +- frontend/src/components/TourFlowManager.tsx | 21 +- .../Tour_pages/configureTour_pagesCols.tsx | 30 +- .../Transitions/configureTransitionsCols.tsx | 30 +- frontend/src/components/UserAvatar.tsx | 27 +- .../components/Users/configureUsersCols.tsx | 31 +- frontend/src/config/offline.config.ts | 52 + frontend/src/config/preload.config.ts | 60 + frontend/src/context/DownloadContext.tsx | 255 ++++ frontend/src/factories/createFormPage.tsx | 13 +- frontend/src/helpers/pexels.ts | 75 -- frontend/src/hooks/useCSVHandling.ts | 11 +- frontend/src/hooks/useNeighborGraph.ts | 315 +++++ frontend/src/hooks/useNetworkAware.ts | 163 +++ frontend/src/hooks/useOfflineMode.ts | 387 +++++++ frontend/src/hooks/usePreloadOrchestrator.ts | 533 +++++++++ frontend/src/hooks/usePreloadProgress.ts | 220 ++++ frontend/src/hooks/useReversePlayback.ts | 239 ++++ frontend/src/hooks/useStorageQuota.ts | 110 ++ frontend/src/layouts/Authenticated.tsx | 11 +- frontend/src/lib/assetUrl.ts | 44 + frontend/src/lib/logger.ts | 163 +++ frontend/src/lib/offline/DownloadEventBus.ts | 179 +++ frontend/src/lib/offline/DownloadManager.ts | 462 ++++++++ frontend/src/lib/offline/StorageManager.ts | 281 +++++ .../src/lib/offlineDb/OfflineDbManager.ts | 342 ++++++ frontend/src/lib/offlineDb/schema.ts | 41 + frontend/src/menuAside.ts | 11 +- frontend/src/pages/_app.tsx | 148 ++- .../src/pages/access_logs/[access_logsId].tsx | 49 +- .../pages/access_logs/access_logs-edit.tsx | 11 +- .../pages/access_logs/access_logs-list.tsx | 5 +- .../src/pages/access_logs/access_logs-new.tsx | 2 +- .../pages/access_logs/access_logs-table.tsx | 5 +- .../pages/access_logs/access_logs-view.tsx | 48 +- .../asset_variants/[asset_variantsId].tsx | 73 +- .../asset_variants/asset_variants-edit.tsx | 38 +- .../asset_variants/asset_variants-list.tsx | 10 +- .../asset_variants/asset_variants-new.tsx | 5 +- .../asset_variants/asset_variants-table.tsx | 9 +- .../asset_variants/asset_variants-view.tsx | 43 +- frontend/src/pages/assets/[assetsId].tsx | 90 +- frontend/src/pages/assets/assets-edit.tsx | 40 +- frontend/src/pages/assets/assets-list.tsx | 90 +- frontend/src/pages/assets/assets-new.tsx | 8 +- frontend/src/pages/assets/assets-table.tsx | 13 +- frontend/src/pages/assets/assets-view.tsx | 75 +- frontend/src/pages/constructor.tsx | 1032 +++++++++++++---- frontend/src/pages/dashboard.tsx | 159 +-- frontend/src/pages/forgot.tsx | 6 +- frontend/src/pages/index.tsx | 30 +- frontend/src/pages/login.tsx | 110 +- .../pages/page_elements/[page_elementsId].tsx | 109 +- .../page_elements/page_elements-edit.tsx | 62 +- .../page_elements/page_elements-list.tsx | 36 +- .../pages/page_elements/page_elements-new.tsx | 22 +- .../page_elements-project-edit.tsx | 616 ++++++++-- .../page_elements/page_elements-table.tsx | 25 +- .../page_elements/page_elements-view.tsx | 54 +- .../src/pages/page_links/[page_linksId].tsx | 70 +- .../src/pages/page_links/page_links-list.tsx | 3 +- .../src/pages/page_links/page_links-table.tsx | 3 +- .../src/pages/page_links/page_links-view.tsx | 59 +- .../src/pages/permissions/[permissionsId].tsx | 55 +- .../pages/permissions/permissions-view.tsx | 19 +- .../[presigned_url_requestsId].tsx | 101 +- .../presigned_url_requests-edit.tsx | 37 +- .../presigned_url_requests-list.tsx | 7 +- .../presigned_url_requests-new.tsx | 3 +- .../presigned_url_requests-table.tsx | 7 +- .../presigned_url_requests-view.tsx | 65 +- frontend/src/pages/privacy-policy.tsx | 14 +- .../[project_audio_tracksId].tsx | 102 +- .../project_audio_tracks-edit.tsx | 45 +- .../project_audio_tracks-list.tsx | 7 +- .../project_audio_tracks-new.tsx | 3 +- .../project_audio_tracks-table.tsx | 7 +- .../project_audio_tracks-view.tsx | 50 +- .../[project_membershipsId].tsx | 90 +- .../project_memberships-edit.tsx | 50 +- .../project_memberships-list.tsx | 7 +- .../project_memberships-new.tsx | 3 +- .../project_memberships-table.tsx | 7 +- .../project_memberships-view.tsx | 54 +- frontend/src/pages/projects/[projectsId].tsx | 20 +- frontend/src/pages/projects/projects-edit.tsx | 24 +- frontend/src/pages/projects/projects-list.tsx | 38 +- frontend/src/pages/projects/projects-new.tsx | 3 +- .../src/pages/projects/projects-table.tsx | 5 +- frontend/src/pages/projects/projects-view.tsx | 274 +++-- .../publish_events/[publish_eventsId].tsx | 104 +- .../publish_events/publish_events-edit.tsx | 57 +- .../publish_events/publish_events-list.tsx | 11 +- .../publish_events/publish_events-new.tsx | 3 +- .../publish_events/publish_events-table.tsx | 13 +- .../publish_events/publish_events-view.tsx | 69 +- .../src/pages/pwa_caches/[pwa_cachesId].tsx | 87 +- .../src/pages/pwa_caches/pwa_caches-list.tsx | 5 +- .../src/pages/pwa_caches/pwa_caches-table.tsx | 5 +- .../src/pages/pwa_caches/pwa_caches-view.tsx | 67 +- frontend/src/pages/register.tsx | 6 +- frontend/src/pages/roles/[rolesId].tsx | 66 +- frontend/src/pages/roles/roles-new.tsx | 3 +- frontend/src/pages/roles/roles-view.tsx | 115 +- frontend/src/pages/runtime.tsx | 210 +++- frontend/src/pages/search.tsx | 6 +- .../src/pages/tour_pages/[tour_pagesId].tsx | 101 +- .../src/pages/tour_pages/tour_pages-edit.tsx | 38 +- .../src/pages/tour_pages/tour_pages-new.tsx | 5 +- .../src/pages/tour_pages/tour_pages-table.tsx | 5 +- .../src/pages/tour_pages/tour_pages-view.tsx | 56 +- .../src/pages/transitions/[transitionsId].tsx | 92 +- .../pages/transitions/transitions-table.tsx | 5 +- .../pages/transitions/transitions-view.tsx | 52 +- frontend/src/pages/ui-elements.tsx | 6 +- frontend/src/pages/ui-elements/[id].tsx | 698 ++++++++--- frontend/src/pages/users/[usersId].tsx | 82 +- frontend/src/pages/users/users-new.tsx | 3 +- frontend/src/pages/users/users-view.tsx | 381 +++--- frontend/src/pages/verify-email.tsx | 6 +- frontend/src/stores/authSlice.ts | 1 + frontend/src/stores/usersSlice.ts | 6 +- frontend/src/sw.ts | 300 +++++ frontend/src/types/entities.ts | 48 +- frontend/src/types/offline.ts | 194 ++++ frontend/tsconfig.tsbuildinfo | 2 +- frontend/yarn.lock | 664 +++++------ 167 files changed, 10818 insertions(+), 3364 deletions(-) delete mode 100644 backend/src/routes/pexels.js create mode 100644 backend/src/services/pwa_manifest.js create mode 100644 frontend/public/manifest.json create mode 100644 frontend/src/components/Offline/DownloadProgressPanel.tsx create mode 100644 frontend/src/components/Offline/OfflineStatusIndicator.tsx create mode 100644 frontend/src/components/Offline/OfflineToggle.tsx create mode 100644 frontend/src/components/Offline/StorageUsageDisplay.tsx create mode 100644 frontend/src/components/Offline/index.ts create mode 100644 frontend/src/config/offline.config.ts create mode 100644 frontend/src/config/preload.config.ts create mode 100644 frontend/src/context/DownloadContext.tsx delete mode 100644 frontend/src/helpers/pexels.ts create mode 100644 frontend/src/hooks/useNeighborGraph.ts create mode 100644 frontend/src/hooks/useNetworkAware.ts create mode 100644 frontend/src/hooks/useOfflineMode.ts create mode 100644 frontend/src/hooks/usePreloadOrchestrator.ts create mode 100644 frontend/src/hooks/usePreloadProgress.ts create mode 100644 frontend/src/hooks/useReversePlayback.ts create mode 100644 frontend/src/hooks/useStorageQuota.ts create mode 100644 frontend/src/lib/assetUrl.ts create mode 100644 frontend/src/lib/logger.ts create mode 100644 frontend/src/lib/offline/DownloadEventBus.ts create mode 100644 frontend/src/lib/offline/DownloadManager.ts create mode 100644 frontend/src/lib/offline/StorageManager.ts create mode 100644 frontend/src/lib/offlineDb/OfflineDbManager.ts create mode 100644 frontend/src/lib/offlineDb/schema.ts create mode 100644 frontend/src/sw.ts create mode 100644 frontend/src/types/offline.ts diff --git a/backend/.eslintrc.cjs b/backend/.eslintrc.cjs index f312476..dc8e0db 100644 --- a/backend/.eslintrc.cjs +++ b/backend/.eslintrc.cjs @@ -10,6 +10,7 @@ module.exports = { 'import' ], rules: { - 'import/no-unresolved': 'error' + 'import/no-unresolved': 'error', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }] } }; diff --git a/backend/src/config.js b/backend/src/config.js index 5bcff70..5313a08 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -77,9 +77,6 @@ const config = { gpt_key: process.env.GPT_KEY || '', }; -config.pexelsKey = process.env.PEXELS_KEY || ''; - -config.pexelsQuery = 'Architect drafting blueprint'; config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost"; config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; diff --git a/backend/src/db/api/page_links.js b/backend/src/db/api/page_links.js index ec7e66a..6b59287 100644 --- a/backend/src/db/api/page_links.js +++ b/backend/src/db/api/page_links.js @@ -181,6 +181,27 @@ class Page_linksDBApi extends GenericDBApi { include[2].required = false; } + // Filter by project ID (through from_page's project association) + if (filter.project) { + const projectInclude = [{ + model: db.projects, + as: 'project', + required: true, + where: { + [Op.or]: [ + { id: { [Op.in]: filter.project.split('|').map(term => Utils.uuid(term)) } }, + { + name: { + [Op.or]: filter.project.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) + } + }, + ] + }, + }]; + include[0].include = [...(include[0].include || []), ...projectInclude]; + include[0].required = true; + } + if (filter.id) { where.id = Utils.uuid(filter.id); } diff --git a/backend/src/index.js b/backend/src/index.js index 1787c90..f38bc95 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,7 +15,6 @@ const authRoutes = require('./routes/auth'); const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); -const pexelsRoutes = require('./routes/pexels'); const openaiRoutes = require('./routes/openai'); @@ -172,7 +171,6 @@ app.get('/api/health', async (req, res) => { }); app.use('/api/auth', authRoutes); -app.use('/api/pexels', pexelsRoutes); app.use('/api/runtime-context', runtimeContextRoutes); @@ -258,7 +256,7 @@ if (fs.existsSync(publicDir)) { } // Generic error handler -app.use((err, req, res, next) => { +app.use((err, req, res, _next) => { if (!res.headersSent) { logger.error({ err, url: req.url, method: req.method }, 'Unhandled error'); res.status(500).json({ message: 'Internal server error' }); diff --git a/backend/src/routes/pexels.js b/backend/src/routes/pexels.js deleted file mode 100644 index 8298595..0000000 --- a/backend/src/routes/pexels.js +++ /dev/null @@ -1,104 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { pexelsKey, pexelsQuery } = require('../config'); -const fetch = require('node-fetch'); - -const KEY = pexelsKey; - -router.get('/image', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - const query = pexelsQuery || 'nature'; - const orientation = 'portrait'; - const perPage = 1; - const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; - - try { - const response = await fetch(url, { headers }); - const data = await response.json(); - res.status(200).json(data.photos[0]); - } catch (error) { - res.status(200).json({ error: 'Failed to fetch image' }); - } -}); - -router.get('/video', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - const query = pexelsQuery || 'nature'; - const orientation = 'portrait'; - const perPage = 1; - const url = `https://api.pexels.com/videos/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; - - try { - const response = await fetch(url, { headers }); - const data = await response.json(); - res.status(200).json(data.videos[0]); - } catch (error) { - res.status(200).json({ error: 'Failed to fetch video' }); - } -}); - -router.get('/multiple-images', async (req, res) => { - const headers = { - Authorization: `${KEY}`, - }; - - const queries = req.query.queries - ? req.query.queries.split(',') - : ['home', 'apple', 'pizza', 'mountains', 'cat']; - const orientation = 'square'; - const perPage = 1; - - const fallbackImage = { - src: 'https://images.pexels.com/photos/8199252/pexels-photo-8199252.jpeg', - photographer: 'Yan Krukau', - photographer_url: 'https://www.pexels.com/@yankrukov', - }; - const fetchFallbackImage = async () => { - try { - const response = await fetch('https://picsum.photos/600'); - return { - src: response.url, - photographer: 'Random Picsum', - photographer_url: 'https://picsum.photos/', - }; - } catch (error) { - return fallbackImage; - } - }; - const fetchImage = async (query) => { - const url = `https://api.pexels.com/v1/search?query=${query}&orientation=${orientation}&per_page=${perPage}&page=1`; - const response = await fetch(url, { headers }); - const data = await response.json(); - return data.photos[0] || null; - }; - - const imagePromises = queries.map((query) => fetchImage(query)); - const imagesResults = await Promise.allSettled(imagePromises); - - const formattedImages = await Promise.all(imagesResults.map(async (result) => { - if (result.status === 'fulfilled' && result.value) { - const image = result.value; - return { - src: image.src?.original || fallbackImage.src, - photographer: image.photographer || fallbackImage.photographer, - photographer_url: image.photographer_url || fallbackImage.photographer_url, - }; - } else { - const fallback = await fetchFallbackImage(); - return { - src: fallback.src || '', - photographer: fallback.photographer || 'Unknown', - photographer_url: fallback.photographer_url || '', - }; - } - })); - - - res.json(formattedImages); -}); - -module.exports = router; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index ce7666c..9ec68ce 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -457,12 +457,61 @@ router.get('/:id', wrapAsync(async (req, res) => { { 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 + */ +router.get('/:id/offline-manifest', wrapAsync(async (req, res) => { + if (!isUuidV4(req.params.id)) { + return res.status(400).send('Invalid project id'); + } + + const PWAManifestService = require('../services/pwa_manifest'); + const { variant = 'desktop' } = req.query; + + const manifest = await PWAManifestService.generateManifest( + req.params.id, + variant + ); + + res.status(200).json(manifest); +})); + router.use('/', require('../helpers').commonErrorHandler); module.exports = router; diff --git a/backend/src/services/pwa_manifest.js b/backend/src/services/pwa_manifest.js new file mode 100644 index 0000000..038b752 --- /dev/null +++ b/backend/src/services/pwa_manifest.js @@ -0,0 +1,273 @@ +/** + * PWA Manifest Service + * + * Generates offline manifests for PWA asset downloads. + */ + +const AssetsDBApi = require('../db/api/assets'); +const AssetVariantsDBApi = require('../db/api/asset_variants'); +const TourPagesDBApi = require('../db/api/tour_pages'); +const PageElementsDBApi = require('../db/api/page_elements'); +const TransitionsDBApi = require('../db/api/transitions'); + +/** + * Get asset type from MIME type or filename + */ +function getAssetType(mimeType, filename) { + if (!mimeType && !filename) return 'other'; + + const mime = (mimeType || '').toLowerCase(); + const name = (filename || '').toLowerCase(); + + if (mime.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|svg)$/.test(name)) { + return 'image'; + } + if (mime.startsWith('video/') || /\.(mp4|webm|mov)$/.test(name)) { + return 'video'; + } + if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|m4a)$/.test(name)) { + return 'audio'; + } + + return 'other'; +} + +/** + * Extract URLs from element content JSON + */ +function extractUrlsFromContent(contentJson) { + if (!contentJson) return []; + + try { + const content = + typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + const urls = []; + + const urlFields = [ + 'image_url', + 'video_url', + 'audio_url', + 'background_url', + 'src', + 'url', + 'poster', + 'thumbnail', + ]; + + const checkObject = (obj, depth = 0) => { + if (depth > 5 || !obj || typeof obj !== 'object') return; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' && urlFields.includes(key) && value.startsWith('http')) { + urls.push({ + url: value, + fieldType: key, + }); + } else if (typeof value === 'object' && value !== null) { + checkObject(value, depth + 1); + } + } + }; + + checkObject(content); + return urls; + } catch { + return []; + } +} + +class PWAManifestService { + /** + * Generate offline manifest for a project + * @param {string} projectId - Project ID + * @param {string} deviceType - 'mobile' or 'desktop' (affects variant selection) + * @returns {Object} Offline manifest + */ + static async generateManifest(projectId, deviceType = 'desktop') { + // Fetch all project data + const [assetsResult, pagesResult, elementsResult, transitionsResult] = + await Promise.all([ + AssetsDBApi.findAll({ project: projectId }, {}), + TourPagesDBApi.findAll({ project: projectId }, {}), + PageElementsDBApi.findAll({}, {}), // Filter by page IDs after + TransitionsDBApi.findAll({}, {}), + ]); + + const assets = assetsResult?.rows || []; + const pages = pagesResult?.rows || []; + const pageIds = pages.map((p) => p.id); + const elements = (elementsResult?.rows || []).filter((e) => + pageIds.includes(e.page || e.pageId) + ); + const transitions = transitionsResult?.rows || []; + + // Build asset manifest entries + const manifestAssets = []; + const seenUrls = new Set(); + + // Helper to add an asset to the manifest + const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => { + if (!url || seenUrls.has(url)) return; + seenUrls.add(url); + + manifestAssets.push({ + id: id || `url-${Date.now()}-${Math.random().toString(36).slice(2)}`, + url, + filename: filename || url.split('/').pop() || 'unknown', + variantType: variantType || 'original', + assetType: assetType || getAssetType(mimeType, filename), + mimeType: mimeType || 'application/octet-stream', + sizeBytes: sizeBytes || 0, + pageIds: pageIds || [], + }); + }; + + // Add assets with their variants + for (const asset of assets) { + // Get asset variants + const variants = await AssetVariantsDBApi.findAll({ asset: asset.id }, {}); + const variantRows = variants?.rows || []; + + // Select appropriate variants based on device type + const selectedVariants = this.selectVariants(variantRows, deviceType); + + for (const variant of selectedVariants) { + addAsset( + variant.id, + variant.url, + variant.filename || asset.filename, + variant.variant_type, + asset.type || getAssetType(asset.mime_type, asset.filename), + variant.mime_type || asset.mime_type, + variant.size_bytes || 0, + asset.pages?.map((p) => p.id) || [] + ); + } + + // If no variants, add original + if (selectedVariants.length === 0 && asset.url) { + addAsset( + asset.id, + asset.url, + asset.filename, + 'original', + asset.type || getAssetType(asset.mime_type, asset.filename), + asset.mime_type, + asset.size_bytes || 0, + asset.pages?.map((p) => p.id) || [] + ); + } + } + + // Add page background images/videos + for (const page of pages) { + if (page.background_image_url) { + addAsset( + `page-bg-${page.id}`, + page.background_image_url, + `page-${page.slug}-bg.jpg`, + 'original', + 'image', + 'image/jpeg', + 0, + [page.id] + ); + } + if (page.background_video_url) { + addAsset( + `page-video-${page.id}`, + page.background_video_url, + `page-${page.slug}-video.mp4`, + 'original', + 'video', + 'video/mp4', + 0, + [page.id] + ); + } + } + + // Add transition videos + for (const transition of transitions) { + if (transition.video_url) { + addAsset( + `transition-${transition.id}`, + transition.video_url, + `transition-${transition.slug || transition.id}.mp4`, + 'original', + 'transition', + 'video/mp4', + 0, + [] + ); + } + } + + // Extract URLs from element content + for (const element of elements) { + const contentUrls = extractUrlsFromContent(element.content_json); + for (const { url, fieldType } of contentUrls) { + const assetType = + fieldType.includes('video') ? 'video' : fieldType.includes('audio') ? 'audio' : 'image'; + addAsset( + `element-${element.id}-${fieldType}`, + url, + url.split('/').pop() || 'unknown', + 'original', + assetType, + null, + 0, + [element.page || element.pageId] + ); + } + } + + // Calculate total size + const totalSizeBytes = manifestAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0); + + return { + version: `v${Date.now()}`, + projectId, + projectSlug: '', // Would need project data + assets: manifestAssets, + totalSizeBytes, + generatedAt: Date.now(), + }; + } + + /** + * Select appropriate variants based on device type + */ + static selectVariants(variants, deviceType) { + if (!variants || variants.length === 0) return []; + + const selected = []; + + // Prioritize variants based on device type + const priority = + deviceType === 'mobile' + ? ['mp4_low', 'webp', 'thumbnail', 'preview', 'mp4_high', 'original'] + : ['mp4_high', 'webp', 'preview', 'mp4_low', 'thumbnail', 'original']; + + // Group variants by base asset + const variantMap = new Map(); + for (const variant of variants) { + const type = variant.variant_type; + if (priority.includes(type)) { + variantMap.set(type, variant); + } + } + + // Select best variant for each type + for (const type of priority) { + if (variantMap.has(type)) { + selected.push(variantMap.get(type)); + break; // Take first matching priority + } + } + + return selected; + } +} + +module.exports = PWAManifestService; diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 590e61f..7ee9d25 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -21,5 +21,30 @@ module.exports = { 'import/named': 'error', 'import/no-duplicates': 'error', 'import/no-unresolved': 'error', + // Disallow console - use logger from lib/logger.ts instead + 'no-console': 'error', }, + overrides: [ + { + // Allow console in the logger utility (it's the abstraction layer) + files: ['src/lib/logger.ts'], + rules: { + 'no-console': 'off', + }, + }, + { + // Service worker runs in isolated context, can't import app modules + files: ['src/sw.ts'], + rules: { + 'no-console': 'off', + }, + }, + { + // API routes run on server, use server-side logging + files: ['src/pages/api/**/*.ts'], + rules: { + 'no-console': 'off', + }, + }, + ], }; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 89767ec..53c683e 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,21 +1,30 @@ /** * @type {import('next').NextConfig} */ +import withSerwistInit from '@serwist/next'; const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone'; - const nextConfig = { -trailingSlash: true, + +// Configure Serwist for service worker generation +const withSerwist = withSerwistInit({ + swSrc: 'src/sw.ts', + swDest: 'public/sw.js', + disable: process.env.NODE_ENV === 'development', +}); + +const nextConfig = { + trailingSlash: true, distDir: 'build', output, - basePath: "", + basePath: '', devIndicators: { - position: 'bottom-left', + position: 'bottom-left', }, typescript: { - ignoreBuildErrors: true, + ignoreBuildErrors: true, }, eslint: { - ignoreDuringBuilds: true, + ignoreDuringBuilds: true, }, images: { unoptimized: true, @@ -26,7 +35,6 @@ trailingSlash: true, }, ], }, - -} +}; -export default nextConfig \ No newline at end of file +export default withSerwist(nextConfig); \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 94ac57a..f9a2912 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,16 +11,19 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mdi/js": "^7.4.47", + "@mdi/react": "^1.6.1", "@mui/material": "^6.3.0", "@mui/x-data-grid": "^7.0.0", "@reduxjs/toolkit": "^2.1.0", + "@serwist/next": "^9.5.7", "@tailwindcss/typography": "^0.5.13", - "@tinymce/tinymce-react": "^4.3.2", + "@tinymce/tinymce-react": "^6.3.0", "apexcharts": "^5.0.0", "axios": "^1.8.4", "chart.js": "^4.4.1", "chroma-js": "^2.4.2", "dayjs": "^1.11.10", + "dexie": "^4.3.0", "file-saver": "^2.0.5", "formik": "^2.4.5", "html2canvas": "^1.4.1", @@ -48,7 +51,7 @@ "react-i18next": "^15.5.1", "react-redux": "^9.0.0", "react-select": "^5.7.0", - "react-select-async-paginate": "^0.7.9", + "react-select-async-paginate": "^0.7.11", "react-switch": "^7.0.0", "react-toastify": "^11.0.2", "swr": "^2.0.0", @@ -74,6 +77,7 @@ "postcss": "^8.4.4", "postcss-import": "^14.1.0", "prettier": "^3.2.4", + "serwist": "^9.5.7", "tailwindcss": "^3.4.1", "typescript": "^5.4.5" } diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..0c8517f --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "Shimahara Visual Tour Builder", + "short_name": "Tour Builder", + "description": "Build, preview, and publish offline-ready interactive tours", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3B82F6", + "orientation": "any", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"], + "screenshots": [], + "prefer_related_applications": false, + "related_applications": [], + "scope": "/", + "lang": "en", + "dir": "ltr" +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index d5a09ec..45f6238 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,210 +1,2 @@ -/** - * Tour Builder Platform - Service Worker - * - * Provides offline caching for PWA functionality. - * Caches tour assets (images, videos, audio) for offline viewing. - */ - -const CACHE_NAME = 'tour-builder-v1'; -const STATIC_CACHE_NAME = 'tour-builder-static-v1'; -const DYNAMIC_CACHE_NAME = 'tour-builder-dynamic-v1'; - -// Static assets to cache on install -const STATIC_ASSETS = [ - '/', - '/runtime', - '/offline.html', -]; - -// Asset types to cache -const CACHEABLE_EXTENSIONS = [ - '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', - '.mp4', '.webm', '.mov', - '.mp3', '.wav', '.ogg', '.m4a', - '.woff', '.woff2', '.ttf', '.eot', - '.css', '.js', -]; - -// Check if request should be cached -const isCacheableRequest = (request) => { - const url = new URL(request.url); - - // Don't cache API requests (except static assets from /file/download) - if (url.pathname.startsWith('/api/') && !url.pathname.includes('/file/download')) { - return false; - } - - // Cache known asset extensions - const hasExtension = CACHEABLE_EXTENSIONS.some((ext) => url.pathname.toLowerCase().endsWith(ext)); - if (hasExtension) { - return true; - } - - // Cache file downloads (S3 presigned URLs, CDN assets) - if (url.pathname.includes('/file/download') || url.hostname.includes('amazonaws.com') || url.hostname.includes('cloudfront.net')) { - return true; - } - - return false; -}; - -// Install event - cache static assets -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(STATIC_CACHE_NAME).then((cache) => { - console.log('[SW] Caching static assets'); - return cache.addAll(STATIC_ASSETS).catch((error) => { - console.warn('[SW] Failed to cache some static assets:', error); - }); - }) - ); - self.skipWaiting(); -}); - -// Activate event - clean up old caches -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((name) => { - return name.startsWith('tour-builder-') && name !== STATIC_CACHE_NAME && name !== DYNAMIC_CACHE_NAME; - }) - .map((name) => { - console.log('[SW] Deleting old cache:', name); - return caches.delete(name); - }) - ); - }) - ); - self.clients.claim(); -}); - -// Fetch event - serve from cache or network -self.addEventListener('fetch', (event) => { - const { request } = event; - - // Only handle GET requests - if (request.method !== 'GET') { - return; - } - - // Skip non-http(s) requests - if (!request.url.startsWith('http')) { - return; - } - - event.respondWith( - caches.match(request).then((cachedResponse) => { - // Return cached response if available - if (cachedResponse) { - // Optionally update cache in background (stale-while-revalidate) - if (isCacheableRequest(request)) { - event.waitUntil( - fetch(request) - .then((networkResponse) => { - if (networkResponse && networkResponse.status === 200) { - const responseToCache = networkResponse.clone(); - caches.open(DYNAMIC_CACHE_NAME).then((cache) => { - cache.put(request, responseToCache); - }); - } - }) - .catch(() => { - // Network failed, but we have cache - that's fine - }) - ); - } - return cachedResponse; - } - - // Fetch from network - return fetch(request) - .then((networkResponse) => { - // Cache successful responses for cacheable requests - if (networkResponse && networkResponse.status === 200 && isCacheableRequest(request)) { - const responseToCache = networkResponse.clone(); - caches.open(DYNAMIC_CACHE_NAME).then((cache) => { - cache.put(request, responseToCache); - }); - } - return networkResponse; - }) - .catch((error) => { - console.warn('[SW] Fetch failed:', error); - - // Return offline page for navigation requests - if (request.mode === 'navigate') { - return caches.match('/offline.html'); - } - - // Return empty response for assets - return new Response('', { - status: 503, - statusText: 'Service Unavailable', - }); - }); - }) - ); -}); - -// Message event - handle commands from main thread -self.addEventListener('message', (event) => { - const { type, payload } = event.data || {}; - - switch (type) { - case 'CACHE_ASSETS': - // Cache specific assets for a project/page - if (Array.isArray(payload?.urls)) { - event.waitUntil( - caches.open(DYNAMIC_CACHE_NAME).then((cache) => { - return Promise.all( - payload.urls.map((url) => - fetch(url) - .then((response) => { - if (response.status === 200) { - return cache.put(url, response); - } - }) - .catch((error) => { - console.warn('[SW] Failed to cache asset:', url, error); - }) - ) - ); - }) - ); - } - break; - - case 'CLEAR_CACHE': - // Clear all dynamic caches - event.waitUntil( - caches.delete(DYNAMIC_CACHE_NAME).then(() => { - console.log('[SW] Dynamic cache cleared'); - }) - ); - break; - - case 'GET_CACHE_STATUS': - // Return current cache status - event.waitUntil( - caches.open(DYNAMIC_CACHE_NAME).then((cache) => { - return cache.keys().then((keys) => { - event.source.postMessage({ - type: 'CACHE_STATUS', - payload: { - cachedCount: keys.length, - urls: keys.map((request) => request.url), - }, - }); - }); - }) - ); - break; - - default: - break; - } -}); - -console.log('[SW] Service worker loaded'); +(()=>{"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':'b27047c3aec77402f6d7ff5b9e6a999a','url':'/_next/static/UdZg50KAiIA57PrSVK8gW/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/UdZg50KAiIA57PrSVK8gW/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/12142dde-27563c775e4f8cf0.js'},{'revision':null,'url':'/_next/static/chunks/1517-448167d58580fc02.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/4023-c9923b0b395bb0f7.js'},{'revision':null,'url':'/_next/static/chunks/4100-f9560c05ca491093.js'},{'revision':null,'url':'/_next/static/chunks/4271-ad5c7f8848172804.js'},{'revision':null,'url':'/_next/static/chunks/4542-dc59d09a930d4899.js'},{'revision':null,'url':'/_next/static/chunks/4587-c9e5910a896d025b.js'},{'revision':null,'url':'/_next/static/chunks/4712-3fffce404a072118.js'},{'revision':null,'url':'/_next/static/chunks/5117-9ee1ea8278f87610.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/6062-78e61590fb896a77.js'},{'revision':null,'url':'/_next/static/chunks/6d2b60a9-eb6c7fd9a57c4f19.js'},{'revision':null,'url':'/_next/static/chunks/764-1456dc10fb4078c8.js'},{'revision':null,'url':'/_next/static/chunks/7e42aecb-94f8c450c54b9556.js'},{'revision':null,'url':'/_next/static/chunks/8230-251e23b7a24ff307.js'},{'revision':null,'url':'/_next/static/chunks/8232-06043c4b3efac0d3.js'},{'revision':null,'url':'/_next/static/chunks/8317-4e9c090ccc089653.js'},{'revision':null,'url':'/_next/static/chunks/8779-77957446e53e7969.js'},{'revision':null,'url':'/_next/static/chunks/9002-7ddc8224be6392ac.js'},{'revision':null,'url':'/_next/static/chunks/9375.2c053880845a1e8b.js'},{'revision':null,'url':'/_next/static/chunks/98309536-d5583eac3828dd0e.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-341d9a28ea32a6d9.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-edit-7fa40ec1aac23882.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-list-0883a5dbe78cd921.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-new-2d9d506a9102a0b9.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-table-648641c0969f9c43.js'},{'revision':null,'url':'/_next/static/chunks/pages/access_logs/access_logs-view-d6f2af9623ea449e.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/%5Basset_variantsId%5D-f814dd7911671db7.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-edit-8439b3e7e5a6c891.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-list-e2a0417f149ce503.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-new-bc1e51037d088734.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-table-02cc100af42f1100.js'},{'revision':null,'url':'/_next/static/chunks/pages/asset_variants/asset_variants-view-6de4f12e93d33992.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/%5BassetsId%5D-7cc6ed291b0e4297.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-edit-9380e2627c09250a.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-list-d95d0edc2715ba6e.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-new-1d15c2b15a829fc0.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-table-53c778f8487f178e.js'},{'revision':null,'url':'/_next/static/chunks/pages/assets/assets-view-92f564143ad3cbaf.js'},{'revision':null,'url':'/_next/static/chunks/pages/constructor-c450da3e08b24965.js'},{'revision':null,'url':'/_next/static/chunks/pages/dashboard-0b84b4055c5690ab.js'},{'revision':null,'url':'/_next/static/chunks/pages/error-e0bd1c24aeb3d601.js'},{'revision':null,'url':'/_next/static/chunks/pages/forgot-78b70d91742fd5b1.js'},{'revision':null,'url':'/_next/static/chunks/pages/forms-4abb0b486c293f14.js'},{'revision':null,'url':'/_next/static/chunks/pages/index-59d7d6dae123c4d0.js'},{'revision':null,'url':'/_next/static/chunks/pages/login-f8c8717c4f56a66c.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/%5Bpage_elementsId%5D-94c24e13d8d44ded.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-edit-edda480c51a8fa6d.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-list-0124b3b74f82d896.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-new-81bc80f8143adf6b.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-project-edit-184da57651738096.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-table-ebd5d212234d783b.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_elements/page_elements-view-ee907b1e4f123434.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/%5Bpage_linksId%5D-b449421c89057915.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/page_links-edit-9d200d7233f6c4d6.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/page_links-list-682db50eb089f743.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/page_links-new-240370c62f7cb6aa.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/page_links-table-1c54db3f1dca8009.js'},{'revision':null,'url':'/_next/static/chunks/pages/page_links/page_links-view-76bf370b801ac912.js'},{'revision':null,'url':'/_next/static/chunks/pages/password-reset-18f74e912f3914f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/%5BpermissionsId%5D-015a7fd284f71e57.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-edit-f6d4a0eba96c10b6.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-list-2d7263c103b52585.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-new-08f24f069cd8a56a.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-table-d419a13776d38ee9.js'},{'revision':null,'url':'/_next/static/chunks/pages/permissions/permissions-view-c66b0b45f39045c4.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/%5Bpresigned_url_requestsId%5D-ceb3cf8762682fbd.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-edit-3ac5cc7793c11854.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-list-b4d7010719173095.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-new-81e160670e9af61d.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-table-ce7dd129cbc6cd89.js'},{'revision':null,'url':'/_next/static/chunks/pages/presigned_url_requests/presigned_url_requests-view-7c1aab954c0b9deb.js'},{'revision':null,'url':'/_next/static/chunks/pages/privacy-policy-3d8241b650bbb976.js'},{'revision':null,'url':'/_next/static/chunks/pages/profile-7964782fa31e7b6b.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/%5Bproject_audio_tracksId%5D-2e7ff03658c80b1f.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-edit-4bf8c83d956a245e.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-list-cd7f67939bb11b1f.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-new-c626b0d42e229d86.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-table-5440b16f34d60a35.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_audio_tracks/project_audio_tracks-view-974632ba2b8ef166.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/%5Bproject_membershipsId%5D-23c7f32314a0ba66.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-edit-2856a8a134a216f9.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-list-87528e51523c60ca.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-new-14a939cf19c4be75.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-table-fdd8db8d2b10fa44.js'},{'revision':null,'url':'/_next/static/chunks/pages/project_memberships/project_memberships-view-9da8688a32f164a2.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/%5BprojectsId%5D-9046894d1cc43ae8.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-edit-7bb6cf366f414ddb.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-list-0b33fee9a480dce2.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-new-6e6b2bf89b8decbb.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-table-ad851fdde5928e1b.js'},{'revision':null,'url':'/_next/static/chunks/pages/projects/projects-view-0529ee7d409c8710.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/%5Bpublish_eventsId%5D-0cde4b99f2589447.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-edit-6f187cb06551a1c7.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-list-4bea2de9414d64d0.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-new-ab26e3f165369295.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-table-3c15925267b1f9f5.js'},{'revision':null,'url':'/_next/static/chunks/pages/publish_events/publish_events-view-7a638f06c4ed7e32.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/%5Bpwa_cachesId%5D-ebcde703f390f5b2.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-edit-0e1ea47884e8d620.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-list-98c9eec3b3145bce.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-new-4b159f94f0fb9fcd.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-table-d17e5a955dc2fd49.js'},{'revision':null,'url':'/_next/static/chunks/pages/pwa_caches/pwa_caches-view-da2dcf6437f3540a.js'},{'revision':null,'url':'/_next/static/chunks/pages/register-74317198a1107978.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/%5BrolesId%5D-f95397bc8ca29089.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-edit-6a6951d2b96ec883.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-list-45a3aa5514fc8a7c.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-new-bb561de6076af9b9.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-table-702ea329e048306d.js'},{'revision':null,'url':'/_next/static/chunks/pages/roles/roles-view-7d61cebaa8cf3e1a.js'},{'revision':null,'url':'/_next/static/chunks/pages/runtime-53cf8cedbabbad01.js'},{'revision':null,'url':'/_next/static/chunks/pages/search-4e8e0c686eb502bb.js'},{'revision':null,'url':'/_next/static/chunks/pages/tables-d590c70884fcf95f.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-bc78bfde54bc2ab0.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-edit-234fbf3bc55f4548.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-list-963ed13e6492b6ce.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-new-311f1ed8da97a407.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-table-7679d454b21bfd4e.js'},{'revision':null,'url':'/_next/static/chunks/pages/tour_pages/tour_pages-view-9beec20f6c358a19.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/%5BtransitionsId%5D-efd6a5f8ffe07347.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/transitions-edit-29eed83b84f043bf.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/transitions-list-c76cbf702d5839d6.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/transitions-new-4eddd202b8621ca3.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/transitions-table-7bac178d9781acff.js'},{'revision':null,'url':'/_next/static/chunks/pages/transitions/transitions-view-d82489f7265bd179.js'},{'revision':null,'url':'/_next/static/chunks/pages/ui-elements-ec69b6de43c7eea1.js'},{'revision':null,'url':'/_next/static/chunks/pages/ui-elements/%5Bid%5D-06c8f30d61eb93ac.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/%5BusersId%5D-7dd9bc34a2557906.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-edit-4efc42eee3a6a0c9.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-list-4e3ecae926f74c21.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-new-d524c9065c15d3f6.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-table-f3171e3c9afba4d1.js'},{'revision':null,'url':'/_next/static/chunks/pages/users/users-view-961059cff4fbe3f8.js'},{'revision':null,'url':'/_next/static/chunks/pages/verify-email-3c9a0ef6c6d5daff.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-9bfc6282834605db.js'},{'revision':null,'url':'/_next/static/css/715be398208dca58.css'},{'revision':null,'url':'/_next/static/css/9bcdc59efbbc3fe4.css'},{'revision':null,'url':'/_next/static/css/de6fa09b8a0934d1.css'},{'revision':'43778b43fb039fdd5b0510561dc952ac','url':'/assets/vm-shot-2026-03-17T04-16-09-161Z.jpg'},{'revision':'3395d49fe2b96221471b4db0f5caf9e7','url':'/assets/vm-shot-2026-03-17T04-19-03-565Z.jpg'},{'revision':'62127cd8f85821f3e570a377a8a7e14b','url':'/assets/vm-shot-2026-03-17T04-36-56-252Z.jpg'},{'revision':'145c2d7e4ef298391258c6d8a8aaaece','url':'/assets/vm-shot-2026-03-17T04-45-14-111Z.jpg'},{'revision':'c6aae6521f08c847e370764d1c9c613d','url':'/assets/vm-shot-2026-03-17T04-50-58-546Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg'},{'revision':'73de6cac0695249f4e59ce778a8e6e74','url':'/assets/vm-shot-2026-03-19T06-13-19-234Z.jpg'},{'revision':'bf90fb959c1d418b01eb7e55de6672d5','url':'/assets/vm-shot-2026-03-19T06-13-54-729Z.jpg'},{'revision':'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.static,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 eb({cacheName:eq.cacheNames.dynamic})},...eS]});self.addEventListener("message",e=>{let{type:t,payload:a}=e.data||{};switch(t){case"CACHE_ASSETS":Array.isArray(null==a?void 0:a.urls)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>Promise.all(a.urls.map(t=>fetch(t).then(a=>{if(200===a.status)return e.put(t,a)}).catch(e=>{console.warn("[SW] Failed to cache asset:",t,e)})))));break;case"CACHE_VIDEO_CHUNK":(null==a?void 0:a.url)&&(null==a?void 0:a.chunk)&&e.waitUntil(caches.open(eq.cacheNames.assets).then(e=>{let t=new Response(a.chunk,{headers:{"Content-Type":a.contentType||"video/mp4","Content-Length":String(a.chunk.byteLength)}});return e.put(a.url,t)}));break;case"CLEAR_CACHE":e.waitUntil(Promise.all([caches.delete(eq.cacheNames.dynamic),caches.delete(eq.cacheNames.assets)]).then(()=>{console.log("[SW] Caches cleared")}));break;case"GET_CACHE_STATUS":e.waitUntil(caches.open(eq.cacheNames.assets).then(t=>t.keys().then(t=>{let a=e.source;null==a||a.postMessage({type:"CACHE_STATUS",payload:{cachedCount:t.length,urls:t.map(e=>e.url)}})})));break;case"SKIP_WAITING":self.skipWaiting()}}),eD.addEventListeners(),console.log("[SW] Serwist service worker loaded")})(); \ No newline at end of file diff --git a/frontend/src/components/Access_logs/configureAccess_logsCols.tsx b/frontend/src/components/Access_logs/configureAccess_logsCols.tsx index 1d760ea..54f69bc 100644 --- a/frontend/src/components/Access_logs/configureAccess_logsCols.tsx +++ b/frontend/src/components/Access_logs/configureAccess_logsCols.tsx @@ -1,24 +1,18 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; export const loadColumns = async ( onDelete: Params, entityName: string, - - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -26,7 +20,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -46,11 +43,12 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', - getOptionValue: (value: any) => value?.id, - getOptionLabel: (value: any) => value?.label, + type: 'singleSelect' as const, + getOptionValue: (value: { id?: string }) => value?.id, + getOptionLabel: (value: { label?: string }) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string) => + (typeof value === 'object' ? value?.id : value) ?? value, }, { @@ -81,7 +79,9 @@ export const loadColumns = async ( getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('users'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { diff --git a/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx b/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx index 865b5e7..1d8a80e 100644 --- a/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx +++ b/frontend/src/components/Asset_variants/configureAsset_variantsCols.tsx @@ -1,15 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -17,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -26,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -46,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('assets'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -121,7 +121,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Assets/ProjectSelector.tsx b/frontend/src/components/Assets/ProjectSelector.tsx index 19e7c64..a7e268b 100644 --- a/frontend/src/components/Assets/ProjectSelector.tsx +++ b/frontend/src/components/Assets/ProjectSelector.tsx @@ -2,6 +2,7 @@ import axios from 'axios'; import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; +import { logger } from '../../lib/logger'; export type Project = { id: string; @@ -82,7 +83,7 @@ export function useProjectSelector({ } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Failed to load projects:', errorMessage); + logger.error('Failed to load projects:', { error: errorMessage }); setProjects([]); setSelectedProjectId(''); toast('Failed to load projects', { diff --git a/frontend/src/components/Assets/configureAssetsCols.tsx b/frontend/src/components/Assets/configureAssetsCols.tsx index a268bf2..f21b21b 100644 --- a/frontend/src/components/Assets/configureAssetsCols.tsx +++ b/frontend/src/components/Assets/configureAssetsCols.tsx @@ -1,15 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { GridActionsCellItem, GridRowParams } from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -17,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -26,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -46,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -232,13 +232,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.deleted_at_time), }, { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Assets/useAssetUploader.ts b/frontend/src/components/Assets/useAssetUploader.ts index d93475e..0b2909d 100644 --- a/frontend/src/components/Assets/useAssetUploader.ts +++ b/frontend/src/components/Assets/useAssetUploader.ts @@ -4,6 +4,7 @@ import { toast } from 'react-toastify'; import FileUploader from '../Uploaders/UploadService'; import type { AssetSection } from './AssetSectionCard'; import type { UploadQueueItem } from './UploadProgressList'; +import { logger } from '../../lib/logger'; interface UseAssetUploaderOptions { selectedProjectId: string; @@ -190,7 +191,10 @@ export function useAssetUploader({ axiosError?.response?.data?.message || axiosError?.message || 'Upload failed'; - console.error(`Failed to upload ${item.file.name}:`, error); + logger.error( + `Failed to upload ${item.file.name}:`, + error instanceof Error ? error : { error }, + ); failedCount += 1; updateSectionUpload(section.key, item.itemId, { status: 'error', diff --git a/frontend/src/components/DevModeBadge.tsx b/frontend/src/components/DevModeBadge.tsx index 2d226a0..402d0d0 100644 --- a/frontend/src/components/DevModeBadge.tsx +++ b/frontend/src/components/DevModeBadge.tsx @@ -49,7 +49,7 @@ Current request is compiling and may take a few moments. useEffect(() => { if ( process.env.NODE_ENV === 'development' || - process.env.NODE_ENV === 'dev_stage' + (process.env.NODE_ENV as string) === 'dev_stage' ) { setIsVisible(true); diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 490bd95..f6090f4 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,6 +1,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { mdiAlertCircle } from '@mdi/js'; import BaseIcon from './BaseIcon'; +import { logger } from '../lib/logger'; // Define the props and state interfaces interface ErrorBoundaryProps { @@ -42,19 +43,19 @@ class ErrorBoundary extends Component { snapshot?: any, ) { if (process.env.NODE_ENV !== 'production') { - console.log('componentDidUpdate'); + logger.debug('componentDidUpdate'); } } async componentWillUnmount() { if (process.env.NODE_ENV !== 'production') { - console.log('componentWillUnmount'); + logger.debug('componentWillUnmount'); const response = await fetch('/api/logError', { method: 'DELETE', }); const data = await response.json(); - console.log('Error logs cleared:', data); + logger.debug('Error logs cleared:', data); } } @@ -66,7 +67,7 @@ class ErrorBoundary extends Component { // Only perform logging in non-production environments if (process.env.NODE_ENV !== 'production') { - console.log('Error caught in boundary:', error, errorInfo); + logger.info('Error caught in boundary:', { error, errorInfo }); // Function to log errors to the server const logErrorToServer = async () => { @@ -83,9 +84,12 @@ class ErrorBoundary extends Component { }); const data = await response.json(); - console.log('Error logged:', data); + logger.debug('Error logged:', data); } catch (err) { - console.error('Failed to log error:', err); + logger.error( + 'Failed to log error:', + err instanceof Error ? err : { error: err }, + ); } }; @@ -94,9 +98,12 @@ class ErrorBoundary extends Component { try { const response = await fetch('/api/logError'); const data = await response.json(); - console.log('Fetched logs:', data); + logger.debug('Fetched logs:', data); } catch (err) { - console.error('Failed to fetch logs:', err); + logger.error( + 'Failed to fetch logs:', + err instanceof Error ? err : { error: err }, + ); } }; @@ -129,9 +136,12 @@ class ErrorBoundary extends Component { }); const data = await response.json(); - console.log('Error logs cleared:', data); + logger.debug('Error logs cleared:', data); } catch (e) { - console.error('Failed to clear error logs:', e); + logger.error( + 'Failed to clear error logs:', + e instanceof Error ? e : { error: e }, + ); } } diff --git a/frontend/src/components/Generic/GenericTable.tsx b/frontend/src/components/Generic/GenericTable.tsx index e69de29..9c72c73 100644 --- a/frontend/src/components/Generic/GenericTable.tsx +++ b/frontend/src/components/Generic/GenericTable.tsx @@ -0,0 +1,428 @@ +/** + * GenericTable Component + * + * A reusable data grid component that handles fetching, displaying, + * filtering, and editing entity data using MUI X DataGrid. + */ + +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + DataGrid, + GridRowsProp, + GridColDef, + GridRowModesModel, + GridEventListener, + GridRowEditStopReasons, + GridRowModel, + GridRowSelectionModel, + GridPaginationModel, + GridSortModel, +} from '@mui/x-data-grid'; +import { ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import CardBox from '../CardBox'; +import BaseButton from '../BaseButton'; +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 { 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; + sliceSelector: (state: RootState) => EntitySliceState; + fetchAction: AsyncThunk< + T | { rows: T[]; count: number }, + { id?: string; query?: string }, + object + >; + updateAction: AsyncThunk }, object>; + deleteAction: AsyncThunk; + deleteByIdsAction?: AsyncThunk; + setRefetchAction: (refetch: boolean) => { type: string; payload: boolean }; + loadColumnsFunction: ( + onDelete: (id: string) => void, + entityName: string, + user: unknown, + ) => Promise; + filters: Filter[]; + filterItems: FilterItem[]; + setFilterItems: (items: FilterItem[]) => void; + extraQuery?: string; +} + +function GenericTable({ + entityName, + sliceSelector, + fetchAction, + updateAction, + deleteAction, + deleteByIdsAction, + setRefetchAction, + loadColumnsFunction, + filters, + filterItems, + setFilterItems, + extraQuery = '', +}: GenericTableProps) { + const dispatch = useAppDispatch(); + 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[]) || []; + const { count, loading, notify, refetch } = entityState; + + // Style selectors + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + + // Local state + const [columns, setColumns] = useState([]); + const [rowModesModel, setRowModesModel] = useState({}); + const [rowSelectionModel, setRowSelectionModel] = + useState([]); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const [sortModel, setSortModel] = useState([]); + const [itemIdToDelete, setItemIdToDelete] = useState(null); + + // Control classes for form fields + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + `${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + // Handle delete action + const onDelete = useCallback((id: string) => { + setItemIdToDelete(id); + }, []); + + // Load columns on mount + useEffect(() => { + async function loadCols() { + const cols = await loadColumnsFunction(onDelete, entityName, currentUser); + setColumns(cols); + } + loadCols(); + }, [loadColumnsFunction, entityName, currentUser, onDelete]); + + // Generate filter query string + const generateFilterRequests = useMemo(() => { + let request = ''; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) request += `&${item.fields.selectedField}Range=${from}`; + if (to) request += `&${item.fields.selectedField}Range=${to}`; + } else { + const value = item.fields.filterValue; + if (value) request += `&${item.fields.selectedField}=${value}`; + } + }); + return request; + }, [filterItems, filters]); + + // Fetch data + useEffect(() => { + const sortQuery = sortModel.length + ? `&sortBy=${sortModel[0].field}&sortOrder=${sortModel[0].sort}` + : ''; + const query = `?page=${paginationModel.page + 1}&limit=${paginationModel.pageSize}${sortQuery}${generateFilterRequests}${extraQuery}`; + dispatch(fetchAction({ query })); + }, [ + dispatch, + fetchAction, + paginationModel, + sortModel, + generateFilterRequests, + extraQuery, + ]); + + // Handle refetch flag + useEffect(() => { + if (refetch) { + dispatch(setRefetchAction(false)); + const sortQuery = sortModel.length + ? `&sortBy=${sortModel[0].field}&sortOrder=${sortModel[0].sort}` + : ''; + const query = `?page=${paginationModel.page + 1}&limit=${paginationModel.pageSize}${sortQuery}${generateFilterRequests}${extraQuery}`; + dispatch(fetchAction({ query })); + } + }, [ + refetch, + dispatch, + setRefetchAction, + fetchAction, + paginationModel, + sortModel, + generateFilterRequests, + extraQuery, + ]); + + // Show notifications + useEffect(() => { + if (notify.showNotification && notify.textNotification) { + const toastType = + notify.typeNotification === 'warn' + ? 'warning' + : notify.typeNotification || 'info'; + toast(notify.textNotification, { + type: toastType as 'success' | 'error' | 'info' | 'warning', + }); + } + }, [notify]); + + // Handle delete confirmation + useEffect(() => { + if (itemIdToDelete) { + dispatch(deleteAction(itemIdToDelete)); + setItemIdToDelete(null); + } + }, [itemIdToDelete, dispatch, deleteAction]); + + // Row edit handlers + const handleRowEditStop: GridEventListener<'rowEditStop'> = ( + params, + event, + ) => { + if (params.reason === GridRowEditStopReasons.rowFocusOut) { + event.defaultMuiPrevented = true; + } + }; + + const processRowUpdate = async ( + newRow: GridRowModel, + ): Promise => { + await dispatch( + updateAction({ id: newRow.id as string, data: newRow as Partial }), + ); + return newRow; + }; + + const handleProcessRowUpdateError = (error: Error) => { + toast.error(`Error updating row: ${error.message}`); + }; + + // Filter handlers + const handleFilterChange = + (id: string) => + (e: React.ChangeEvent) => { + const value = e.target.value; + const name = e.target.name as keyof FilterFields; + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') { + return { + id, + fields: { + selectedField: value, + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + }, + }; + } + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const deleteFilter = (id: string) => { + setFilterItems(filterItems.filter((item) => item.id !== id)); + }; + + const handleResetFilters = () => { + setFilterItems([]); + }; + + // Bulk delete handler + const handleBulkDelete = () => { + if (deleteByIdsAction && rowSelectionModel.length > 0) { + dispatch(deleteByIdsAction(rowSelectionModel as string[])); + setRowSelectionModel([]); + } + }; + + return ( + <> + {/* Filter Panel */} + {filterItems && Array.isArray(filterItems) && filterItems.length > 0 && ( + + { + /* Filters are applied on change, not submit */ + }} + > +
+ {filterItems.map((filterItem) => ( +
+
+
+ Filter +
+ + + {filters.map((filter) => ( + + ))} + +
+ + {/* Enum filter */} + {filters.find( + (f) => f.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
+ Value +
+ + + {filters + .find( + (f) => + f.title === filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : ( +
+
+ Contains +
+ +
+ )} + +
+
+ Action +
+ deleteFilter(filterItem.id)} + /> +
+
+ ))} +
+ +
+
+
+
+ )} + + {/* Bulk Actions */} + {rowSelectionModel.length > 0 && deleteByIdsAction && ( +
+ +
+ )} + + {/* Data Grid */} + +
+ row.id} + sx={{ + border: 'none', + '& .MuiDataGrid-cell': { + borderColor: 'rgba(0,0,0,0.1)', + }, + '& .MuiDataGrid-columnHeaders': { + backgroundColor: 'rgba(0,0,0,0.02)', + }, + }} + /> +
+
+ + + + ); +} + +export default GenericTable; diff --git a/frontend/src/components/ImageField.tsx b/frontend/src/components/ImageField.tsx index 2bf42ae..bbf7c3c 100644 --- a/frontend/src/components/ImageField.tsx +++ b/frontend/src/components/ImageField.tsx @@ -1,15 +1,11 @@ -/* eslint-disable @next/next/no-img-element */ -// Why disabled: -// avatars.dicebear.com provides svg avatars -// next/image needs dangerouslyAllowSVG option for that - import React, { ReactNode } from 'react'; +import Image from 'next/image'; import { mdiImageOutline } from '@mdi/js'; import BaseIcon from './BaseIcon'; type Props = { name: string; - image?: object | null; + image?: Array<{ publicUrl?: string }> | null; api?: string; className?: string; imageClassName?: string; @@ -28,11 +24,15 @@ export default function ImageField({ return (
{imageSrc ? ( - {name} +
+ {name} +
) : (
{ + const cardRef = useRef(null); const [{ isDragging }, drag] = useDrag( () => ({ type: 'box', @@ -30,9 +31,16 @@ const KanbanCard = ({ [item], ); + // Connect the drag ref to the DOM element + useEffect(() => { + if (cardRef.current) { + drag(cardRef.current); + } + }, [drag]); + return (
diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx index 86f8c7e..28e7b1d 100644 --- a/frontend/src/components/KanbanBoard/KanbanColumn.tsx +++ b/frontend/src/components/KanbanBoard/KanbanColumn.tsx @@ -6,6 +6,7 @@ import CardBoxModal from '../CardBoxModal'; import { AsyncThunk } from '@reduxjs/toolkit'; import { useDrop } from 'react-dnd'; import KanbanCard from './KanbanCard'; +import { logger } from '../../lib/logger'; type Props = { column: { id: string; label: string }; @@ -100,7 +101,10 @@ const KanbanColumn = ({ setCurrentPage(page); }) .catch((err) => { - console.error(err); + logger.error( + 'Failed to load data:', + err instanceof Error ? err : { error: err }, + ); }) .finally(() => { setLoading(false); @@ -149,7 +153,10 @@ const KanbanColumn = ({ } }) .catch((err) => { - console.error(err); + logger.error( + 'Delete operation failed:', + err instanceof Error ? err : { error: err }, + ); }) .finally(() => { setItemIdToDelete(''); diff --git a/frontend/src/components/Offline/DownloadProgressPanel.tsx b/frontend/src/components/Offline/DownloadProgressPanel.tsx new file mode 100644 index 0000000..771585c --- /dev/null +++ b/frontend/src/components/Offline/DownloadProgressPanel.tsx @@ -0,0 +1,193 @@ +/** + * DownloadProgressPanel Component + * + * Shows progress of all active downloads with pause/resume/cancel controls. + * Similar to hoboken's VideoOptimizationDropdown pattern. + */ + +import React from 'react'; +import { + mdiPause, + mdiPlay, + mdiClose, + mdiCheck, + mdiAlertCircle, + mdiLoading, +} from '@mdi/js'; +import Icon from '@mdi/react'; +import { usePreloadProgress } from '../../hooks/usePreloadProgress'; + +interface DownloadProgressPanelProps { + className?: string; + maxItems?: number; + showWhenEmpty?: boolean; +} + +export function DownloadProgressPanel({ + className = '', + maxItems = 5, + showWhenEmpty = false, +}: DownloadProgressPanelProps) { + const { + jobs, + activeCount, + completedCount, + errorCount, + totalProgress, + isActive, + clearJob, + clearAllCompleted, + clearAllErrors, + } = usePreloadProgress(); + + // Don't render if empty and not configured to show + if (!showWhenEmpty && jobs.length === 0) { + return null; + } + + const visibleJobs = jobs.slice(0, maxItems); + const hiddenCount = jobs.length - maxItems; + + return ( +
+ {/* Header */} +
+
+ Downloads + {isActive && ( + {activeCount} active + )} +
+
+ {completedCount > 0 && ( + + )} + {errorCount > 0 && ( + + )} +
+
+ + {/* Overall progress */} + {isActive && ( +
+
+ Overall progress + {totalProgress}% +
+
+
+
+
+ )} + + {/* Job list */} +
+ {visibleJobs.map((job) => ( +
+ {/* Status icon */} +
+ {job.status === 'downloading' && ( + + )} + {job.status === 'completed' && ( + + )} + {job.status === 'error' && ( + + )} + {job.status === 'queued' && ( + + )} +
+ + {/* Info */} +
+
+ {job.filename} +
+ {job.status === 'downloading' && ( +
+
+
+ )} + {job.status === 'error' && job.error && ( +
{job.error}
+ )} +
+ + {/* Progress/size */} +
+ {job.status === 'downloading' && `${job.progress}%`} + {job.status === 'completed' && formatBytes(job.totalBytes)} +
+ + {/* Close button */} + +
+ ))} + + {hiddenCount > 0 && ( +
+ +{hiddenCount} more items +
+ )} + + {jobs.length === 0 && showWhenEmpty && ( +
+ No active downloads +
+ )} +
+
+ ); +} + +/** + * Format bytes to human-readable string + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export default DownloadProgressPanel; diff --git a/frontend/src/components/Offline/OfflineStatusIndicator.tsx b/frontend/src/components/Offline/OfflineStatusIndicator.tsx new file mode 100644 index 0000000..bd03399 --- /dev/null +++ b/frontend/src/components/Offline/OfflineStatusIndicator.tsx @@ -0,0 +1,61 @@ +/** + * OfflineStatusIndicator Component + * + * Small badge showing offline status (for use in headers/toolbars). + */ + +import React from 'react'; +import { mdiCloudCheck, mdiCloudOffOutline, mdiCloudSync } from '@mdi/js'; +import Icon from '@mdi/react'; +import { useNetworkAware } from '../../hooks/useNetworkAware'; +import { usePreloadProgress } from '../../hooks/usePreloadProgress'; + +interface OfflineStatusIndicatorProps { + className?: string; + showLabel?: boolean; + projectId?: string | null; +} + +export function OfflineStatusIndicator({ + className = '', + showLabel = false, +}: OfflineStatusIndicatorProps) { + const { networkInfo } = useNetworkAware(); + const { isActive, totalProgress } = usePreloadProgress(); + + // Determine status + let icon = mdiCloudCheck; + let label = 'Online'; + let colorClass = 'text-green-500'; + let bgClass = 'bg-green-50'; + + if (!networkInfo.isOnline) { + icon = mdiCloudOffOutline; + label = 'Offline'; + colorClass = 'text-gray-500'; + bgClass = 'bg-gray-100'; + } else if (isActive) { + icon = mdiCloudSync; + label = `Syncing ${totalProgress}%`; + colorClass = 'text-blue-500'; + bgClass = 'bg-blue-50'; + } + + return ( +
+ + {showLabel && ( + {label} + )} +
+ ); +} + +export default OfflineStatusIndicator; diff --git a/frontend/src/components/Offline/OfflineToggle.tsx b/frontend/src/components/Offline/OfflineToggle.tsx new file mode 100644 index 0000000..1e3c1f3 --- /dev/null +++ b/frontend/src/components/Offline/OfflineToggle.tsx @@ -0,0 +1,149 @@ +/** + * OfflineToggle Component + * + * Button to toggle offline mode for a project. + * Shows download status, size estimate, and provides download/delete actions. + */ + +import React from 'react'; +import { + mdiCloudDownload, + mdiCloudCheck, + mdiCloudOff, + mdiDelete, +} from '@mdi/js'; +import Icon from '@mdi/react'; +import BaseButton from '../BaseButton'; +import { useOfflineMode } from '../../hooks/useOfflineMode'; +import { useStorageQuota } from '../../hooks/useStorageQuota'; + +interface OfflineToggleProps { + projectId: string | null; + projectSlug?: string; + projectName?: string; + className?: string; + showLabel?: boolean; + size?: 'small' | 'medium' | 'large'; +} + +export function OfflineToggle({ + projectId, + projectSlug, + projectName, + className = '', + showLabel = true, + size = 'medium', +}: OfflineToggleProps) { + const { + isOfflineCapable, + isDownloaded, + isDownloading, + status, + progress, + startDownload, + pauseDownload, + resumeDownload, + cancelDownload, + deleteOfflineData, + estimatedSize, + formatSize, + } = useOfflineMode({ + projectId, + projectSlug, + projectName, + }); + + const { canStore, isWarning, isCritical } = useStorageQuota(); + + // Don't render if offline not supported + if (!isOfflineCapable) { + return null; + } + + const handleClick = () => { + if (isDownloaded) { + // Show confirmation before deleting + if (confirm('Remove offline data for this project?')) { + deleteOfflineData(); + } + } else if (isDownloading) { + pauseDownload(); + } else if (status === 'error') { + resumeDownload(); + } else { + // Check storage before starting + if (isCritical) { + alert( + 'Storage space is critically low. Please free up some space first.', + ); + return; + } + if (isWarning && estimatedSize > 0) { + if ( + !confirm( + `Storage space is running low. Download ${formatSize(estimatedSize)} anyway?`, + ) + ) { + return; + } + } + startDownload(); + } + }; + + // Determine icon and label + let icon = mdiCloudDownload; + let label = 'Download for offline'; + let color: 'info' | 'success' | 'danger' | 'warning' | 'lightDark' = 'info'; + + if (isDownloaded) { + icon = mdiCloudCheck; + label = 'Available offline'; + color = 'success'; + } else if (isDownloading) { + icon = mdiCloudDownload; + label = `Downloading ${progress}%`; + color = 'info'; + } else if (status === 'error') { + icon = mdiCloudOff; + label = 'Retry download'; + color = 'danger'; + } else if (status === 'outdated') { + icon = mdiCloudDownload; + label = 'Update available'; + color = 'warning'; + } + + const sizeLabel = + estimatedSize > 0 && !isDownloaded && !isDownloading + ? ` (${formatSize(estimatedSize)})` + : ''; + + return ( +
+ + {isDownloaded && ( + + )} +
+ ); +} + +export default OfflineToggle; diff --git a/frontend/src/components/Offline/StorageUsageDisplay.tsx b/frontend/src/components/Offline/StorageUsageDisplay.tsx new file mode 100644 index 0000000..41e5ea4 --- /dev/null +++ b/frontend/src/components/Offline/StorageUsageDisplay.tsx @@ -0,0 +1,124 @@ +/** + * StorageUsageDisplay Component + * + * Visual display of storage quota usage. + */ + +import React from 'react'; +import { mdiDatabase } from '@mdi/js'; +import Icon from '@mdi/react'; +import { useStorageQuota } from '../../hooks/useStorageQuota'; + +interface StorageUsageDisplayProps { + className?: string; + showDetails?: boolean; + compact?: boolean; +} + +export function StorageUsageDisplay({ + className = '', + showDetails = true, + compact = false, +}: StorageUsageDisplayProps) { + const { + usage, + quota, + percentUsed, + isLoading, + isWarning, + isCritical, + isPersisted, + formatSize, + requestPersistence, + } = useStorageQuota(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + // Determine color based on usage + let barColor = 'bg-blue-500'; + let textColor = 'text-gray-600'; + + if (isCritical) { + barColor = 'bg-red-500'; + textColor = 'text-red-600'; + } else if (isWarning) { + barColor = 'bg-yellow-500'; + textColor = 'text-yellow-600'; + } + + if (compact) { + return ( +
+ +
+
+
+ + {Math.round(percentUsed)}% + +
+ ); + } + + return ( +
+
+
+ + Storage +
+ {showDetails && ( + + {formatSize(usage)} / {formatSize(quota)} + + )} +
+ + {/* Progress bar */} +
+
+
+ + {/* Status messages */} + {showDetails && ( +
+ + {isCritical + ? 'Storage critically low' + : isWarning + ? 'Storage running low' + : `${Math.round(100 - percentUsed)}% available`} + + {!isPersisted && ( + + )} + {isPersisted && ( + Persistent storage enabled + )} +
+ )} +
+ ); +} + +export default StorageUsageDisplay; diff --git a/frontend/src/components/Offline/index.ts b/frontend/src/components/Offline/index.ts new file mode 100644 index 0000000..6fbabb5 --- /dev/null +++ b/frontend/src/components/Offline/index.ts @@ -0,0 +1,10 @@ +/** + * Offline Components Index + * + * Export all offline-related UI components. + */ + +export { OfflineToggle } from './OfflineToggle'; +export { DownloadProgressPanel } from './DownloadProgressPanel'; +export { OfflineStatusIndicator } from './OfflineStatusIndicator'; +export { StorageUsageDisplay } from './StorageUsageDisplay'; diff --git a/frontend/src/components/Page_elements/TablePage_elements.tsx b/frontend/src/components/Page_elements/TablePage_elements.tsx index f44e8c4..f7326cf 100644 --- a/frontend/src/components/Page_elements/TablePage_elements.tsx +++ b/frontend/src/components/Page_elements/TablePage_elements.tsx @@ -89,7 +89,15 @@ const TablePage_elements: React.FC = ({ filterItems.map((item) => { if (item.id !== id) return item; if (name === 'selectedField') - return { id, fields: { [name]: value } }; + return { + id, + fields: { + selectedField: value, + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + }, + }; return { id, fields: { ...item.fields, [name]: value } }; }), ); diff --git a/frontend/src/components/Page_elements/configurePage_elementsCols.tsx b/frontend/src/components/Page_elements/configurePage_elementsCols.tsx index ffe6724..4709851 100644 --- a/frontend/src/components/Page_elements/configurePage_elementsCols.tsx +++ b/frontend/src/components/Page_elements/configurePage_elementsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -205,7 +201,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Page_links/configurePage_linksCols.tsx b/frontend/src/components/Page_links/configurePage_linksCols.tsx index 0997487..0c942a2 100644 --- a/frontend/src/components/Page_links/configurePage_linksCols.tsx +++ b/frontend/src/components/Page_links/configurePage_linksCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -69,11 +65,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('tour_pages'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -112,11 +110,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('transitions'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -147,7 +147,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Permissions/TablePermissions.tsx b/frontend/src/components/Permissions/TablePermissions.tsx index 0193067..ff03e41 100644 --- a/frontend/src/components/Permissions/TablePermissions.tsx +++ b/frontend/src/components/Permissions/TablePermissions.tsx @@ -12,7 +12,7 @@ import { deleteItemsByIds, } from '../../stores/permissions/permissionsSlice'; import { loadColumns } from './configurePermissionsCols'; -import type { Permission } from '../../types/entities'; +import type { PermissionEntity } from '../../types/entities'; import type { RootState } from '../../stores/store'; import type { Filter, FilterItem } from '../../types/filters'; @@ -29,7 +29,7 @@ const TablePermissions: React.FC = ({ filters, }) => { return ( - + entityName='permissions' sliceSelector={(state: RootState) => state.permissions} fetchAction={fetch} diff --git a/frontend/src/components/Permissions/configurePermissionsCols.tsx b/frontend/src/components/Permissions/configurePermissionsCols.tsx index 9586b1b..9bdb282 100644 --- a/frontend/src/components/Permissions/configurePermissionsCols.tsx +++ b/frontend/src/components/Permissions/configurePermissionsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -52,7 +46,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx b/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx index add74d9..a0a2733 100644 --- a/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx +++ b/frontend/src/components/Presigned_url_requests/configurePresigned_url_requestsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -53,11 +47,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -72,11 +68,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('users'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -152,7 +150,7 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.expires_at), }, @@ -170,7 +168,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx b/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx index f9cdc33..bf51465 100644 --- a/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx +++ b/frontend/src/components/Project_audio_tracks/configureProject_audio_tracksCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -53,11 +47,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -178,7 +174,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx b/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx index 6769abd..82ba9db 100644 --- a/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx +++ b/frontend/src/components/Project_memberships/configureProject_membershipsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -69,11 +65,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('users'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -113,7 +111,7 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.invited_at), }, @@ -128,13 +126,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.accepted_at), }, { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Projects/configureProjectsCols.tsx b/frontend/src/components/Projects/configureProjectsCols.tsx index 30969a7..fa6999e 100644 --- a/frontend/src/components/Projects/configureProjectsCols.tsx +++ b/frontend/src/components/Projects/configureProjectsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -195,13 +189,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.deleted_at_time), }, { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx b/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx index 480625b..22fc2c9 100644 --- a/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx +++ b/frontend/src/components/Publish_events/configurePublish_eventsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -69,11 +65,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('users'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -111,7 +109,7 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.started_at), }, @@ -126,7 +124,7 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.finished_at), }, @@ -222,7 +220,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx b/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx index e112bf8..2f94a49 100644 --- a/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx +++ b/frontend/src/components/Pwa_caches/configurePwa_cachesCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -116,7 +112,7 @@ export const loadColumns = async ( editable: hasUpdatePermission, - type: 'dateTime', + type: 'dateTime' as const, valueGetter: (_value, row) => new Date(row.generated_at), }, @@ -136,7 +132,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Roles/configureRolesCols.tsx b/frontend/src/components/Roles/configureRolesCols.tsx index 03e5175..fb1fd9a 100644 --- a/frontend/src/components/Roles/configureRolesCols.tsx +++ b/frontend/src/components/Roles/configureRolesCols.tsx @@ -1,19 +1,12 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import dataFormatter from '../../helpers/dataFormatter'; import DataGridMultiSelect from '../DataGridMultiSelect'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +14,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +23,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -61,7 +57,7 @@ export const loadColumns = async ( editable: false, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, valueFormatter: ({ value }) => dataFormatter.permissionsManyListFormatter(value).join(', '), renderEditCell: (params) => ( @@ -71,7 +67,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx index eac4769..a4ec6e4 100644 --- a/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx +++ b/frontend/src/components/SmartWidget/components/BarChart/ChartJSBarChart.tsx @@ -13,6 +13,7 @@ import { Tooltip, } from 'chart.js'; import chroma from 'chroma-js'; +import { logger } from '../../../../lib/logger'; ChartJS.register( CategoryScale, @@ -24,7 +25,7 @@ ChartJS.register( ); export const ChartJSBarChart = ({ widget }) => { - console.log(widget); + logger.debug('ChartJSBarChart widget:', widget); const options = () => { return { responsive: true, diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index 737310f..3e16f4e 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -18,6 +18,7 @@ import SectionTitleLineWithButton from './SectionTitleLineWithButton'; import { getPageTitle } from '../config'; import { hasPermission } from '../helpers/userPermissions'; import { useAppSelector } from '../stores/hooks'; +import { logger } from '../lib/logger'; type TourPage = { id: string; @@ -215,7 +216,10 @@ const TourFlowManager = () => { error?.message || 'Failed to load pages and transitions.', ); - console.error('Failed to load merged pages/transitions list:', error); + logger.error( + 'Failed to load merged pages/transitions list:', + error instanceof Error ? error : { error }, + ); } finally { setIsLoading(false); } @@ -438,7 +442,10 @@ const TourFlowManager = () => { 'Failed to create page.'; setErrorMessage(message); setNewPageSlugError(message); - console.error('Failed to create page:', error); + logger.error( + 'Failed to create page:', + error instanceof Error ? error : { error }, + ); } finally { setIsCreatingPage(false); } @@ -475,7 +482,10 @@ const TourFlowManager = () => { error?.message || 'Failed to create transition.', ); - console.error('Failed to create transition:', error); + logger.error( + 'Failed to create transition:', + error instanceof Error ? error : { error }, + ); } finally { setIsCreatingTransition(false); } @@ -505,7 +515,10 @@ const TourFlowManager = () => { error?.message || 'Failed to delete item.', ); - console.error('Failed to delete item:', error); + logger.error( + 'Failed to delete item:', + error instanceof Error ? error : { error }, + ); } finally { setDeletingId(''); } diff --git a/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx b/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx index f94f4f1..c5d9af9 100644 --- a/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx +++ b/frontend/src/components/Tour_pages/configureTour_pagesCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -197,7 +193,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/Transitions/configureTransitionsCols.tsx b/frontend/src/components/Transitions/configureTransitionsCols.tsx index 7bb70b7..06405df 100644 --- a/frontend/src/components/Transitions/configureTransitionsCols.tsx +++ b/frontend/src/components/Transitions/configureTransitionsCols.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; -import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import DataGridMultiSelect from '../DataGridMultiSelect'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +12,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +21,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -50,11 +44,13 @@ export const loadColumns = async ( editable: hasUpdatePermission, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, getOptionValue: (value: any) => value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('projects'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -159,7 +155,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx index a517a4c..85f29e8 100644 --- a/frontend/src/components/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar.tsx @@ -1,16 +1,12 @@ -/* eslint-disable @next/next/no-img-element */ -// Why disabled: -// avatars.dicebear.com provides svg avatars -// next/image needs dangerouslyAllowSVG option for that - import React, { ReactNode } from 'react'; +import Image from 'next/image'; import BaseIcon from './BaseIcon'; import { mdiAccountCircleOutline } from '@mdi/js'; type Props = { username: string; avatar?: string | null; - image?: object | null; + image?: Array<{ publicUrl?: string }> | null; api?: string; className?: string; children?: ReactNode; @@ -19,15 +15,14 @@ type Props = { export default function UserAvatar({ username, image, - avatar, className = '', children, }: Props) { - const avatarImage = image && image[0] ? `${image[0].publicUrl}` : '#'; + const avatarImage = image && image[0] ? `${image[0].publicUrl}` : ''; return (
- {avatarImage === '#' ? ( + {!avatarImage ? ( ) : ( - {username} +
+ {username} +
)} {children}
diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx index e64f876..5d334e0 100644 --- a/frontend/src/components/Users/configureUsersCols.tsx +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -1,19 +1,13 @@ import React from 'react'; -import BaseIcon from '../BaseIcon'; -import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import axios from 'axios'; -import { - GridActionsCellItem, - GridRowParams, - -} from '@mui/x-data-grid'; +import { GridColDef, GridRowParams } from '@mui/x-data-grid'; import ImageField from '../ImageField'; -import { saveFile } from '../../helpers/fileSaver'; import dataFormatter from '../../helpers/dataFormatter'; import DataGridMultiSelect from '../DataGridMultiSelect'; import ListActionsPopover from '../ListActionsPopover'; import { hasPermission } from '../../helpers/userPermissions'; +import { logger } from '../../lib/logger'; type Params = (id: string) => void; @@ -21,8 +15,8 @@ export const loadColumns = async ( onDelete: Params, entityName: string, - user, -) => { + user: unknown, +): Promise => { async function callOptionsApi(entityName: string) { if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; @@ -30,7 +24,10 @@ export const loadColumns = async ( const data = await axios(`/${entityName}/autocomplete?limit=100`); return data.data; } catch (error) { - console.log(error); + logger.error( + 'Failed to fetch options', + error instanceof Error ? error : { error }, + ); return []; } } @@ -111,7 +108,7 @@ export const loadColumns = async ( editable: false, sortable: false, - renderCell: (value) => ( + renderCell: (params) => ( value?.id, getOptionLabel: (value: any) => value?.label, valueOptions: await callOptionsApi('roles'), - valueGetter: (value) => value?.id ?? value, + valueGetter: (value: { id?: string } | string | null) => + (typeof value === 'object' && value !== null ? value?.id : value) ?? + value, }, { @@ -150,7 +149,7 @@ export const loadColumns = async ( editable: false, sortable: false, - type: 'singleSelect', + type: 'singleSelect' as const, valueFormatter: ({ value }) => dataFormatter.permissionsManyListFormatter(value).join(', '), renderEditCell: (params) => ( @@ -160,7 +159,7 @@ export const loadColumns = async ( { field: 'actions', - type: 'actions', + type: 'actions' as const, minWidth: 30, headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell', diff --git a/frontend/src/config/offline.config.ts b/frontend/src/config/offline.config.ts new file mode 100644 index 0000000..698bcb6 --- /dev/null +++ b/frontend/src/config/offline.config.ts @@ -0,0 +1,52 @@ +/** + * Offline Configuration + * + * Centralized configuration for offline mode, IndexedDB, and service worker settings. + */ + +export const OFFLINE_CONFIG = { + // IndexedDB + dbName: 'TourBuilderOffline', + dbVersion: 1, + + // Cache names (for Cache API) + cacheNames: { + static: 'tour-builder-static-v1', + dynamic: 'tour-builder-dynamic-v1', + assets: 'tour-builder-assets-v1', + }, + + // Events (EventEmitter event names) + events: { + preloadStart: 'asset-preload-start', + preloadProgress: 'asset-preload-progress', + preloadComplete: 'asset-preload-complete', + preloadError: 'asset-preload-error', + projectDownloadProgress: 'project-download-progress', + projectDownloadComplete: 'project-download-complete', + queueUpdate: 'queue-update', + }, + + // Service worker settings + serviceWorker: { + scope: '/', + updateInterval: 60 * 60 * 1000, // 1 hour + }, + + // Storage settings + storage: { + // Maximum size for Cache API storage (files smaller than this go to Cache API) + cacheApiMaxSize: 5 * 1024 * 1024, // 5MB + // Files larger than this go to IndexedDB + indexedDbMinSize: 5 * 1024 * 1024, // 5MB + }, + + // Retry settings + retry: { + maxRetries: 3, + backoffMs: 1000, + maxBackoffMs: 30000, + }, +} as const; + +export type OfflineConfig = typeof OFFLINE_CONFIG; diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts new file mode 100644 index 0000000..f9340f2 --- /dev/null +++ b/frontend/src/config/preload.config.ts @@ -0,0 +1,60 @@ +/** + * Preload Configuration + * + * Centralized configuration for asset preloading, priority weights, and queue settings. + */ + +export const PRELOAD_CONFIG = { + // Queue settings + maxConcurrentDownloads: 3, + maxRetries: 3, + retryDelayMs: 1000, + + // Size thresholds + largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB + videoChunkSize: 5 * 1024 * 1024, // 5MB chunks + initialVideoBufferSeconds: 5, + + // Priority weights (higher = load first) + priority: { + currentPage: 1000, + neighborBase: 500, + assetType: { + image: 100, + transition: 80, + audio: 50, + video: 30, + } as Record, + variant: { + thumbnail: 50, + preview: 40, + webp: 35, + mp4_low: 20, + mp4_high: 10, + original: 5, + } as Record, + linkCountMultiplier: 10, + maxLinkBonus: 50, + }, + + // Storage + storage: { + warningPercent: 80, + criticalPercent: 95, + minFreeBuffer: 50 * 1024 * 1024, // 50MB + }, + + // Auto-cleanup timeouts (from hoboken pattern) + autoRemove: { + completedMs: 3000, + errorMs: 10000, + }, + + // Neighbor graph traversal + neighborGraph: { + maxDepth: 2, // How far to look ahead + constructorMaxDepth: 1, // Reduced depth for constructor preview + }, +} as const; + +export type PreloadConfig = typeof PRELOAD_CONFIG; diff --git a/frontend/src/context/DownloadContext.tsx b/frontend/src/context/DownloadContext.tsx new file mode 100644 index 0000000..03ca487 --- /dev/null +++ b/frontend/src/context/DownloadContext.tsx @@ -0,0 +1,255 @@ +/** + * DownloadContext + * + * React context for managing download state and progress across the application. + * Integrates with DownloadEventBus for real-time progress updates. + */ + +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { downloadEventBus } from '../lib/offline/DownloadEventBus'; +import { OFFLINE_CONFIG } from '../config/offline.config'; +import { PRELOAD_CONFIG } from '../config/preload.config'; +import type { + PreloadJob, + PreloadJobStatus, + PreloadProgressEvent, + PreloadCompleteEvent, + PreloadErrorEvent, + PreloadStartEvent, +} from '../types/offline'; + +// Context state +interface DownloadState { + jobs: PreloadJob[]; + activeCount: number; + completedCount: number; + errorCount: number; + totalProgress: number; + isDownloading: boolean; +} + +// Context value with actions +interface DownloadContextValue extends DownloadState { + clearJob: (id: string) => void; + clearAllCompleted: () => void; + clearAllErrors: () => void; + clearAll: () => void; +} + +const DownloadContext = createContext(null); + +// Helper to update a job in the array +const updateJobInArray = ( + jobs: PreloadJob[], + jobId: string, + updates: Partial, +): PreloadJob[] => { + return jobs.map((job) => (job.id === jobId ? { ...job, ...updates } : job)); +}; + +// Helper to calculate overall progress +const calculateTotalProgress = (jobs: PreloadJob[]): number => { + if (jobs.length === 0) return 0; + + const activeJobs = jobs.filter( + (j) => j.status !== 'completed' && j.status !== 'error', + ); + if (activeJobs.length === 0) return 100; + + const totalBytes = activeJobs.reduce((sum, j) => sum + j.totalBytes, 0); + const loadedBytes = activeJobs.reduce((sum, j) => sum + j.bytesLoaded, 0); + + if (totalBytes === 0) { + // Fallback to count-based progress + const completedCount = jobs.filter((j) => j.status === 'completed').length; + return Math.round((completedCount / jobs.length) * 100); + } + + return Math.round((loadedBytes / totalBytes) * 100); +}; + +interface DownloadProviderProps { + children: ReactNode; +} + +export function DownloadProvider({ children }: DownloadProviderProps) { + const [jobs, setJobs] = useState([]); + + // Handle preload start + useEffect(() => { + const handleStart = (data: PreloadStartEvent) => { + const newJob: PreloadJob = { + id: data.jobId, + assetId: data.assetId, + url: data.url, + filename: data.url.split('/').pop() || 'unknown', + progress: 0, + status: 'downloading', + bytesLoaded: 0, + totalBytes: 0, + addedAt: Date.now(), + startedAt: Date.now(), + }; + + setJobs((prev) => { + // Avoid duplicates + if (prev.some((j) => j.id === data.jobId)) { + return prev; + } + return [...prev, newJob]; + }); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadStart as Parameters< + typeof downloadEventBus.on + >[0], + handleStart as Parameters[1], + ); + }, []); + + // Handle preload progress + useEffect(() => { + const handleProgress = (data: PreloadProgressEvent) => { + setJobs((prev) => + updateJobInArray(prev, data.jobId, { + progress: data.progress, + bytesLoaded: data.bytesLoaded, + totalBytes: data.totalBytes, + status: 'downloading', + }), + ); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadProgress as Parameters< + typeof downloadEventBus.on + >[0], + handleProgress as Parameters[1], + ); + }, []); + + // Handle preload complete + useEffect(() => { + const handleComplete = (data: PreloadCompleteEvent) => { + setJobs((prev) => + updateJobInArray(prev, data.jobId, { + status: 'completed', + progress: 100, + completedAt: Date.now(), + }), + ); + + // Auto-remove completed jobs after delay + setTimeout(() => { + setJobs((prev) => prev.filter((j) => j.id !== data.jobId)); + }, PRELOAD_CONFIG.autoRemove.completedMs); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadComplete as Parameters< + typeof downloadEventBus.on + >[0], + handleComplete as Parameters[1], + ); + }, []); + + // Handle preload error + useEffect(() => { + const handleError = (data: PreloadErrorEvent) => { + setJobs((prev) => + updateJobInArray(prev, data.jobId, { + status: 'error', + error: data.error, + }), + ); + + // Auto-remove error jobs after delay + setTimeout(() => { + setJobs((prev) => prev.filter((j) => j.id !== data.jobId)); + }, PRELOAD_CONFIG.autoRemove.errorMs); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadError as Parameters< + typeof downloadEventBus.on + >[0], + handleError as Parameters[1], + ); + }, []); + + // Actions + const clearJob = useCallback((id: string) => { + setJobs((prev) => prev.filter((j) => j.id !== id)); + }, []); + + const clearAllCompleted = useCallback(() => { + setJobs((prev) => prev.filter((j) => j.status !== 'completed')); + }, []); + + const clearAllErrors = useCallback(() => { + setJobs((prev) => prev.filter((j) => j.status !== 'error')); + }, []); + + const clearAll = useCallback(() => { + setJobs([]); + }, []); + + // Computed values + const value = useMemo(() => { + const activeCount = jobs.filter( + (j) => j.status === 'downloading' || j.status === 'queued', + ).length; + const completedCount = jobs.filter((j) => j.status === 'completed').length; + const errorCount = jobs.filter((j) => j.status === 'error').length; + const totalProgress = calculateTotalProgress(jobs); + const isDownloading = activeCount > 0; + + return { + jobs, + activeCount, + completedCount, + errorCount, + totalProgress, + isDownloading, + clearJob, + clearAllCompleted, + clearAllErrors, + clearAll, + }; + }, [jobs, clearJob, clearAllCompleted, clearAllErrors, clearAll]); + + return ( + + {children} + + ); +} + +/** + * Hook to access download context + */ +export function useDownloadContext(): DownloadContextValue { + const context = useContext(DownloadContext); + if (!context) { + throw new Error( + 'useDownloadContext must be used within a DownloadProvider', + ); + } + return context; +} + +/** + * Optional hook that returns null if not within provider + */ +export function useDownloadContextOptional(): DownloadContextValue | null { + return useContext(DownloadContext); +} diff --git a/frontend/src/factories/createFormPage.tsx b/frontend/src/factories/createFormPage.tsx index bc1c34b..36cbc31 100644 --- a/frontend/src/factories/createFormPage.tsx +++ b/frontend/src/factories/createFormPage.tsx @@ -17,6 +17,10 @@ import BaseButtons from '../components/BaseButtons'; import BaseButton from '../components/BaseButton'; import { getPageTitle } from '../config'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { SelectField } from '../components/SelectField'; +import { SelectFieldMany } from '../components/SelectFieldMany'; +import { SwitchField } from '../components/SwitchField'; +import FormImagePicker from '../components/FormImagePicker'; import type { RootState } from '../stores/store'; import type { AsyncThunk } from '@reduxjs/toolkit'; @@ -241,9 +245,6 @@ function renderField( ); case 'select': - // Dynamic import to avoid circular dependencies - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { SelectField } = require('../components/SelectField'); return ( ( ); case 'selectMany': - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { SelectFieldMany } = require('../components/SelectFieldMany'); return ( ( ); case 'switch': - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { SwitchField } = require('../components/SwitchField'); return ; case 'image': - // eslint-disable-next-line @typescript-eslint/no-var-requires - const FormImagePicker = require('../components/FormImagePicker').default; return ( - query.trim().toLowerCase().replace(/\s+/g, ''); - - while (localStorageLock) { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - localStorageLock = true; - - const cachedImages = - JSON.parse(localStorage.getItem('pexelsImagesCache')) || {}; - - const isImageCached = (query) => { - const normalizedQuery = normalizeQuery(query); - const cached = cachedImages[normalizedQuery]; - const isCached = - cached && cached.src && cached.photographer && cached.photographer_url; - return isCached; - }; - - const missingQueries = queries.filter((query) => !isImageCached(query)); - - if (missingQueries.length > 0) { - const queryString = missingQueries.join(','); - - try { - const response = await axios.get(`/pexels/multiple-images`, { - params: { queries: queryString }, - }); - - missingQueries.forEach((query, index) => { - const normalizedQuery = normalizeQuery(query); - if (!cachedImages[normalizedQuery]) { - cachedImages[normalizedQuery] = response.data[index]; - } - }); - - localStorage.setItem('pexelsImagesCache', JSON.stringify(cachedImages)); - } catch (error) { - console.error(error); - } - } - - const result = queries.map((query) => cachedImages[normalizeQuery(query)]); - - localStorageLock = false; - - return result; -} diff --git a/frontend/src/hooks/useCSVHandling.ts b/frontend/src/hooks/useCSVHandling.ts index 7fabc68..75756f4 100644 --- a/frontend/src/hooks/useCSVHandling.ts +++ b/frontend/src/hooks/useCSVHandling.ts @@ -6,6 +6,7 @@ import { useState, useCallback } from 'react'; import axios from 'axios'; import { useAppDispatch } from '../stores/hooks'; import type { AsyncThunk } from '@reduxjs/toolkit'; +import { logger } from '../lib/logger'; interface UseCSVHandlingReturn { csvFile: File | null; @@ -88,7 +89,10 @@ export function useCSVHandling( const message = err instanceof Error ? err.message : 'Failed to download CSV'; setError(message); - console.error('CSV download error:', err); + logger.error( + 'CSV download error:', + err instanceof Error ? err : { error: err }, + ); } finally { setIsDownloading(false); } @@ -112,7 +116,10 @@ export function useCSVHandling( const message = err instanceof Error ? err.message : 'Failed to upload CSV'; setError(message); - console.error('CSV upload error:', err); + logger.error( + 'CSV upload error:', + err instanceof Error ? err : { error: err }, + ); } finally { setIsUploading(false); } diff --git a/frontend/src/hooks/useNeighborGraph.ts b/frontend/src/hooks/useNeighborGraph.ts new file mode 100644 index 0000000..e7d6f2f --- /dev/null +++ b/frontend/src/hooks/useNeighborGraph.ts @@ -0,0 +1,315 @@ +/** + * useNeighborGraph Hook + * + * Builds a navigation graph from page_links to determine which pages + * are neighbors and should have their assets preloaded. + */ + +import { useMemo } from 'react'; +import { PRELOAD_CONFIG } from '../config/preload.config'; + +interface PageLink { + id: string; + from_pageId?: string; + to_pageId?: string; + is_active?: boolean; + transition?: { + id: string; + video_url?: string; + }; +} + +interface Page { + id: string; +} + +interface Element { + id: string; + pageId?: string; + element_type?: string; + content_json?: string; +} + + +interface UseNeighborGraphOptions { + pages: Page[]; + pageLinks: PageLink[]; + elements: Element[]; + maxDepth?: number; +} + +interface NeighborInfo { + pageId: string; + distance: number; +} + +interface AssetInfo { + url: string; + pageId: string; + assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; + priority: number; +} + +interface NeighborGraphResult { + /** + * Get neighboring page IDs within maxDepth hops + */ + getNeighbors: (currentPageId: string, maxDepth?: number) => NeighborInfo[]; + + /** + * Get all assets that should be preloaded for given pages + */ + getAssetsForPages: (pageIds: string[]) => AssetInfo[]; + + /** + * Get prioritized assets for preloading based on current page + */ + getPrioritizedAssets: ( + currentPageId: string, + maxDepth?: number, + ) => AssetInfo[]; + + /** + * Raw adjacency list for debugging + */ + adjacencyList: Map; +} + +/** + * Parse content_json to extract asset URLs + */ +function extractAssetsFromContent( + contentJson: string | undefined, + pageId: string, +): AssetInfo[] { + if (!contentJson) return []; + + try { + const content = + typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + const assets: AssetInfo[] = []; + + // Check for common asset URL fields (both snake_case and camelCase) + const urlFields = [ + 'image_url', + 'video_url', + 'audio_url', + 'background_url', + 'src', + 'url', + 'poster', + 'thumbnail', + 'transitionVideoUrl', // For transition videos in constructor + 'videoUrl', // camelCase variant + 'audioUrl', // camelCase variant + 'iconUrl', // icon images + 'backgroundImageUrl', // background images + ]; + + const checkObject = (obj: Record, depth = 0) => { + if (depth > 5 || !obj || typeof obj !== 'object') return; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string' && value && urlFields.includes(key)) { + const assetType = key.toLowerCase().includes('video') + ? 'video' + : key.toLowerCase().includes('audio') + ? 'audio' + : 'image'; + + assets.push({ + url: value, + pageId, + assetType, + priority: 0, // Will be calculated later + }); + } else if (typeof value === 'object' && value !== null) { + checkObject(value as Record, depth + 1); + } + } + }; + + checkObject(content); + return assets; + } catch { + return []; + } +} + +export function useNeighborGraph( + options: UseNeighborGraphOptions, +): NeighborGraphResult { + const { + pages, + pageLinks, + elements, + maxDepth = PRELOAD_CONFIG.neighborGraph.maxDepth, + } = options; + + // Build adjacency list from page links + const adjacencyList = useMemo(() => { + const adj = new Map(); + + // Initialize all pages + pages.forEach((page) => { + adj.set(page.id, []); + }); + + // Add edges from active page links + const activeLinks = pageLinks.filter((link) => link.is_active !== false); + + activeLinks.forEach((link) => { + if (link.from_pageId && link.to_pageId) { + const neighbors = adj.get(link.from_pageId) || []; + if (!neighbors.includes(link.to_pageId)) { + neighbors.push(link.to_pageId); + adj.set(link.from_pageId, neighbors); + } + } + }); + + return adj; + }, [pages, pageLinks]); + + // BFS to find neighbors within depth + const getNeighbors = useMemo(() => { + return (currentPageId: string, depth = maxDepth): NeighborInfo[] => { + const visited = new Set(); + const result: NeighborInfo[] = []; + const queue: { pageId: string; distance: number }[] = [ + { pageId: currentPageId, distance: 0 }, + ]; + + visited.add(currentPageId); + + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + const { pageId, distance } = item; + + if (distance > 0) { + result.push({ pageId, distance }); + } + + if (distance < depth) { + const neighbors = adjacencyList.get(pageId) || []; + for (const neighborId of neighbors) { + if (!visited.has(neighborId)) { + visited.add(neighborId); + queue.push({ pageId: neighborId, distance: distance + 1 }); + } + } + } + } + + // Sort by distance (closest first) + return result.sort((a, b) => a.distance - b.distance); + }; + }, [adjacencyList, maxDepth]); + + // Get assets for a set of pages + const getAssetsForPages = useMemo(() => { + return (pageIds: string[]): AssetInfo[] => { + const assets: AssetInfo[] = []; + const seenUrls = new Set(); + + pageIds.forEach((pageId) => { + // Get elements for this page + const pageElements = elements.filter((el) => el.pageId === pageId); + + // Extract assets from element content + pageElements.forEach((element) => { + const elementAssets = extractAssetsFromContent( + element.content_json, + pageId, + ); + elementAssets.forEach((asset) => { + if (!seenUrls.has(asset.url)) { + seenUrls.add(asset.url); + assets.push(asset); + } + }); + }); + }); + + // Add transition videos (transition is eagerly loaded in page_links) + const matchingLinks = pageLinks.filter( + (link) => + link.is_active !== false && + pageIds.includes(link.from_pageId || ''), + ); + + matchingLinks.forEach((link) => { + const videoUrl = link.transition?.video_url; + if (videoUrl && !seenUrls.has(videoUrl)) { + seenUrls.add(videoUrl); + assets.push({ + url: videoUrl, + pageId: link.from_pageId || '', + assetType: 'transition', + priority: 0, + }); + } + }); + + return assets; + }; + }, [elements, pageLinks]); + + // Get prioritized assets for preloading + const getPrioritizedAssets = useMemo(() => { + return (currentPageId: string, depth = maxDepth): AssetInfo[] => { + // Get current page assets (highest priority) + const currentPageAssets = getAssetsForPages([currentPageId]).map( + (asset) => ({ + ...asset, + priority: + PRELOAD_CONFIG.priority.currentPage + + (PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0), + }), + ); + + // Get neighbor page assets + const neighbors = getNeighbors(currentPageId, depth); + const neighborAssets: AssetInfo[] = []; + + neighbors.forEach(({ pageId, distance }) => { + const assets = getAssetsForPages([pageId]); + assets.forEach((asset) => { + const basePriority = PRELOAD_CONFIG.priority.neighborBase / distance; + const typePriority = + PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0; + + neighborAssets.push({ + ...asset, + priority: basePriority + typePriority, + }); + }); + }); + + // Combine and sort by priority (highest first) + const allAssets = [...currentPageAssets, ...neighborAssets]; + + // Deduplicate by URL, keeping highest priority + const urlToPriority = new Map(); + allAssets.forEach((asset) => { + const existing = urlToPriority.get(asset.url); + if (!existing || asset.priority > existing.priority) { + urlToPriority.set(asset.url, asset); + } + }); + + return Array.from(urlToPriority.values()).sort( + (a, b) => b.priority - a.priority, + ); + }; + }, [getAssetsForPages, getNeighbors, maxDepth]); + + return { + getNeighbors, + getAssetsForPages, + getPrioritizedAssets, + adjacencyList, + }; +} diff --git a/frontend/src/hooks/useNetworkAware.ts b/frontend/src/hooks/useNetworkAware.ts new file mode 100644 index 0000000..9860e37 --- /dev/null +++ b/frontend/src/hooks/useNetworkAware.ts @@ -0,0 +1,163 @@ +/** + * useNetworkAware Hook + * + * Monitors network conditions and adapts preloading strategy accordingly. + * Uses the Network Information API where available. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { NetworkInfo } from '../types/offline'; + +// Extend Navigator interface for Network Information API +interface NetworkInformation extends EventTarget { + readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; + readonly downlink?: number; + readonly rtt?: number; + readonly saveData?: boolean; + onchange?: () => void; +} + +interface NavigatorWithConnection extends Navigator { + connection?: NetworkInformation; + mozConnection?: NetworkInformation; + webkitConnection?: NetworkInformation; +} + +interface UseNetworkAwareResult { + networkInfo: NetworkInfo; + /** + * Whether preloading should be aggressive (good connection) + */ + shouldPreloadAggressively: boolean; + /** + * Whether to prefer lower quality variants + */ + preferLowQuality: boolean; + /** + * Recommended concurrent download count based on network + */ + recommendedConcurrency: number; + /** + * Whether offline mode should be suggested to user + */ + suggestOfflineMode: boolean; +} + +const getConnection = (): NetworkInformation | null => { + if (typeof navigator === 'undefined') return null; + const nav = navigator as NavigatorWithConnection; + return nav.connection || nav.mozConnection || nav.webkitConnection || null; +}; + +const getNetworkInfo = (): NetworkInfo => { + if (typeof navigator === 'undefined') { + return { isOnline: true }; + } + + const connection = getConnection(); + + return { + isOnline: navigator.onLine, + effectiveType: connection?.effectiveType, + downlink: connection?.downlink, + rtt: connection?.rtt, + saveData: connection?.saveData, + }; +}; + +export function useNetworkAware(): UseNetworkAwareResult { + const [networkInfo, setNetworkInfo] = useState(getNetworkInfo); + + // Update network info on changes + useEffect(() => { + if (typeof window === 'undefined') return; + + const updateNetworkInfo = () => { + setNetworkInfo(getNetworkInfo()); + }; + + // Listen for online/offline events + window.addEventListener('online', updateNetworkInfo); + window.addEventListener('offline', updateNetworkInfo); + + // Listen for connection changes if available + const connection = getConnection(); + if (connection) { + connection.addEventListener('change', updateNetworkInfo); + } + + return () => { + window.removeEventListener('online', updateNetworkInfo); + window.removeEventListener('offline', updateNetworkInfo); + if (connection) { + connection.removeEventListener('change', updateNetworkInfo); + } + }; + }, []); + + // Determine if preloading should be aggressive + const shouldPreloadAggressively = useCallback((): boolean => { + if (!networkInfo.isOnline) return false; + if (networkInfo.saveData) return false; + + // Good connection: 4g or high downlink + if (networkInfo.effectiveType === '4g') return true; + if (networkInfo.downlink && networkInfo.downlink >= 5) return true; + + return false; + }, [networkInfo]); + + // Determine if low quality variants should be preferred + const preferLowQuality = useCallback((): boolean => { + if (networkInfo.saveData) return true; + if (networkInfo.effectiveType === 'slow-2g') return true; + if (networkInfo.effectiveType === '2g') return true; + if (networkInfo.downlink && networkInfo.downlink < 1) return true; + + return false; + }, [networkInfo]); + + // Calculate recommended concurrency + const getRecommendedConcurrency = useCallback((): number => { + if (!networkInfo.isOnline) return 0; + if (networkInfo.saveData) return 1; + + switch (networkInfo.effectiveType) { + case 'slow-2g': + return 1; + case '2g': + return 1; + case '3g': + return 2; + case '4g': + return 3; + default: + // Fall back to downlink-based calculation + if (networkInfo.downlink) { + if (networkInfo.downlink < 1) return 1; + if (networkInfo.downlink < 5) return 2; + return 3; + } + return 2; // Default + } + }, [networkInfo]); + + // Determine if offline mode should be suggested + const suggestOfflineMode = useCallback((): boolean => { + // Suggest offline if on poor connection + if (networkInfo.effectiveType === 'slow-2g') return true; + if (networkInfo.effectiveType === '2g') return true; + if (networkInfo.rtt && networkInfo.rtt > 500) return true; + if (networkInfo.downlink && networkInfo.downlink < 0.5) return true; + + return false; + }, [networkInfo]); + + return { + networkInfo, + shouldPreloadAggressively: shouldPreloadAggressively(), + preferLowQuality: preferLowQuality(), + recommendedConcurrency: getRecommendedConcurrency(), + suggestOfflineMode: suggestOfflineMode(), + }; +} diff --git a/frontend/src/hooks/useOfflineMode.ts b/frontend/src/hooks/useOfflineMode.ts new file mode 100644 index 0000000..d49904f --- /dev/null +++ b/frontend/src/hooks/useOfflineMode.ts @@ -0,0 +1,387 @@ +/** + * useOfflineMode Hook + * + * Manages offline mode state and project download functionality. + */ + +import { useState, useEffect, useCallback, useMemo } 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 { logger } from '../lib/logger'; +import type { + OfflineProject, + OfflineManifest, + ProjectOfflineStatus, + ProjectDownloadProgressEvent, +} from '../types/offline'; + +interface UseOfflineModeOptions { + projectId: string | null; + projectSlug?: string; + projectName?: string; + enabled?: boolean; +} + +interface UseOfflineModeResult { + // Status + isOfflineCapable: boolean; + isDownloaded: boolean; + isDownloading: boolean; + status: ProjectOfflineStatus; + progress: number; + downloadedAssets: number; + totalAssets: number; + downloadedBytes: number; + totalBytes: number; + error: string | null; + + // Actions + startDownload: () => Promise; + pauseDownload: () => void; + resumeDownload: () => void; + cancelDownload: () => void; + deleteOfflineData: () => Promise; + checkForUpdates: () => Promise; + + // Info + projectInfo: OfflineProject | null; + estimatedSize: number; + formatSize: (bytes: number) => string; +} + +/** + * Format bytes to human-readable string + */ +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +export function useOfflineMode( + options: UseOfflineModeOptions, +): UseOfflineModeResult { + const { projectId, projectSlug, projectName, enabled = true } = options; + + const [projectInfo, setProjectInfo] = useState(null); + const [manifest, setManifest] = useState(null); + const [status, setStatus] = useState('not_downloaded'); + const [progress, setProgress] = useState(0); + const [downloadedAssets, setDownloadedAssets] = useState(0); + const [totalAssets, setTotalAssets] = useState(0); + const [downloadedBytes, setDownloadedBytes] = useState(0); + const [totalBytes, setTotalBytes] = useState(0); + const [error, setError] = useState(null); + const [isPaused, setIsPaused] = useState(false); + + // Check if offline mode is supported + const isOfflineCapable = useMemo(() => { + if (typeof window === 'undefined') return false; + return 'serviceWorker' in navigator && 'caches' in window; + }, []); + + // Load project offline status from IndexedDB + useEffect(() => { + if (!projectId || !enabled) return; + + const loadProjectInfo = async () => { + const info = await OfflineDbManager.getProject(projectId); + if (info) { + setProjectInfo(info); + setStatus(info.status); + setDownloadedAssets(info.downloadedAssets); + setTotalAssets(info.totalAssets); + setDownloadedBytes(info.downloadedSizeBytes); + setTotalBytes(info.totalSizeBytes); + + if (info.totalAssets > 0) { + setProgress( + Math.round((info.downloadedAssets / info.totalAssets) * 100), + ); + } + } + }; + + loadProjectInfo(); + }, [projectId, enabled]); + + // Listen for progress events + useEffect(() => { + if (!projectId) return; + + const handleProgress = (data: ProjectDownloadProgressEvent) => { + if (data.projectId !== projectId) return; + + setProgress(data.progress); + setDownloadedAssets(data.downloadedAssets); + setTotalAssets(data.totalAssets); + setDownloadedBytes(data.downloadedBytes); + setTotalBytes(data.totalBytes); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.projectDownloadProgress as Parameters< + typeof downloadEventBus.on + >[0], + handleProgress as Parameters[1], + ); + }, [projectId]); + + // Fetch manifest from backend + const fetchManifest = + useCallback(async (): Promise => { + if (!projectId) return null; + + try { + const response = await axios.get( + `/api/projects/${projectId}/offline-manifest`, + ); + return response.data; + } catch (err) { + logger.error( + '[useOfflineMode] Failed to fetch manifest:', + err instanceof Error ? err : { error: err }, + ); + return null; + } + }, [projectId]); + + // Start download + const startDownload = useCallback(async (): Promise => { + if (!projectId || !enabled) return; + + setError(null); + setStatus('downloading'); + setIsPaused(false); + + try { + // Fetch manifest + const manifestData = await fetchManifest(); + if (!manifestData) { + throw new Error('Failed to fetch offline manifest'); + } + + setManifest(manifestData); + setTotalAssets(manifestData.assets.length); + setTotalBytes(manifestData.totalSizeBytes); + + // Create or update project record + const projectRecord: OfflineProject = { + id: projectId, + slug: projectSlug || '', + name: projectName || '', + status: 'downloading', + totalAssets: manifestData.assets.length, + downloadedAssets: 0, + totalSizeBytes: manifestData.totalSizeBytes, + downloadedSizeBytes: 0, + version: manifestData.version, + }; + await OfflineDbManager.upsertProject(projectRecord); + setProjectInfo(projectRecord); + + // Check storage quota + const quota = await StorageManager.getStorageQuota(); + if (!quota.canStore(manifestData.totalSizeBytes)) { + throw new Error('Insufficient storage space'); + } + + // Add all assets to download queue + let downloadedCount = 0; + let downloadedSize = 0; + + for (const asset of manifestData.assets) { + // Check if already downloaded + const hasAsset = await StorageManager.hasAsset(asset.url); + if (hasAsset) { + downloadedCount++; + downloadedSize += asset.sizeBytes; + continue; + } + + 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, + }); + } + + // Update initial progress + setDownloadedAssets(downloadedCount); + setDownloadedBytes(downloadedSize); + + if (downloadedCount === manifestData.assets.length) { + // All already downloaded + 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, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Download failed'; + setError(message); + setStatus('error'); + await OfflineDbManager.updateProjectStatus(projectId, 'error'); + } + }, [projectId, projectSlug, projectName, enabled, fetchManifest]); + + // Pause download + const pauseDownload = useCallback(() => { + downloadManager.pauseAll(); + setIsPaused(true); + }, []); + + // Resume download + const resumeDownload = useCallback(() => { + downloadManager.resumeAll(); + setIsPaused(false); + }, []); + + // Cancel download + const cancelDownload = useCallback(() => { + if (!projectId) return; + + downloadManager.cancelProjectDownloads(projectId); + setStatus('not_downloaded'); + setProgress(0); + setDownloadedAssets(0); + setDownloadedBytes(0); + setIsPaused(false); + setError(null); + + OfflineDbManager.deleteProject(projectId); + setProjectInfo(null); + }, [projectId]); + + // Delete offline data + const deleteOfflineData = useCallback(async () => { + if (!projectId) return; + + await StorageManager.deleteProjectAssets(projectId); + await OfflineDbManager.deleteProject(projectId); + + setProjectInfo(null); + setStatus('not_downloaded'); + setProgress(0); + setDownloadedAssets(0); + setTotalAssets(0); + setDownloadedBytes(0); + setTotalBytes(0); + }, [projectId]); + + // Check for updates + const checkForUpdates = useCallback(async (): Promise => { + if (!projectId || !projectInfo) return false; + + try { + const latestManifest = await fetchManifest(); + if (!latestManifest) return false; + + if (latestManifest.version !== projectInfo.version) { + setStatus('outdated'); + await OfflineDbManager.updateProjectStatus(projectId, 'outdated'); + return true; + } + + return false; + } catch { + return false; + } + }, [projectId, projectInfo, fetchManifest]); + + // Computed values + const isDownloaded = status === 'downloaded'; + const isDownloading = status === 'downloading' && !isPaused; + const estimatedSize = manifest?.totalSizeBytes || totalBytes; + + return { + isOfflineCapable, + isDownloaded, + isDownloading, + status, + progress, + downloadedAssets, + totalAssets, + downloadedBytes, + totalBytes, + error, + startDownload, + pauseDownload, + resumeDownload, + cancelDownload, + deleteOfflineData, + checkForUpdates, + projectInfo, + estimatedSize, + formatSize: formatBytes, + }; +} diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts new file mode 100644 index 0000000..976159f --- /dev/null +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -0,0 +1,533 @@ +/** + * usePreloadOrchestrator Hook + * + * Main coordinator for online mode asset preloading. + * Manages the priority queue and orchestrates downloads based on navigation. + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useNeighborGraph } from './useNeighborGraph'; +import { useNetworkAware } from './useNetworkAware'; +import { downloadEventBus } from '../lib/offline/DownloadEventBus'; +import { PRELOAD_CONFIG } from '../config/preload.config'; +import { OFFLINE_CONFIG } from '../config/offline.config'; +import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; +import { logger } from '../lib/logger'; + +interface Page { + id: string; + background_image_url?: string; + background_video_url?: string; +} + +interface Element { + id: string; + pageId?: string; + element_type?: string; + content_json?: string; +} + +interface PageLink { + id: string; + from_pageId?: string; + to_pageId?: string; + transitionId?: string; + is_active?: boolean; +} + +interface UsePreloadOrchestratorOptions { + pages: Page[]; + pageLinks: PageLink[]; + elements: Element[]; + currentPageId: string | null; + pageHistory?: string[]; + enabled?: boolean; + maxNeighborDepth?: number; +} + +interface PreloadQueueItem { + id: string; + url: string; + priority: number; + assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; + pageId: string; +} + +interface UsePreloadOrchestratorResult { + isPreloading: boolean; + preloadedUrls: Set; + queueLength: number; + preloadAsset: (url: string, priority?: number) => void; + clearQueue: () => void; + getCachedBlobUrl: (url: string) => Promise; + isUrlPreloaded: (url: string) => Promise; +} + +/** + * Generate a unique ID for preload jobs + */ +const generateJobId = (): string => { + return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +}; + +/** + * Check if a URL is already cached (simplified check) + */ +const isUrlCached = async (url: string): Promise => { + if (typeof caches === 'undefined') return false; + + try { + const cacheNames = await caches.keys(); + for (const cacheName of cacheNames) { + const cache = await caches.open(cacheName); + const response = await cache.match(url); + if (response) return true; + } + return false; + } 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); + } + + // Decode image so it's ready to paint (eliminates white flash) + if (isImageUrl(url)) { + await decodeImage(url); + } + + 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); + } + + // Decode image so it's ready to paint (eliminates white flash) + if (isImageUrl(url)) { + await decodeImage(url); + } + + downloadEventBus.emitPreloadComplete({ jobId, assetId }); + } catch (error) { + downloadEventBus.emitPreloadError({ + jobId, + assetId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } +}; + +export function usePreloadOrchestrator( + options: UsePreloadOrchestratorOptions, +): UsePreloadOrchestratorResult { + const { + pages, + pageLinks, + elements, + currentPageId, + enabled = true, + maxNeighborDepth = PRELOAD_CONFIG.neighborGraph.maxDepth, + } = options; + + const [isPreloading, setIsPreloading] = useState(false); + const [preloadedUrls] = useState(() => new Set()); + const [queueLength, setQueueLength] = useState(0); + + const queueRef = useRef([]); + const activeDownloadsRef = useRef(0); + const isProcessingRef = useRef(false); + const lastPreloadedPageRef = useRef(null); + const lastPreloadedLinksCountRef = useRef(0); + + // Use neighbor graph for determining what to preload + const neighborGraph = useNeighborGraph({ + pages, + pageLinks, + elements, + maxDepth: maxNeighborDepth, + }); + + // Use network info for adaptive preloading + const { networkInfo, recommendedConcurrency, shouldPreloadAggressively } = + useNetworkAware(); + + // Process the queue + const processQueue = useCallback(async () => { + if (isProcessingRef.current) return; + if (!networkInfo.isOnline) return; + if (queueRef.current.length === 0) { + setIsPreloading(false); + return; + } + + isProcessingRef.current = true; + setIsPreloading(true); + + const maxConcurrent = recommendedConcurrency; + + while ( + queueRef.current.length > 0 && + activeDownloadsRef.current < maxConcurrent + ) { + const item = queueRef.current.shift(); + if (!item) break; + + setQueueLength(queueRef.current.length); + + // Skip if already preloaded + if (preloadedUrls.has(item.url)) { + continue; + } + + // Skip download if already cached, but still decode images + const cached = await isUrlCached(item.url); + if (cached) { + logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) }); + // Decode image even if cached (so it's ready to paint) + if (isImageUrl(item.url)) { + await decodeImage(item.url); + } + preloadedUrls.add(item.url); + continue; + } + + activeDownloadsRef.current++; + + const jobId = generateJobId(); + logger.info('[PRELOAD] Starting download', { url: item.url.slice(-50), assetType: item.assetType }); + + preloadWithProgress(item.url, jobId, item.id) + .then(() => { + logger.info('[PRELOAD] Download complete', { url: item.url.slice(-50) }); + preloadedUrls.add(item.url); + }) + .catch((err) => { + logger.error('[PRELOAD] Download failed', { url: item.url.slice(-50), error: err?.message }); + }) + .finally(() => { + activeDownloadsRef.current--; + // Process more items + if (queueRef.current.length > 0) { + processQueue(); + } else if (activeDownloadsRef.current === 0) { + setIsPreloading(false); + isProcessingRef.current = false; + } + }); + } + + if (activeDownloadsRef.current === 0) { + setIsPreloading(false); + isProcessingRef.current = false; + } + }, [networkInfo.isOnline, preloadedUrls, recommendedConcurrency]); + + // Add item to queue with priority sorting + const addToQueue = useCallback( + (item: PreloadQueueItem) => { + // Skip if already in queue or preloaded + if ( + preloadedUrls.has(item.url) || + queueRef.current.some((q) => q.url === item.url) + ) { + 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), + assetType: item.assetType, + priority: item.priority, + queueLength: queueRef.current.length + 1 + }); + + // Insert in priority order (higher priority first) + const insertIndex = queueRef.current.findIndex( + (q) => q.priority < item.priority, + ); + + if (insertIndex === -1) { + queueRef.current.push(item); + } else { + queueRef.current.splice(insertIndex, 0, item); + } + + setQueueLength(queueRef.current.length); + processQueue(); + }, + [preloadedUrls, processQueue], + ); + + // Manual preload function + const preloadAsset = useCallback( + (url: string, priority = 100) => { + addToQueue({ + id: generateJobId(), + url, + priority, + assetType: 'other', + pageId: currentPageId || '', + }); + }, + [addToQueue, currentPageId], + ); + + // Clear queue + const clearQueue = useCallback(() => { + queueRef.current = []; + setQueueLength(0); + }, []); + + // Get a cached asset as a blob URL (for video playback) + const getCachedBlobUrl = useCallback(async (url: string): Promise => { + if (typeof caches === 'undefined') return null; + + try { + const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); + const response = await cache.match(url); + if (!response) return null; + + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } + }, []); + + // Check if URL is preloaded (in cache) + const isUrlPreloaded = useCallback(async (url: string): Promise => { + // First check in-memory set + if (preloadedUrls.has(url)) return true; + + // Then check Cache API + return isUrlCached(url); + }, [preloadedUrls]); + + // React to page changes - preload neighbors + useEffect(() => { + if (!enabled || !currentPageId || !networkInfo.isOnline) { + return; + } + + // Skip if we already preloaded for this page with the same data + // Re-preload if pageLinks count changed (data just loaded) + const currentLinksCount = pageLinks.length; + const samePageAndData = + lastPreloadedPageRef.current === currentPageId && + lastPreloadedLinksCountRef.current === currentLinksCount; + + if (samePageAndData) { + return; + } + lastPreloadedPageRef.current = currentPageId; + lastPreloadedLinksCountRef.current = currentLinksCount; + + logger.info('[PRELOAD] Starting preload for page', { currentPageId, maxNeighborDepth }); + + // Get prioritized assets based on current page + const assets = neighborGraph.getPrioritizedAssets( + currentPageId, + maxNeighborDepth, + ); + + logger.info('[PRELOAD] Found assets from neighbor graph', { + assetCount: assets.length, + assets: assets.map(a => ({ type: a.assetType, url: a.url.slice(-50) })) + }); + + // Add background assets from pages + const currentPage = pages.find((p) => p.id === currentPageId); + if (currentPage?.background_image_url) { + const resolvedUrl = resolveAssetPlaybackUrl( + currentPage.background_image_url, + ); + if (resolvedUrl) { + addToQueue({ + id: `bg-img-${currentPageId}`, + url: resolvedUrl, + priority: PRELOAD_CONFIG.priority.currentPage + 200, + assetType: 'image', + pageId: currentPageId, + }); + } + } + if (currentPage?.background_video_url) { + const resolvedUrl = resolveAssetPlaybackUrl( + currentPage.background_video_url, + ); + if (resolvedUrl) { + addToQueue({ + id: `bg-vid-${currentPageId}`, + url: resolvedUrl, + priority: PRELOAD_CONFIG.priority.currentPage + 150, + assetType: 'video', + pageId: currentPageId, + }); + } + } + + // Add element assets + assets.forEach((asset) => { + const resolvedUrl = resolveAssetPlaybackUrl(asset.url); + if (resolvedUrl) { + addToQueue({ + id: generateJobId(), + url: resolvedUrl, + priority: asset.priority, + assetType: asset.assetType, + pageId: asset.pageId, + }); + } + }); + + // If aggressive preloading, also preload neighbor backgrounds + if (shouldPreloadAggressively) { + const neighbors = neighborGraph.getNeighbors(currentPageId, 1); + neighbors.forEach(({ pageId }) => { + const page = pages.find((p) => p.id === pageId); + if (page?.background_image_url) { + const resolvedUrl = resolveAssetPlaybackUrl(page.background_image_url); + if (resolvedUrl) { + addToQueue({ + id: `bg-img-${pageId}`, + url: resolvedUrl, + priority: PRELOAD_CONFIG.priority.neighborBase, + assetType: 'image', + pageId, + }); + } + } + }); + } + }, [ + enabled, + currentPageId, + networkInfo.isOnline, + neighborGraph, + pages, + pageLinks, + addToQueue, + shouldPreloadAggressively, + maxNeighborDepth, + ]); + + return { + isPreloading, + preloadedUrls, + queueLength, + preloadAsset, + clearQueue, + getCachedBlobUrl, + isUrlPreloaded, + }; +} diff --git a/frontend/src/hooks/usePreloadProgress.ts b/frontend/src/hooks/usePreloadProgress.ts new file mode 100644 index 0000000..782e1b5 --- /dev/null +++ b/frontend/src/hooks/usePreloadProgress.ts @@ -0,0 +1,220 @@ +/** + * usePreloadProgress Hook + * + * Tracks preload job progress via DownloadEventBus. + * Adapted from hoboken's useVideoOptimizationProgress pattern. + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { downloadEventBus } from '../lib/offline/DownloadEventBus'; +import { OFFLINE_CONFIG } from '../config/offline.config'; +import { PRELOAD_CONFIG } from '../config/preload.config'; +import type { + PreloadJob, + PreloadStartEvent, + PreloadProgressEvent, + PreloadCompleteEvent, + PreloadErrorEvent, +} from '../types/offline'; + +interface UsePreloadProgressResult { + jobs: PreloadJob[]; + activeCount: number; + completedCount: number; + errorCount: number; + totalProgress: number; + isActive: boolean; + clearJob: (id: string) => void; + clearAllCompleted: () => void; + clearAllErrors: () => void; +} + +// Helper to update a job in the array +const updateJob = ( + jobs: PreloadJob[], + jobId: string, + updates: Partial, +): PreloadJob[] => { + return jobs.map((job) => (job.id === jobId ? { ...job, ...updates } : job)); +}; + +// Helper to calculate overall progress +const calculateOverallProgress = (jobs: PreloadJob[]): number => { + const activeJobs = jobs.filter( + (j) => j.status !== 'completed' && j.status !== 'error', + ); + if (activeJobs.length === 0) return jobs.length > 0 ? 100 : 0; + + const totalBytes = activeJobs.reduce( + (sum, j) => sum + (j.totalBytes || 0), + 0, + ); + const loadedBytes = activeJobs.reduce( + (sum, j) => sum + (j.bytesLoaded || 0), + 0, + ); + + if (totalBytes === 0) { + // Fallback to average progress + const avgProgress = + activeJobs.reduce((sum, j) => sum + j.progress, 0) / activeJobs.length; + return Math.round(avgProgress); + } + + return Math.round((loadedBytes / totalBytes) * 100); +}; + +export function usePreloadProgress(): UsePreloadProgressResult { + const [jobs, setJobs] = useState([]); + + // Handle preload start + useEffect(() => { + const handleStart = (data: PreloadStartEvent) => { + setJobs((prev) => { + // Avoid duplicates + if (prev.some((j) => j.id === data.jobId)) { + return prev; + } + + const newJob: PreloadJob = { + id: data.jobId, + assetId: data.assetId, + url: data.url, + filename: data.url.split('/').pop() || 'unknown', + progress: 0, + status: 'downloading', + bytesLoaded: 0, + totalBytes: 0, + addedAt: Date.now(), + startedAt: Date.now(), + }; + + return [...prev, newJob]; + }); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadStart as Parameters< + typeof downloadEventBus.on + >[0], + handleStart as Parameters[1], + ); + }, []); + + // Handle preload progress + useEffect(() => { + const handleProgress = (data: PreloadProgressEvent) => { + setJobs((prev) => + updateJob(prev, data.jobId, { + progress: data.progress, + bytesLoaded: data.bytesLoaded, + totalBytes: data.totalBytes, + status: 'downloading', + }), + ); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadProgress as Parameters< + typeof downloadEventBus.on + >[0], + handleProgress as Parameters[1], + ); + }, []); + + // Handle preload complete + useEffect(() => { + const handleComplete = (data: PreloadCompleteEvent) => { + setJobs((prev) => + updateJob(prev, data.jobId, { + status: 'completed', + progress: 100, + completedAt: Date.now(), + }), + ); + + // Auto-remove completed jobs after delay (same pattern as hoboken) + setTimeout(() => { + setJobs((prev) => prev.filter((j) => j.id !== data.jobId)); + }, PRELOAD_CONFIG.autoRemove.completedMs); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadComplete as Parameters< + typeof downloadEventBus.on + >[0], + handleComplete as Parameters[1], + ); + }, []); + + // Handle preload error + useEffect(() => { + const handleError = (data: PreloadErrorEvent) => { + setJobs((prev) => + updateJob(prev, data.jobId, { + status: 'error', + error: data.error, + }), + ); + + // Auto-remove error jobs after longer delay (same pattern as hoboken) + setTimeout(() => { + setJobs((prev) => prev.filter((j) => j.id !== data.jobId)); + }, PRELOAD_CONFIG.autoRemove.errorMs); + }; + + return downloadEventBus.on( + OFFLINE_CONFIG.events.preloadError as Parameters< + typeof downloadEventBus.on + >[0], + handleError as Parameters[1], + ); + }, []); + + // Actions + const clearJob = useCallback((id: string) => { + setJobs((prev) => prev.filter((j) => j.id !== id)); + }, []); + + const clearAllCompleted = useCallback(() => { + setJobs((prev) => prev.filter((j) => j.status !== 'completed')); + }, []); + + const clearAllErrors = useCallback(() => { + setJobs((prev) => prev.filter((j) => j.status !== 'error')); + }, []); + + // Computed values + const activeCount = useMemo( + () => + jobs.filter((j) => j.status === 'downloading' || j.status === 'queued') + .length, + [jobs], + ); + + const completedCount = useMemo( + () => jobs.filter((j) => j.status === 'completed').length, + [jobs], + ); + + const errorCount = useMemo( + () => jobs.filter((j) => j.status === 'error').length, + [jobs], + ); + + const totalProgress = useMemo(() => calculateOverallProgress(jobs), [jobs]); + + const isActive = useMemo(() => activeCount > 0, [activeCount]); + + return { + jobs, + activeCount, + completedCount, + errorCount, + totalProgress, + isActive, + clearJob, + clearAllCompleted, + clearAllErrors, + }; +} diff --git a/frontend/src/hooks/useReversePlayback.ts b/frontend/src/hooks/useReversePlayback.ts new file mode 100644 index 0000000..b1fe82c --- /dev/null +++ b/frontend/src/hooks/useReversePlayback.ts @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from 'react'; + +interface UseReversePlaybackOptions { + videoRef: RefObject; + duration: number; + onComplete: () => void; + preloadedUrls?: Set; + videoUrl?: string; +} + +interface UseReversePlaybackResult { + startReverse: () => Promise; + stopReverse: () => void; + isReversing: boolean; + canUseNativeReverse: boolean; +} + +// Feature detection for native reverse playback (Chrome 141+, Safari 16+) +function checkNativeReverseSupport(): boolean { + if (typeof window === 'undefined') return false; + try { + const video = document.createElement('video'); + video.playbackRate = -1; + return video.playbackRate === -1; + } catch { + return false; + } +} + +// Native playbackRate = -1 (Chrome 141+, Safari 16+) +async function startNativeReverse( + video: HTMLVideoElement, + duration: number, + onComplete: () => void +): Promise { + return new Promise((resolve) => { + // Seek to end + video.currentTime = duration; + video.playbackRate = -1; + + const cleanup = () => { + video.removeEventListener('timeupdate', onTimeUpdate); + video.playbackRate = 1; + }; + + const onTimeUpdate = () => { + if (video.currentTime <= 0.05) { + cleanup(); + video.pause(); + video.currentTime = 0; + onComplete(); + resolve(); + } + }; + + video.addEventListener('timeupdate', onTimeUpdate); + + video.play().catch(() => { + // Fallback if native reverse fails + cleanup(); + resolve(); + }); + }); +} + +// When video is already cached, seeking to end is instant +async function startPreloadedReverse( + video: HTMLVideoElement, + duration: number, + onComplete: () => void, + intervalRef: MutableRefObject +): Promise { + return new Promise((resolve) => { + video.pause(); + video.currentTime = duration; // Instant seek (video is cached) + + // Use 30 fps for smoother reverse when video is preloaded + const fps = 30; + const stepSize = 1 / fps; + + intervalRef.current = window.setInterval(() => { + const newTime = video.currentTime - stepSize; + + if (newTime <= 0) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + video.currentTime = 0; + onComplete(); + resolve(); + } else { + video.currentTime = newTime; + } + }, 1000 / fps); + }); +} + +// For non-preloaded videos, wait for canplaythrough then reverse +async function startBufferedReverse( + video: HTMLVideoElement, + duration: number, + onComplete: () => void, + intervalRef: MutableRefObject +): Promise { + return new Promise((resolve) => { + const startFrameStepping = () => { + video.pause(); + video.currentTime = duration; + + // Lower fps for non-cached videos (less seeking overhead) + const fps = 15; + const stepSize = 1 / fps; + + intervalRef.current = window.setInterval(() => { + const newTime = video.currentTime - stepSize; + + if (newTime <= 0) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + video.currentTime = 0; + onComplete(); + resolve(); + } else { + video.currentTime = newTime; + } + }, 1000 / fps); + }; + + // Check if video is already buffered enough + const isFullyBuffered = + video.readyState >= 4 || + (video.buffered.length > 0 && + video.buffered.end(video.buffered.length - 1) >= duration - 0.1); + + if (isFullyBuffered) { + startFrameStepping(); + return; + } + + // Wait for buffering to complete + const onCanPlayThrough = () => { + video.removeEventListener('canplaythrough', onCanPlayThrough); + startFrameStepping(); + }; + + video.addEventListener('canplaythrough', onCanPlayThrough); + + // Also try seeking to end to force buffering + video.currentTime = duration; + video.play().catch(() => { + // If play fails, just wait for canplaythrough + }); + + // Timeout fallback - if canplaythrough doesn't fire within 3s, start anyway + setTimeout(() => { + video.removeEventListener('canplaythrough', onCanPlayThrough); + if (!intervalRef.current) { + startFrameStepping(); + } + }, 3000); + }); +} + +export function useReversePlayback( + options: UseReversePlaybackOptions +): UseReversePlaybackResult { + const [isReversing, setIsReversing] = useState(false); + const intervalRef = useRef(null); + + // Feature detection for native reverse + const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []); + + const stopReverse = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsReversing(false); + + // Reset playback rate if it was modified + const video = options.videoRef.current; + if (video && video.playbackRate !== 1) { + video.playbackRate = 1; + } + }, [options.videoRef]); + + const startReverse = useCallback(async () => { + const video = options.videoRef.current; + if (!video) return; + + // Stop any existing reverse playback + stopReverse(); + + setIsReversing(true); + + const { duration, onComplete, preloadedUrls, videoUrl } = options; + + // Strategy 1: Native playbackRate = -1 (smoothest) + if (canUseNativeReverse) { + await startNativeReverse(video, duration, () => { + setIsReversing(false); + onComplete(); + }); + return; + } + + // Strategy 2: Check if video is preloaded (instant seek) + const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl); + if (isPreloaded) { + await startPreloadedReverse(video, duration, () => { + setIsReversing(false); + onComplete(); + }, intervalRef); + return; + } + + // Strategy 3: Wait for buffering then reverse + await startBufferedReverse(video, duration, () => { + setIsReversing(false); + onComplete(); + }, intervalRef); + }, [canUseNativeReverse, options, stopReverse]); + + // Cleanup on unmount + useEffect(() => () => stopReverse(), [stopReverse]); + + return { startReverse, stopReverse, isReversing, canUseNativeReverse }; +} diff --git a/frontend/src/hooks/useStorageQuota.ts b/frontend/src/hooks/useStorageQuota.ts new file mode 100644 index 0000000..30a8009 --- /dev/null +++ b/frontend/src/hooks/useStorageQuota.ts @@ -0,0 +1,110 @@ +/** + * useStorageQuota Hook + * + * Monitors storage quota and usage for offline assets. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { StorageManager } from '../lib/offline/StorageManager'; +import { PRELOAD_CONFIG } from '../config/preload.config'; +import type { StorageQuotaInfo } from '../types/offline'; + +interface UseStorageQuotaResult extends StorageQuotaInfo { + isLoading: boolean; + error: string | null; + refresh: () => Promise; + requestPersistence: () => Promise; + isPersisted: boolean; + isWarning: boolean; + isCritical: boolean; + formatSize: (bytes: number) => string; +} + +/** + * Format bytes to human-readable string + */ +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + if (bytes === Infinity) return 'Unlimited'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +export function useStorageQuota(): UseStorageQuotaResult { + const [quotaInfo, setQuotaInfo] = useState({ + usage: 0, + quota: Infinity, + percentUsed: 0, + available: Infinity, + canStore: () => true, + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isPersisted, setIsPersisted] = useState(false); + + // Fetch quota info + const refresh = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const info = await StorageManager.getStorageQuota(); + setQuotaInfo(info); + + // Check persistence status + if (typeof navigator !== 'undefined' && navigator.storage?.persisted) { + const persisted = await navigator.storage.persisted(); + setIsPersisted(persisted); + } + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to get storage quota', + ); + } finally { + setIsLoading(false); + } + }, []); + + // Request persistent storage + const requestPersistence = useCallback(async (): Promise => { + try { + const granted = await StorageManager.requestPersistentStorage(); + setIsPersisted(granted); + return granted; + } catch { + return false; + } + }, []); + + // Initial fetch and periodic refresh + useEffect(() => { + refresh(); + + // Refresh every 30 seconds + const interval = setInterval(refresh, 30000); + + return () => clearInterval(interval); + }, [refresh]); + + // Computed values + const isWarning = + quotaInfo.percentUsed >= PRELOAD_CONFIG.storage.warningPercent; + const isCritical = + quotaInfo.percentUsed >= PRELOAD_CONFIG.storage.criticalPercent; + + return { + ...quotaInfo, + isLoading, + error, + refresh, + requestPersistence, + isPersisted, + isWarning, + isCritical, + formatSize: formatBytes, + }; +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 5062f80..79533e7 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks'; import Search from '../components/Search'; import { useRouter } from 'next/router'; import { findMe, logoutUser } from '../stores/authSlice'; +import { logger } from '../lib/logger'; import { hasPermission } from '../helpers/userPermissions'; @@ -67,7 +68,10 @@ export default function LayoutAuthenticated({ if (typeof decoded.exp !== 'number') return true; return Date.now() / 1000 < decoded.exp; } catch (error) { - console.error('Failed to decode auth token:', error); + logger.error( + 'Failed to decode auth token:', + error instanceof Error ? error : { error }, + ); return false; } }; @@ -125,7 +129,10 @@ export default function LayoutAuthenticated({ router.replace('/projects/projects-new'); } } catch (error) { - console.error('Failed to check projects:', error); + logger.error( + 'Failed to check projects:', + error instanceof Error ? error : { error }, + ); } }; diff --git a/frontend/src/lib/assetUrl.ts b/frontend/src/lib/assetUrl.ts new file mode 100644 index 0000000..4fb9cd3 --- /dev/null +++ b/frontend/src/lib/assetUrl.ts @@ -0,0 +1,44 @@ +/** + * Asset URL Resolution Utility + * + * Resolves relative asset paths to absolute backend URLs for playback and preloading. + */ + +import { baseURLApi } from '../config'; + +/** + * Resolves an asset path to its full playback URL. + * + * Handles: + * - data: and blob: URLs (passthrough) + * - /api/file/download URLs (passthrough) + * - /file/download URLs (prepend baseURLApi) + * - Full http/https URLs (passthrough) + * - Relative paths (convert to /api/file/download?privateUrl=...) + * + * @param value - The asset URL or path to resolve + * @returns The resolved full URL, or empty string if no value + */ +export const resolveAssetPlaybackUrl = (value?: string): string => { + const normalized = String(value || '').trim(); + if (!normalized) return ''; + + // Data and blob URLs pass through + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) + return normalized; + + // Already an API file download URL + if (normalized.startsWith('/api/file/download')) return normalized; + + // File download path (prepend API base) + if (normalized.startsWith('/file/download')) + return `${baseURLApi}${normalized}`; + + // Full URLs pass through + if (normalized.startsWith('http://') || normalized.startsWith('https://')) + return normalized; + + // Relative path - convert to API download URL + const normalizedPrivateUrl = normalized.replace(/^\/+/, ''); + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; +}; diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts new file mode 100644 index 0000000..5e2443a --- /dev/null +++ b/frontend/src/lib/logger.ts @@ -0,0 +1,163 @@ +/** + * Frontend Logger + * + * A lightweight, isomorphic logger for Next.js applications. + * Works in both browser and SSR environments. + * + * Usage: + * import { logger } from '@/lib/logger'; + * logger.info('User logged in', { userId: '123' }); + * logger.error('Failed to fetch data', error); + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogContext { + [key: string]: unknown; +} + +interface LoggerConfig { + level: LogLevel; + isDevelopment: boolean; + service: string; +} + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +class Logger { + private config: LoggerConfig; + + constructor() { + this.config = { + level: (process.env.NEXT_PUBLIC_LOG_LEVEL as LogLevel) || 'info', + isDevelopment: process.env.NODE_ENV === 'development', + service: 'tour-builder-frontend', + }; + } + + private shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[this.config.level]; + } + + private formatMessage( + level: LogLevel, + message: string, + context?: LogContext | Error, + ): { formatted: string; data?: object } { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + + if (context instanceof Error) { + return { + formatted: `${prefix} ${message}`, + data: { + error: context.message, + stack: context.stack, + }, + }; + } + + return { + formatted: `${prefix} ${message}`, + data: context, + }; + } + + private output( + level: LogLevel, + message: string, + context?: LogContext | Error, + ): void { + if (!this.shouldLog(level)) return; + + const { formatted, data } = this.formatMessage(level, message, context); + + // In development, use colored console output + if (this.config.isDevelopment) { + const consoleFn = level === 'debug' ? 'log' : level; + if (data) { + console[consoleFn](formatted, data); + } else { + console[consoleFn](formatted); + } + return; + } + + // In production, use structured JSON logging (for log aggregation services) + const logEntry = { + timestamp: new Date().toISOString(), + level, + message, + service: this.config.service, + ...(data || {}), + }; + + const consoleFn = level === 'debug' ? 'log' : level; + console[consoleFn](JSON.stringify(logEntry)); + } + + debug(message: string, context?: LogContext): void { + this.output('debug', message, context); + } + + info(message: string, context?: LogContext): void { + this.output('info', message, context); + } + + warn(message: string, context?: LogContext | Error): void { + this.output('warn', message, context); + } + + error(message: string, context?: LogContext | Error): void { + this.output('error', message, context); + } + + /** + * Create a child logger with additional context + */ + child(context: LogContext): ChildLogger { + return new ChildLogger(this, context); + } +} + +class ChildLogger { + constructor( + private parent: Logger, + private context: LogContext, + ) {} + + debug(message: string, additionalContext?: LogContext): void { + this.parent.debug(message, { ...this.context, ...additionalContext }); + } + + info(message: string, additionalContext?: LogContext): void { + this.parent.info(message, { ...this.context, ...additionalContext }); + } + + warn(message: string, additionalContext?: LogContext | Error): void { + if (additionalContext instanceof Error) { + this.parent.warn(message, additionalContext); + } else { + this.parent.warn(message, { ...this.context, ...additionalContext }); + } + } + + error(message: string, additionalContext?: LogContext | Error): void { + if (additionalContext instanceof Error) { + this.parent.error(message, additionalContext); + } else { + this.parent.error(message, { ...this.context, ...additionalContext }); + } + } +} + +// Export singleton instance +export const logger = new Logger(); + +// Export types for consumers +export type { LogLevel, LogContext }; diff --git a/frontend/src/lib/offline/DownloadEventBus.ts b/frontend/src/lib/offline/DownloadEventBus.ts new file mode 100644 index 0000000..83447bf --- /dev/null +++ b/frontend/src/lib/offline/DownloadEventBus.ts @@ -0,0 +1,179 @@ +/** + * DownloadEventBus + * + * Browser-native EventEmitter for asset preload progress tracking. + * Replaces Socket.IO pattern from hoboken repository with client-side events. + */ + +import { OFFLINE_CONFIG } from '../../config/offline.config'; +import { logger } from '../logger'; +import type { + PreloadStartEvent, + PreloadProgressEvent, + PreloadCompleteEvent, + PreloadErrorEvent, + ProjectDownloadProgressEvent, + ProjectDownloadCompleteEvent, +} from '../../types/offline'; + +type EventMap = { + [OFFLINE_CONFIG.events.preloadStart]: PreloadStartEvent; + [OFFLINE_CONFIG.events.preloadProgress]: PreloadProgressEvent; + [OFFLINE_CONFIG.events.preloadComplete]: PreloadCompleteEvent; + [OFFLINE_CONFIG.events.preloadError]: PreloadErrorEvent; + [OFFLINE_CONFIG.events.projectDownloadProgress]: ProjectDownloadProgressEvent; + [OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent; + [OFFLINE_CONFIG.events.queueUpdate]: void; +}; + +type EventCallback = (data: T) => void; + +class DownloadEventBusClass { + private listeners: Map>> = new Map(); + + /** + * Subscribe to an event + */ + on( + event: K, + callback: EventCallback, + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const listenerSet = this.listeners.get(event); + if (listenerSet) { + listenerSet.add(callback as EventCallback); + } + + // Return unsubscribe function + return () => this.off(event, callback); + } + + /** + * Unsubscribe from an event + */ + off( + event: K, + callback: EventCallback, + ): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.delete(callback as EventCallback); + } + } + + /** + * Emit an event to all subscribers + */ + emit(event: K, data: EventMap[K]): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach((callback) => { + try { + callback(data); + } catch (error) { + logger.error( + `[DownloadEventBus] Error in ${event} handler:`, + error instanceof Error ? error : { error }, + ); + } + }); + } + } + + /** + * Subscribe to an event for one emission only + */ + once( + event: K, + callback: EventCallback, + ): () => void { + const wrappedCallback: EventCallback = (data) => { + this.off(event, wrappedCallback); + callback(data); + }; + return this.on(event, wrappedCallback); + } + + /** + * Remove all listeners for an event (or all events if no event specified) + */ + removeAllListeners(event?: keyof EventMap): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } + + /** + * Get listener count for an event + */ + listenerCount(event: keyof EventMap): number { + return this.listeners.get(event)?.size ?? 0; + } + + // Convenience methods for common events + + /** + * Emit preload start event + */ + emitPreloadStart(data: PreloadStartEvent): void { + this.emit(OFFLINE_CONFIG.events.preloadStart as keyof EventMap, data); + } + + /** + * Emit preload progress event + */ + emitPreloadProgress(data: PreloadProgressEvent): void { + this.emit(OFFLINE_CONFIG.events.preloadProgress as keyof EventMap, data); + } + + /** + * Emit preload complete event + */ + emitPreloadComplete(data: PreloadCompleteEvent): void { + this.emit(OFFLINE_CONFIG.events.preloadComplete as keyof EventMap, data); + } + + /** + * Emit preload error event + */ + emitPreloadError(data: PreloadErrorEvent): void { + this.emit(OFFLINE_CONFIG.events.preloadError as keyof EventMap, data); + } + + /** + * Emit project download progress event + */ + emitProjectProgress(data: ProjectDownloadProgressEvent): void { + this.emit( + OFFLINE_CONFIG.events.projectDownloadProgress as keyof EventMap, + data, + ); + } + + /** + * Emit project download complete event + */ + emitProjectComplete(data: ProjectDownloadCompleteEvent): void { + this.emit( + OFFLINE_CONFIG.events.projectDownloadComplete as keyof EventMap, + data, + ); + } + + /** + * Emit queue update event + */ + emitQueueUpdate(): void { + this.emit( + OFFLINE_CONFIG.events.queueUpdate as keyof EventMap, + undefined as never, + ); + } +} + +// Singleton instance +export const downloadEventBus = new DownloadEventBusClass(); diff --git a/frontend/src/lib/offline/DownloadManager.ts b/frontend/src/lib/offline/DownloadManager.ts new file mode 100644 index 0000000..e45819b --- /dev/null +++ b/frontend/src/lib/offline/DownloadManager.ts @@ -0,0 +1,462 @@ +/** + * DownloadManager + * + * Manages asset downloads with queue, retry, and progress tracking. + * Adapted from hoboken's videoProcessingQueue pattern for frontend use. + */ + +import { PRELOAD_CONFIG } from '../../config/preload.config'; +import { OFFLINE_CONFIG } from '../../config/offline.config'; +import { downloadEventBus } from './DownloadEventBus'; +import { StorageManager } from './StorageManager'; +import { OfflineDbManager } from '../offlineDb/OfflineDbManager'; +import type { + PreloadJobStatus, + AssetVariantType, + AssetType, + DownloadQueueItem, +} from '../../types/offline'; + +interface DownloadJob { + id: string; + assetId: string; + projectId: string; + url: string; + filename: string; + variantType: AssetVariantType; + assetType: AssetType; + priority: number; + status: PreloadJobStatus; + progress: number; + bytesLoaded: number; + totalBytes: number; + retryCount: number; + addedAt: number; + abortController?: AbortController; + resolve?: () => void; + reject?: (error: Error) => void; +} + +class DownloadManagerClass { + private queue: DownloadJob[] = []; + private activeDownloads: Map = new Map(); + private isPaused = false; + private isProcessing = false; + + private config = { + maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, + chunkSize: PRELOAD_CONFIG.videoChunkSize, + maxRetries: PRELOAD_CONFIG.maxRetries, + retryDelayMs: PRELOAD_CONFIG.retryDelayMs, + largeFileThreshold: PRELOAD_CONFIG.largeFileThreshold, + }; + + /** + * Add a download job to the queue + */ + async addJob(params: { + assetId: string; + projectId: string; + url: string; + filename: string; + variantType: AssetVariantType; + assetType: AssetType; + priority?: number; + }): Promise { + // Check if already downloaded + const hasAsset = await StorageManager.hasAsset(params.url); + if (hasAsset) { + return; + } + + // Check if already in queue + if ( + this.queue.some((j) => j.url === params.url) || + this.activeDownloads.has(params.url) + ) { + return; + } + + return new Promise((resolve, reject) => { + const job: DownloadJob = { + id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + assetId: params.assetId, + projectId: params.projectId, + url: params.url, + filename: params.filename, + variantType: params.variantType, + assetType: params.assetType, + priority: + params.priority ?? + this.calculatePriority(params.assetType, params.variantType), + status: 'queued', + progress: 0, + bytesLoaded: 0, + totalBytes: 0, + retryCount: 0, + addedAt: Date.now(), + resolve, + reject, + }; + + // Persist to IndexedDB for resume capability + this.persistQueueItem(job); + + // Insert in priority order (higher priority first) + const insertIndex = this.queue.findIndex( + (q) => q.priority < job.priority, + ); + if (insertIndex === -1) { + this.queue.push(job); + } else { + this.queue.splice(insertIndex, 0, job); + } + + downloadEventBus.emitQueueUpdate(); + this.processQueue(); + }); + } + + /** + * Calculate priority based on asset and variant type + */ + private calculatePriority( + assetType: AssetType, + variantType: AssetVariantType, + ): number { + const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0; + const variantPriority = PRELOAD_CONFIG.priority.variant[variantType] || 0; + return typePriority + variantPriority; + } + + /** + * Persist queue item to IndexedDB + */ + private async persistQueueItem(job: DownloadJob): Promise { + const queueItem: DownloadQueueItem = { + id: job.id, + projectId: job.projectId, + assetId: job.assetId, + url: job.url, + filename: job.filename, + status: job.status, + priority: job.priority, + retryCount: job.retryCount, + bytesLoaded: job.bytesLoaded, + totalBytes: job.totalBytes, + addedAt: job.addedAt, + }; + await OfflineDbManager.addToQueue(queueItem); + } + + /** + * Process the download queue + */ + private async processQueue(): Promise { + if (this.isProcessing || this.isPaused) return; + if (this.queue.length === 0 && this.activeDownloads.size === 0) return; + + this.isProcessing = true; + + while ( + !this.isPaused && + this.activeDownloads.size < this.config.maxConcurrent && + this.queue.length > 0 + ) { + const job = this.queue.shift(); + if (!job) break; + + this.activeDownloads.set(job.url, job); + this.downloadAsset(job); + } + + this.isProcessing = false; + } + + /** + * Download a single asset with progress tracking + */ + private async downloadAsset(job: DownloadJob): Promise { + job.status = 'downloading'; + job.abortController = new AbortController(); + + await OfflineDbManager.updateQueueStatus(job.id, 'downloading'); + + downloadEventBus.emitPreloadStart({ + jobId: job.id, + assetId: job.assetId, + url: job.url, + }); + + try { + const response = await fetch(job.url, { + signal: job.abortController.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentLength = response.headers.get('content-length'); + job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0; + + let blob: Blob; + + if (response.body) { + // Stream with progress tracking + const reader = response.body.getReader(); + const chunks: BlobPart[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + job.bytesLoaded += value.length; + job.progress = + job.totalBytes > 0 + ? Math.round((job.bytesLoaded / job.totalBytes) * 100) + : 0; + + downloadEventBus.emitPreloadProgress({ + jobId: job.id, + progress: job.progress, + bytesLoaded: job.bytesLoaded, + totalBytes: job.totalBytes, + }); + + await OfflineDbManager.updateQueueProgress( + job.id, + job.bytesLoaded, + job.totalBytes, + ); + } + + blob = new Blob(chunks, { + type: + response.headers.get('content-type') || 'application/octet-stream', + }); + } else { + // No streaming, get blob directly + blob = await response.blob(); + job.totalBytes = blob.size; + job.bytesLoaded = blob.size; + job.progress = 100; + } + + // Store the asset + await StorageManager.storeAsset(job.url, blob, { + id: job.assetId, + projectId: job.projectId, + filename: job.filename, + variantType: job.variantType, + assetType: job.assetType, + }); + + // Mark as completed + job.status = 'completed'; + await OfflineDbManager.removeFromQueue(job.id); + + downloadEventBus.emitPreloadComplete({ + jobId: job.id, + assetId: job.assetId, + }); + + job.resolve?.(); + } catch (error) { + if (job.abortController.signal.aborted) { + // Download was cancelled + job.status = 'paused'; + return; + } + + job.retryCount++; + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + if (job.retryCount < this.config.maxRetries) { + // Retry with backoff + job.status = 'queued'; + await OfflineDbManager.updateQueueStatus(job.id, 'queued'); + + setTimeout(() => { + this.queue.unshift(job); + this.processQueue(); + }, this.config.retryDelayMs * job.retryCount); + } else { + // Max retries exceeded + job.status = 'error'; + await OfflineDbManager.updateQueueStatus(job.id, 'error', errorMessage); + + downloadEventBus.emitPreloadError({ + jobId: job.id, + assetId: job.assetId, + error: errorMessage, + }); + + job.reject?.(error instanceof Error ? error : new Error(errorMessage)); + } + } finally { + this.activeDownloads.delete(job.url); + downloadEventBus.emitQueueUpdate(); + this.processQueue(); + } + } + + /** + * Pause all downloads + */ + pauseAll(): void { + this.isPaused = true; + this.activeDownloads.forEach((job) => { + job.abortController?.abort(); + job.status = 'paused'; + }); + downloadEventBus.emitQueueUpdate(); + } + + /** + * Resume all downloads + */ + resumeAll(): void { + this.isPaused = false; + + // Move paused jobs back to queue + this.activeDownloads.forEach((job) => { + if (job.status === 'paused') { + job.status = 'queued'; + this.queue.unshift(job); + } + }); + this.activeDownloads.clear(); + + downloadEventBus.emitQueueUpdate(); + this.processQueue(); + } + + /** + * Cancel a specific download + */ + cancelJob(jobId: string): void { + // Check active downloads + const entries = Array.from(this.activeDownloads.entries()); + for (const [url, job] of entries) { + if (job.id === jobId) { + job.abortController?.abort(); + this.activeDownloads.delete(url); + OfflineDbManager.removeFromQueue(jobId); + downloadEventBus.emitQueueUpdate(); + return; + } + } + + // Check queue + const index = this.queue.findIndex((j) => j.id === jobId); + if (index !== -1) { + this.queue.splice(index, 1); + OfflineDbManager.removeFromQueue(jobId); + downloadEventBus.emitQueueUpdate(); + } + } + + /** + * Cancel all downloads for a project + */ + cancelProjectDownloads(projectId: string): void { + // Cancel active downloads + const entries = Array.from(this.activeDownloads.entries()); + for (const [url, job] of entries) { + if (job.projectId === projectId) { + job.abortController?.abort(); + this.activeDownloads.delete(url); + } + } + + // Remove from queue + this.queue = this.queue.filter((j) => j.projectId !== projectId); + + // Clear from IndexedDB + OfflineDbManager.clearProjectQueue(projectId); + downloadEventBus.emitQueueUpdate(); + } + + /** + * Clear entire queue + */ + clearQueue(): void { + // Abort all active downloads + this.activeDownloads.forEach((job) => { + job.abortController?.abort(); + }); + this.activeDownloads.clear(); + + // Clear queue + this.queue = []; + + // Clear IndexedDB + OfflineDbManager.clearQueue(); + downloadEventBus.emitQueueUpdate(); + } + + /** + * Get current queue status + */ + getStatus(): { + queueLength: number; + activeCount: number; + isPaused: boolean; + } { + return { + queueLength: this.queue.length, + activeCount: this.activeDownloads.size, + isPaused: this.isPaused, + }; + } + + /** + * Restore queue from IndexedDB (for resume after page reload) + */ + async restoreQueue(): Promise { + const pendingItems = await OfflineDbManager.getPendingQueue(); + + for (const item of pendingItems) { + // Skip if already downloaded + const hasAsset = await StorageManager.hasAsset(item.url); + if (hasAsset) { + await OfflineDbManager.removeFromQueue(item.id); + continue; + } + + // Re-add to queue + const job: DownloadJob = { + id: item.id, + assetId: item.assetId, + projectId: item.projectId, + url: item.url, + filename: item.filename, + variantType: 'original', + assetType: 'other', + priority: item.priority, + status: 'queued', + progress: 0, + bytesLoaded: item.bytesLoaded, + totalBytes: item.totalBytes, + retryCount: item.retryCount, + addedAt: item.addedAt, + }; + + this.queue.push(job); + } + + // Sort by priority + this.queue.sort((a, b) => b.priority - a.priority); + + if (this.queue.length > 0) { + downloadEventBus.emitQueueUpdate(); + this.processQueue(); + } + } +} + +// Singleton instance +export const downloadManager = new DownloadManagerClass(); diff --git a/frontend/src/lib/offline/StorageManager.ts b/frontend/src/lib/offline/StorageManager.ts new file mode 100644 index 0000000..3fc2197 --- /dev/null +++ b/frontend/src/lib/offline/StorageManager.ts @@ -0,0 +1,281 @@ +/** + * StorageManager + * + * Abstraction layer for storing assets in Cache API or IndexedDB. + * Small files go to Cache API, large files (videos > 5MB) go to IndexedDB. + */ + +import { OFFLINE_CONFIG } from '../../config/offline.config'; +import { PRELOAD_CONFIG } from '../../config/preload.config'; +import { OfflineDbManager } from '../offlineDb/OfflineDbManager'; +import type { + OfflineAsset, + AssetVariantType, + AssetType, + StorageQuotaInfo, +} from '../../types/offline'; + +export class StorageManager { + /** + * Get storage quota information + */ + static async getStorageQuota(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.estimate) { + return { + usage: 0, + quota: Infinity, + percentUsed: 0, + available: Infinity, + canStore: () => true, + }; + } + + try { + const { usage = 0, quota = Infinity } = + await navigator.storage.estimate(); + const percentUsed = quota > 0 ? (usage / quota) * 100 : 0; + const available = quota - usage; + + return { + usage, + quota, + percentUsed, + available, + canStore: (bytes: number) => + available - bytes > PRELOAD_CONFIG.storage.minFreeBuffer, + }; + } catch { + return { + usage: 0, + quota: Infinity, + percentUsed: 0, + available: Infinity, + canStore: () => true, + }; + } + } + + /** + * Request persistent storage (prevents browser from clearing data) + */ + static async requestPersistentStorage(): Promise { + if (typeof navigator === 'undefined' || !navigator.storage?.persist) { + return false; + } + + try { + // Check if already persisted + const isPersisted = await navigator.storage.persisted(); + if (isPersisted) return true; + + // Request persistence + return await navigator.storage.persist(); + } catch { + return false; + } + } + + /** + * Determine if an asset should be stored in IndexedDB (large files) + * or Cache API (small files) + */ + static shouldUseIndexedDB(sizeBytes: number): boolean { + return sizeBytes >= OFFLINE_CONFIG.storage.indexedDbMinSize; + } + + /** + * Store an asset (auto-selects Cache API or IndexedDB) + */ + static async storeAsset( + url: string, + blob: Blob, + metadata: { + id: string; + projectId: string; + filename: string; + variantType: AssetVariantType; + assetType: AssetType; + }, + ): Promise { + const sizeBytes = blob.size; + + if (this.shouldUseIndexedDB(sizeBytes)) { + // Store in IndexedDB for large files + const asset: OfflineAsset = { + id: metadata.id, + projectId: metadata.projectId, + url, + filename: metadata.filename, + variantType: metadata.variantType, + assetType: metadata.assetType, + mimeType: blob.type, + sizeBytes, + blob, + downloadedAt: Date.now(), + }; + await OfflineDbManager.storeAsset(asset); + } else { + // Store in Cache API for small files + const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); + const response = new Response(blob, { + headers: { + 'Content-Type': blob.type, + 'Content-Length': String(sizeBytes), + 'X-Asset-Id': metadata.id, + 'X-Project-Id': metadata.projectId, + }, + }); + await cache.put(url, response); + } + } + + /** + * Get an asset (checks both Cache API and IndexedDB) + */ + static async getAsset(url: string): Promise { + // Check IndexedDB first (for large files) + const indexedAsset = await OfflineDbManager.getAssetByUrl(url); + if (indexedAsset) { + return indexedAsset.blob; + } + + // Check Cache API + if (typeof caches !== 'undefined') { + const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); + const response = await cache.match(url); + if (response) { + return response.blob(); + } + } + + return null; + } + + /** + * Check if an asset exists + */ + static async hasAsset(url: string): Promise { + // Check IndexedDB + const hasInDb = await OfflineDbManager.hasAssetByUrl(url); + 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); + if (response) return true; + } + + return false; + } + + /** + * Delete an asset from all storage locations + */ + static async deleteAsset(url: string, assetId?: string): Promise { + // Delete from IndexedDB + if (assetId) { + await OfflineDbManager.deleteAsset(assetId); + } + + // Delete from Cache API + if (typeof caches !== 'undefined') { + const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets); + await cache.delete(url); + } + } + + /** + * Delete all assets for a project + */ + static async deleteProjectAssets(projectId: string): Promise { + // Delete from IndexedDB + await OfflineDbManager.deleteProjectAssets(projectId); + + // Cache API cleanup is more complex - we'd need to track URLs + // For now, we rely on the service worker to handle this + } + + /** + * Get total storage used + */ + static async getTotalStorageUsed(): Promise { + let total = 0; + + // IndexedDB storage + total += await OfflineDbManager.getTotalAssetsSize(); + + // Cache API storage (approximate from quota) + const quota = await this.getStorageQuota(); + // Note: quota.usage includes all storage, not just our caches + + return total; + } + + /** + * Clear all offline storage + */ + static async clearAll(): Promise { + // Clear IndexedDB + await OfflineDbManager.clearAll(); + + // Clear Cache API + if (typeof caches !== 'undefined') { + await caches.delete(OFFLINE_CONFIG.cacheNames.assets); + await caches.delete(OFFLINE_CONFIG.cacheNames.dynamic); + } + } + + /** + * Send assets to service worker cache + */ + static async cacheViaServiceWorker(urls: string[]): Promise { + if ( + typeof navigator === 'undefined' || + !navigator.serviceWorker?.controller + ) { + return; + } + + navigator.serviceWorker.controller.postMessage({ + type: 'CACHE_ASSETS', + payload: { urls }, + }); + } + + /** + * Check service worker cache status + */ + static async getServiceWorkerCacheStatus(): Promise<{ + cachedCount: number; + urls: string[]; + }> { + return new Promise((resolve) => { + if ( + typeof navigator === 'undefined' || + !navigator.serviceWorker?.controller + ) { + resolve({ cachedCount: 0, urls: [] }); + return; + } + + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === 'CACHE_STATUS') { + navigator.serviceWorker.removeEventListener('message', handleMessage); + resolve(event.data.payload); + } + }; + + navigator.serviceWorker.addEventListener('message', handleMessage); + navigator.serviceWorker.controller.postMessage({ + type: 'GET_CACHE_STATUS', + }); + + // Timeout after 5 seconds + setTimeout(() => { + navigator.serviceWorker.removeEventListener('message', handleMessage); + resolve({ cachedCount: 0, urls: [] }); + }, 5000); + }); + } +} diff --git a/frontend/src/lib/offlineDb/OfflineDbManager.ts b/frontend/src/lib/offlineDb/OfflineDbManager.ts new file mode 100644 index 0000000..44b2317 --- /dev/null +++ b/frontend/src/lib/offlineDb/OfflineDbManager.ts @@ -0,0 +1,342 @@ +/** + * OfflineDbManager + * + * CRUD operations for IndexedDB offline storage. + * Handles large assets (videos > 5MB) and project metadata. + */ + +import { offlineDb } from './schema'; +import type { + OfflineAsset, + OfflineProject, + DownloadQueueItem, + ProjectOfflineStatus, + PreloadJobStatus, +} from '../../types/offline'; + +export class OfflineDbManager { + // ============================================ + // ASSETS + // ============================================ + + /** + * Store an asset blob in IndexedDB + */ + static async storeAsset(asset: OfflineAsset): Promise { + await offlineDb.assets.put(asset); + } + + /** + * Get an asset by ID + */ + static async getAsset(id: string): Promise { + return offlineDb.assets.get(id); + } + + /** + * Get an asset by URL + */ + static async getAssetByUrl(url: string): Promise { + return offlineDb.assets.where('url').equals(url).first(); + } + + /** + * Get all assets for a project + */ + static async getProjectAssets(projectId: string): Promise { + return offlineDb.assets.where('projectId').equals(projectId).toArray(); + } + + /** + * Delete an asset + */ + static async deleteAsset(id: string): Promise { + await offlineDb.assets.delete(id); + } + + /** + * Delete all assets for a project + */ + static async deleteProjectAssets(projectId: string): Promise { + return offlineDb.assets.where('projectId').equals(projectId).delete(); + } + + /** + * Check if an asset exists + */ + static async hasAsset(id: string): Promise { + const count = await offlineDb.assets.where('id').equals(id).count(); + return count > 0; + } + + /** + * Check if an asset URL exists + */ + static async hasAssetByUrl(url: string): Promise { + const count = await offlineDb.assets.where('url').equals(url).count(); + return count > 0; + } + + /** + * Get total storage used by assets + */ + static async getTotalAssetsSize(): Promise { + const assets = await offlineDb.assets.toArray(); + return assets.reduce((total, asset) => total + asset.sizeBytes, 0); + } + + /** + * Get storage used by a project's assets + */ + static async getProjectAssetsSize(projectId: string): Promise { + const assets = await offlineDb.assets + .where('projectId') + .equals(projectId) + .toArray(); + return assets.reduce((total, asset) => total + asset.sizeBytes, 0); + } + + // ============================================ + // PROJECTS + // ============================================ + + /** + * Store or update project offline metadata + */ + static async upsertProject(project: OfflineProject): Promise { + await offlineDb.projects.put(project); + } + + /** + * Get project by ID + */ + static async getProject(id: string): Promise { + return offlineDb.projects.get(id); + } + + /** + * Get project by slug + */ + static async getProjectBySlug( + slug: string, + ): Promise { + return offlineDb.projects.where('slug').equals(slug).first(); + } + + /** + * Get all offline projects + */ + static async getAllProjects(): Promise { + return offlineDb.projects.toArray(); + } + + /** + * Update project status + */ + static async updateProjectStatus( + id: string, + status: ProjectOfflineStatus, + ): Promise { + await offlineDb.projects.update(id, { status }); + } + + /** + * Update project download progress + */ + static async updateProjectProgress( + id: string, + downloadedAssets: number, + downloadedSizeBytes: number, + ): Promise { + await offlineDb.projects.update(id, { + downloadedAssets, + downloadedSizeBytes, + }); + } + + /** + * Delete project and all its assets + */ + static async deleteProject(id: string): Promise { + await offlineDb.transaction( + 'rw', + [offlineDb.projects, offlineDb.assets, offlineDb.downloadQueue], + async () => { + await offlineDb.projects.delete(id); + await offlineDb.assets.where('projectId').equals(id).delete(); + await offlineDb.downloadQueue.where('projectId').equals(id).delete(); + }, + ); + } + + // ============================================ + // DOWNLOAD QUEUE + // ============================================ + + /** + * Add item to download queue + */ + static async addToQueue(item: DownloadQueueItem): Promise { + await offlineDb.downloadQueue.put(item); + } + + /** + * Get queue item by ID + */ + static async getQueueItem( + id: string, + ): Promise { + return offlineDb.downloadQueue.get(id); + } + + /** + * Get all queued items for a project + */ + static async getProjectQueue( + projectId: string, + ): Promise { + return offlineDb.downloadQueue + .where('projectId') + .equals(projectId) + .sortBy('priority'); + } + + /** + * Get pending queue items (sorted by priority) + */ + static async getPendingQueue(): Promise { + return offlineDb.downloadQueue + .where('status') + .equals('queued') + .sortBy('priority'); + } + + /** + * Update queue item status + */ + static async updateQueueStatus( + id: string, + status: PreloadJobStatus, + error?: string, + ): Promise { + const updates: Partial = { + status, + lastAttemptAt: Date.now(), + }; + if (error) { + updates.error = error; + } + await offlineDb.downloadQueue.update(id, updates); + } + + /** + * Update queue item progress + */ + static async updateQueueProgress( + id: string, + bytesLoaded: number, + totalBytes: number, + ): Promise { + await offlineDb.downloadQueue.update(id, { bytesLoaded, totalBytes }); + } + + /** + * Increment retry count + */ + static async incrementRetry(id: string): Promise { + const item = await offlineDb.downloadQueue.get(id); + if (!item) return 0; + const newRetryCount = item.retryCount + 1; + await offlineDb.downloadQueue.update(id, { retryCount: newRetryCount }); + return newRetryCount; + } + + /** + * Remove item from queue + */ + static async removeFromQueue(id: string): Promise { + await offlineDb.downloadQueue.delete(id); + } + + /** + * Clear project download queue + */ + static async clearProjectQueue(projectId: string): Promise { + return offlineDb.downloadQueue + .where('projectId') + .equals(projectId) + .delete(); + } + + /** + * Clear entire download queue + */ + static async clearQueue(): Promise { + await offlineDb.downloadQueue.clear(); + } + + /** + * Reset failed queue items to queued status + */ + static async resetFailedItems(projectId?: string): Promise { + const query = projectId + ? offlineDb.downloadQueue + .where('projectId') + .equals(projectId) + .filter((item) => item.status === 'error') + : offlineDb.downloadQueue.filter((item) => item.status === 'error'); + + const failedItems = await query.toArray(); + + await Promise.all( + failedItems.map((item) => + offlineDb.downloadQueue.update(item.id, { + status: 'queued', + retryCount: 0, + error: undefined, + }), + ), + ); + + return failedItems.length; + } + + // ============================================ + // UTILITY + // ============================================ + + /** + * Clear all offline data + */ + static async clearAll(): Promise { + await offlineDb.transaction( + 'rw', + [offlineDb.projects, offlineDb.assets, offlineDb.downloadQueue], + async () => { + await offlineDb.projects.clear(); + await offlineDb.assets.clear(); + await offlineDb.downloadQueue.clear(); + }, + ); + } + + /** + * Get database statistics + */ + static async getStats(): Promise<{ + projectCount: number; + assetCount: number; + queueCount: number; + totalSizeBytes: number; + }> { + const [projectCount, assetCount, queueCount, totalSizeBytes] = + await Promise.all([ + offlineDb.projects.count(), + offlineDb.assets.count(), + offlineDb.downloadQueue.count(), + this.getTotalAssetsSize(), + ]); + + return { projectCount, assetCount, queueCount, totalSizeBytes }; + } +} diff --git a/frontend/src/lib/offlineDb/schema.ts b/frontend/src/lib/offlineDb/schema.ts new file mode 100644 index 0000000..74c3c49 --- /dev/null +++ b/frontend/src/lib/offlineDb/schema.ts @@ -0,0 +1,41 @@ +/** + * IndexedDB Schema with Dexie.js + * + * Manages offline storage for large assets (videos > 5MB) and project metadata. + */ + +import Dexie, { type EntityTable } from 'dexie'; +import { OFFLINE_CONFIG } from '../../config/offline.config'; +import type { + OfflineAsset, + OfflineProject, + DownloadQueueItem, +} from '../../types/offline'; + +// Dexie database class +class OfflineDatabase extends Dexie { + assets!: EntityTable; + projects!: EntityTable; + downloadQueue!: EntityTable; + + constructor() { + super(OFFLINE_CONFIG.dbName); + + this.version(OFFLINE_CONFIG.dbVersion).stores({ + // Assets table - stores large files (videos > 5MB) + assets: 'id, projectId, url, variantType, assetType, downloadedAt', + + // Projects table - tracks offline project status + projects: 'id, slug, status, lastSyncedAt', + + // Download queue - persists download state for resume capability + downloadQueue: 'id, projectId, status, priority, addedAt', + }); + } +} + +// Singleton instance +export const offlineDb = new OfflineDatabase(); + +// Export types for convenience +export type { OfflineAsset, OfflineProject, DownloadQueueItem }; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 74641ed..a8f2bcb 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -10,12 +10,7 @@ const menuAside: MenuAsideItem[] = [ { href: '/projects/projects-list', label: 'Projects', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: - 'mdiOfficeBuilding' in icon - ? icon['mdiOfficeBuilding' as keyof typeof icon] - : (icon.mdiTable ?? icon.mdiTable), + icon: icon.mdiFolder, permissions: 'READ_PROJECTS', }, { @@ -27,9 +22,7 @@ const menuAside: MenuAsideItem[] = [ { href: '/users/users-list', label: 'Users', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiAccountGroup ?? icon.mdiTable, + icon: icon.mdiAccountGroup, permissions: 'READ_USERS', }, { diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 69ba124..f0b3636 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -21,6 +21,8 @@ import { usersSteps, rolesSteps, } from '../stores/introSteps'; +import { DownloadProvider } from '../context/DownloadContext'; +import { logger } from '../lib/logger'; // Initialize axios axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API @@ -94,6 +96,37 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { const [stepName, setStepName] = React.useState(''); const [steps, setSteps] = React.useState([]); + // Register service worker for PWA offline support + React.useEffect(() => { + if ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + process.env.NODE_ENV === 'production' + ) { + navigator.serviceWorker + .register('/sw.js') + .then((registration) => { + logger.info('[PWA] Service worker registered:', { + scope: registration.scope, + }); + + // Check for updates periodically + setInterval( + () => { + registration.update(); + }, + 60 * 60 * 1000, + ); // Check every hour + }) + .catch((error) => { + logger.error( + '[PWA] Service worker registration failed:', + error instanceof Error ? error : { error }, + ); + }); + } + }, []); + // TODO: Remove this code in future releases React.useEffect(() => { const trustedOrigins = new Set([window.location.origin]); @@ -101,9 +134,9 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { try { trustedOrigins.add(new URL(document.referrer).origin); } catch (error) { - console.warn( + logger.warn( '[postMessage] Failed to parse parent origin from referrer', - error, + error instanceof Error ? error : { error }, ); } } @@ -112,17 +145,16 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { const handleMessage = async (event: MessageEvent) => { if (!isTrustedOrigin(event.origin)) { - console.warn( - '[postMessage] Blocked message from untrusted origin', - event.origin, - ); + logger.warn('[postMessage] Blocked message from untrusted origin', { + origin: event.origin, + }); return; } if (event.data === 'getLocation') { - event.source?.postMessage( + (event.source as WindowProxy)?.postMessage( { iframeLocation: window.location.pathname }, - event.origin, + { targetOrigin: event.origin }, ); return; } @@ -132,9 +164,9 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { sessionStorage.getItem('token') || localStorage.getItem('token'); const user = sessionStorage.getItem('user') || localStorage.getItem('user'); - event.source?.postMessage( + (event.source as WindowProxy)?.postMessage( { iframeAuthToken: token, iframeAuthUser: user }, - event.origin, + { targetOrigin: event.origin }, ); return; } @@ -144,10 +176,19 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { const html2canvas = (await import('html2canvas')).default; const canvas = await html2canvas(document.body, { useCORS: true }); const url = canvas.toDataURL('image/jpeg', 0.8); - event.source?.postMessage({ iframeScreenshot: url }, event.origin); + (event.source as WindowProxy)?.postMessage( + { iframeScreenshot: url }, + { targetOrigin: event.origin }, + ); } catch (e) { - console.error('html2canvas failed', e); - event.source?.postMessage({ iframeScreenshot: null }, event.origin); + logger.error( + 'html2canvas failed', + e instanceof Error ? e : { error: e }, + ); + (event.source as WindowProxy)?.postMessage( + { iframeScreenshot: null }, + { targetOrigin: event.origin }, + ); } } }; @@ -211,43 +252,52 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { return ( - {getLayout( - <> - - + + {getLayout( + <> + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - {(process.env.NODE_ENV === 'development' || - process.env.NODE_ENV === 'dev_stage') && } - , - )} + + + + + {(process.env.NODE_ENV === 'development' || + (process.env.NODE_ENV as string) === 'dev_stage') && ( + + )} + , + )} + ); } diff --git a/frontend/src/pages/access_logs/[access_logsId].tsx b/frontend/src/pages/access_logs/[access_logsId].tsx index 6b94541..5c39fe4 100644 --- a/frontend/src/pages/access_logs/[access_logsId].tsx +++ b/frontend/src/pages/access_logs/[access_logsId].tsx @@ -31,16 +31,17 @@ import { useRouter } from 'next/router'; import { saveFile } from '../../helpers/fileSaver'; import dataFormatter from '../../helpers/dataFormatter'; import ImageField from '../../components/ImageField'; +import type { AccessLog } from '../../types/entities'; const EditAccess_logs = () => { const router = useRouter(); const dispatch = useAppDispatch(); const initVals = { - project: null, + project: null as unknown, - environment: '', + environment: '' as '' | 'admin' | 'stage' | 'production', - user: null, + user: null as unknown, path: '', @@ -55,31 +56,44 @@ const EditAccess_logs = () => { const { access_logs } = useAppSelector((state) => state.access_logs); const { access_logsId } = router.query; + const accessLogIdStr = Array.isArray(access_logsId) + ? access_logsId[0] + : access_logsId; useEffect(() => { - dispatch(fetch({ id: access_logsId })); - }, [access_logsId]); - - useEffect(() => { - if (typeof access_logs === 'object') { - setInitialValues(access_logs); + if (accessLogIdStr) { + dispatch(fetch({ id: accessLogIdStr })); } - }, [access_logs]); + }, [accessLogIdStr, dispatch]); useEffect(() => { - if (typeof access_logs === 'object') { + if ( + access_logs && + typeof access_logs === 'object' && + !Array.isArray(access_logs) + ) { const newInitialVal = { ...initVals }; Object.keys(initVals).forEach( - (el) => (newInitialVal[el] = access_logs[el]), + (el) => + (newInitialVal[el as keyof typeof initVals] = ( + access_logs as Record + )[el] as never), ); setInitialValues(newInitialVal); } }, [access_logs]); - const handleSubmit = async (data) => { - await dispatch(update({ id: access_logsId, data })); + const handleSubmit = async (data: typeof initVals) => { + if (accessLogIdStr) { + await dispatch( + update({ + id: accessLogIdStr, + data: data as unknown as Partial, + }), + ); + } await router.push('/access_logs/access_logs-list'); }; @@ -164,8 +178,11 @@ const EditAccess_logs = () => { ) : null } - onChange={(date) => - setInitialValues({ ...initialValues, accessed_at: date }) + onChange={(date: Date | null) => + setInitialValues({ + ...initialValues, + accessed_at: date || new Date(), + }) } /> diff --git a/frontend/src/pages/access_logs/access_logs-edit.tsx b/frontend/src/pages/access_logs/access_logs-edit.tsx index 67224bc..c5e4deb 100644 --- a/frontend/src/pages/access_logs/access_logs-edit.tsx +++ b/frontend/src/pages/access_logs/access_logs-edit.tsx @@ -26,11 +26,12 @@ import { SelectField } from '../../components/SelectField'; import { update, fetch } from '../../stores/access_logs/access_logsSlice'; import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import { useRouter } from 'next/router'; +import type { AccessLog } from '../../types/entities'; const initVals = { - project: null, - environment: '', - user: null, + project: null as unknown, + environment: '' as '' | 'admin' | 'stage' | 'production', + user: null as unknown, path: '', ip_address: '', user_agent: '', @@ -66,7 +67,9 @@ const EditAccess_logsPage = () => { }, [access_logs]); const handleSubmit = async (data: typeof initVals) => { - await dispatch(update({ id: id as string, data })); + await dispatch( + update({ id: id as string, data: data as unknown as Partial }), + ); await router.push('/access_logs/access_logs-list'); }; diff --git a/frontend/src/pages/access_logs/access_logs-list.tsx b/frontend/src/pages/access_logs/access_logs-list.tsx index 24eed46..af7e0bc 100644 --- a/frontend/src/pages/access_logs/access_logs-list.tsx +++ b/frontend/src/pages/access_logs/access_logs-list.tsx @@ -8,12 +8,13 @@ import { uploadCsv, setRefetch, } from '../../stores/access_logs/access_logsSlice'; +import { Filter } from '../../types/filters'; -const filters = [ +const filters: Filter[] = [ { label: 'Path', title: 'path' }, { label: 'IPaddress', title: 'ip_address' }, { label: 'Useragent', title: 'user_agent' }, - { label: 'Accessedat', title: 'accessed_at', date: 'true' }, + { label: 'Accessedat', title: 'accessed_at', date: true }, { label: 'Project', title: 'project' }, { label: 'User', title: 'user' }, { diff --git a/frontend/src/pages/access_logs/access_logs-new.tsx b/frontend/src/pages/access_logs/access_logs-new.tsx index 9a62854..71849d3 100644 --- a/frontend/src/pages/access_logs/access_logs-new.tsx +++ b/frontend/src/pages/access_logs/access_logs-new.tsx @@ -26,7 +26,7 @@ import { useRouter } from 'next/router'; const initialValues = { project: '', - environment: 'admin', + environment: 'admin' as 'admin' | 'stage' | 'production', user: '', path: '', ip_address: '', diff --git a/frontend/src/pages/access_logs/access_logs-table.tsx b/frontend/src/pages/access_logs/access_logs-table.tsx index 0107cf2..cc718ae 100644 --- a/frontend/src/pages/access_logs/access_logs-table.tsx +++ b/frontend/src/pages/access_logs/access_logs-table.tsx @@ -20,6 +20,7 @@ import { } from '../../stores/access_logs/access_logsSlice'; import { hasPermission } from '../../helpers/userPermissions'; +import { Filter } from '../../types/filters'; const Access_logsTablesPage = () => { const [filterItems, setFilterItems] = useState([]); @@ -31,12 +32,12 @@ const Access_logsTablesPage = () => { const dispatch = useAppDispatch(); - const [filters] = useState([ + const [filters] = useState([ { label: 'Path', title: 'path' }, { label: 'IPaddress', title: 'ip_address' }, { label: 'Useragent', title: 'user_agent' }, - { label: 'Accessedat', title: 'accessed_at', date: 'true' }, + { label: 'Accessedat', title: 'accessed_at', date: true }, { label: 'Project', title: 'project' }, diff --git a/frontend/src/pages/access_logs/access_logs-view.tsx b/frontend/src/pages/access_logs/access_logs-view.tsx index ecc1608..552b423 100644 --- a/frontend/src/pages/access_logs/access_logs-view.tsx +++ b/frontend/src/pages/access_logs/access_logs-view.tsx @@ -6,9 +6,6 @@ import dayjs from 'dayjs'; import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import { useRouter } from 'next/router'; import { fetch } from '../../stores/access_logs/access_logsSlice'; -import { saveFile } from '../../helpers/fileSaver'; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from '../../components/ImageField'; import LayoutAuthenticated from '../../layouts/Authenticated'; import { getPageTitle } from '../../config'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; @@ -17,23 +14,32 @@ import CardBox from '../../components/CardBox'; import BaseButton from '../../components/BaseButton'; import BaseDivider from '../../components/BaseDivider'; import { mdiChartTimelineVariant } from '@mdi/js'; -import { SwitchField } from '../../components/SwitchField'; import FormField from '../../components/FormField'; +import type { AccessLog } from '../../types/entities'; const Access_logsView = () => { const router = useRouter(); const dispatch = useAppDispatch(); - const { access_logs } = useAppSelector((state) => state.access_logs); + 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; const { id } = router.query; + const idStr = Array.isArray(id) ? id[0] : id; - function removeLastCharacter(str) { + function removeLastCharacter(str: string) { return str.slice(0, -1); } useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); + if (idStr) { + dispatch(fetch({ id: idStr })); + } + }, [dispatch, idStr]); return ( <> @@ -56,49 +62,53 @@ const Access_logsView = () => {

Project

-

{access_logs?.project?.name ?? 'No data'}

+

+ {(accessLog?.project as { name?: string } | undefined)?.name ?? + 'No data'} +

Environment

-

{access_logs?.environment ?? 'No data'}

+

{accessLog?.environment ?? 'No data'}

User

-

{access_logs?.user?.firstName ?? 'No data'}

+

+ {(accessLog?.user as { firstName?: string } | undefined) + ?.firstName ?? 'No data'} +

Path

-

{access_logs?.path}

+

{accessLog?.path}

IPaddress

-

{access_logs?.ip_address}

+

{accessLog?.ip_address}