revers transitions and preloading

This commit is contained in:
Dmitri 2026-03-24 08:20:27 +04:00
parent e8f72cb390
commit 4c41205225
167 changed files with 10818 additions and 3364 deletions

View File

@ -10,6 +10,7 @@ module.exports = {
'import'
],
rules: {
'import/no-unresolved': 'error'
'import/no-unresolved': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
};

View File

@ -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}`;

View File

@ -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);
}

View File

@ -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' });

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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',
},
},
],
};

View File

@ -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
export default withSerwist(nextConfig);

View File

@ -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"
}

View File

@ -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"
}

File diff suppressed because one or more lines are too long

View File

@ -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<GridColDef[]> => {
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,
},
{

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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', {

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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',

View File

@ -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);

View File

@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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<ErrorBoundaryProps, ErrorBoundaryState> {
// 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<ErrorBoundaryProps, ErrorBoundaryState> {
});
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<ErrorBoundaryProps, ErrorBoundaryState> {
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<ErrorBoundaryProps, ErrorBoundaryState> {
});
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 },
);
}
}

View File

@ -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<T> {
[key: string]: T[] | boolean | number | NotificationState | unknown[];
loading: boolean;
count: number;
refetch: boolean;
notify: NotificationState;
}
// Props interface for GenericTable
interface GenericTableProps<T extends BaseEntity> {
entityName: string;
sliceSelector: (state: RootState) => EntitySliceState<T>;
fetchAction: AsyncThunk<
T | { rows: T[]; count: number },
{ id?: string; query?: string },
object
>;
updateAction: AsyncThunk<T, { id: string; data: Partial<T> }, object>;
deleteAction: AsyncThunk<void, string, object>;
deleteByIdsAction?: AsyncThunk<void, string[], object>;
setRefetchAction: (refetch: boolean) => { type: string; payload: boolean };
loadColumnsFunction: (
onDelete: (id: string) => void,
entityName: string,
user: unknown,
) => Promise<GridColDef[]>;
filters: Filter[];
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
extraQuery?: string;
}
function GenericTable<T extends BaseEntity>({
entityName,
sliceSelector,
fetchAction,
updateAction,
deleteAction,
deleteByIdsAction,
setRefetchAction,
loadColumnsFunction,
filters,
filterItems,
setFilterItems,
extraQuery = '',
}: GenericTableProps<T>) {
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<GridColDef[]>([]);
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const [rowSelectionModel, setRowSelectionModel] =
useState<GridRowSelectionModel>([]);
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const [itemIdToDelete, setItemIdToDelete] = useState<string | null>(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<GridRowModel> => {
await dispatch(
updateAction({ id: newRow.id as string, data: newRow as Partial<T> }),
);
return newRow;
};
const handleProcessRowUpdateError = (error: Error) => {
toast.error(`Error updating row: ${error.message}`);
};
// Filter handlers
const handleFilterChange =
(id: string) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
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 && (
<CardBox className='mb-4'>
<Formik
initialValues={{}}
onSubmit={() => {
/* Filters are applied on change, not submit */
}}
>
<Form>
{filterItems.map((filterItem) => (
<div key={filterItem.id} className='flex mb-4 gap-3'>
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold text-sm'>
Filter
</div>
<Field
className={controlClasses}
name='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleFilterChange(filterItem.id)}
>
<option value=''>Select field</option>
{filters.map((filter) => (
<option key={filter.title} value={filter.title}>
{filter.label}
</option>
))}
</Field>
</div>
{/* Enum filter */}
{filters.find(
(f) => f.title === filterItem?.fields?.selectedField,
)?.type === 'enum' ? (
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold text-sm'>
Value
</div>
<Field
className={controlClasses}
name='filterValue'
component='select'
value={filterItem?.fields?.filterValue || ''}
onChange={handleFilterChange(filterItem.id)}
>
<option value=''>Select value</option>
{filters
.find(
(f) =>
f.title === filterItem?.fields?.selectedField,
)
?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : (
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold text-sm'>
Contains
</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Filter value'
value={filterItem?.fields?.filterValue || ''}
onChange={handleFilterChange(filterItem.id)}
/>
</div>
)}
<div className='flex flex-col'>
<div className='text-gray-500 font-bold text-sm'>
Action
</div>
<BaseButton
className='my-2'
type='button'
color='danger'
label='Delete'
onClick={() => deleteFilter(filterItem.id)}
/>
</div>
</div>
))}
<div className='flex gap-3'>
<BaseButton
className='my-2'
color='info'
label='Reset Filters'
onClick={handleResetFilters}
/>
</div>
</Form>
</Formik>
</CardBox>
)}
{/* Bulk Actions */}
{rowSelectionModel.length > 0 && deleteByIdsAction && (
<div className='mb-4 flex gap-2'>
<BaseButton
color='danger'
label={`Delete Selected (${rowSelectionModel.length})`}
onClick={handleBulkDelete}
/>
</div>
)}
{/* Data Grid */}
<CardBox className='mb-6 overflow-hidden'>
<div style={{ width: '100%', minHeight: 400 }}>
<DataGrid
rows={rows as GridRowsProp}
columns={columns}
rowCount={count}
loading={loading}
pageSizeOptions={[5, 10, 25, 50]}
paginationModel={paginationModel}
paginationMode='server'
onPaginationModelChange={setPaginationModel}
sortingMode='server'
sortModel={sortModel}
onSortModelChange={setSortModel}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={setRowSelectionModel}
editMode='row'
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onRowEditStop={handleRowEditStop}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={handleProcessRowUpdateError}
getRowId={(row) => row.id}
sx={{
border: 'none',
'& .MuiDataGrid-cell': {
borderColor: 'rgba(0,0,0,0.1)',
},
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'rgba(0,0,0,0.02)',
},
}}
/>
</div>
</CardBox>
<ToastContainer />
</>
);
}
export default GenericTable;

View File

@ -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 (
<div className={className}>
{imageSrc ? (
<img
src={imageSrc}
alt={name}
className={`rounded-full block h-auto w-full max-w-full bg-gray-100 dark:bg-dark-900 ${imageClassName}`}
/>
<div className='relative w-full h-full'>
<Image
src={imageSrc}
alt={name}
fill
sizes='100vw'
className={`rounded-full object-cover bg-gray-100 dark:bg-dark-900 ${imageClassName}`}
/>
</div>
) : (
<div className={'flex h-full bg-slate-100 dark:bg-dark-900/70'}>
<BaseIcon

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useEffect } from 'react';
import Link from 'next/link';
import moment from 'moment';
import ListActionsPopover from '../ListActionsPopover';
@ -19,6 +19,7 @@ const KanbanCard = ({
setItemIdToDelete,
column,
}: Props) => {
const cardRef = useRef<HTMLDivElement>(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 (
<div
ref={drag}
ref={cardRef}
className={`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
>
<div className={'flex items-center justify-between'}>

View File

@ -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('');

View File

@ -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 (
<div
className={`bg-white border border-gray-200 rounded-lg shadow-lg p-3 min-w-[280px] ${className}`}
>
{/* Header */}
<div className='flex items-center justify-between mb-2 pb-2 border-b border-gray-100'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium text-gray-700'>Downloads</span>
{isActive && (
<span className='text-xs text-blue-600'>{activeCount} active</span>
)}
</div>
<div className='flex items-center gap-1'>
{completedCount > 0 && (
<button
onClick={clearAllCompleted}
className='text-xs text-gray-500 hover:text-gray-700'
>
Clear completed
</button>
)}
{errorCount > 0 && (
<button
onClick={clearAllErrors}
className='text-xs text-red-500 hover:text-red-700 ml-2'
>
Clear errors
</button>
)}
</div>
</div>
{/* Overall progress */}
{isActive && (
<div className='mb-3'>
<div className='flex items-center justify-between text-xs text-gray-500 mb-1'>
<span>Overall progress</span>
<span>{totalProgress}%</span>
</div>
<div className='w-full h-1.5 bg-gray-200 rounded-full overflow-hidden'>
<div
className='h-full bg-blue-500 transition-all duration-300'
style={{ width: `${totalProgress}%` }}
/>
</div>
</div>
)}
{/* Job list */}
<div className='space-y-2'>
{visibleJobs.map((job) => (
<div
key={job.id}
className='flex items-center gap-2 p-2 bg-gray-50 rounded'
>
{/* Status icon */}
<div className='flex-shrink-0'>
{job.status === 'downloading' && (
<Icon
path={mdiLoading}
size={0.7}
className='text-blue-500 animate-spin'
/>
)}
{job.status === 'completed' && (
<Icon path={mdiCheck} size={0.7} className='text-green-500' />
)}
{job.status === 'error' && (
<Icon
path={mdiAlertCircle}
size={0.7}
className='text-red-500'
/>
)}
{job.status === 'queued' && (
<Icon path={mdiPause} size={0.7} className='text-gray-400' />
)}
</div>
{/* Info */}
<div className='flex-1 min-w-0'>
<div className='text-xs font-medium text-gray-700 truncate'>
{job.filename}
</div>
{job.status === 'downloading' && (
<div className='w-full h-1 bg-gray-200 rounded-full mt-1 overflow-hidden'>
<div
className='h-full bg-blue-500 transition-all duration-300'
style={{ width: `${job.progress}%` }}
/>
</div>
)}
{job.status === 'error' && job.error && (
<div className='text-xs text-red-500 truncate'>{job.error}</div>
)}
</div>
{/* Progress/size */}
<div className='flex-shrink-0 text-xs text-gray-500'>
{job.status === 'downloading' && `${job.progress}%`}
{job.status === 'completed' && formatBytes(job.totalBytes)}
</div>
{/* Close button */}
<button
onClick={() => clearJob(job.id)}
className='flex-shrink-0 p-0.5 text-gray-400 hover:text-gray-600'
>
<Icon path={mdiClose} size={0.5} />
</button>
</div>
))}
{hiddenCount > 0 && (
<div className='text-xs text-gray-500 text-center py-1'>
+{hiddenCount} more items
</div>
)}
{jobs.length === 0 && showWhenEmpty && (
<div className='text-xs text-gray-400 text-center py-2'>
No active downloads
</div>
)}
</div>
</div>
);
}
/**
* 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;

View File

@ -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 (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${bgClass} ${className}`}
title={label}
>
<Icon
path={icon}
size={0.6}
className={`${colorClass} ${isActive ? 'animate-pulse' : ''}`}
/>
{showLabel && (
<span className={`text-xs font-medium ${colorClass}`}>{label}</span>
)}
</div>
);
}
export default OfflineStatusIndicator;

View File

@ -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 (
<div className={`flex items-center gap-2 ${className}`}>
<BaseButton
small={size === 'small'}
color={color}
icon={icon}
label={showLabel ? `${label}${sizeLabel}` : undefined}
onClick={handleClick}
disabled={!canStore(estimatedSize) && !isDownloaded}
/>
{isDownloaded && (
<button
onClick={() => {
if (confirm('Remove offline data for this project?')) {
deleteOfflineData();
}
}}
className='p-1 text-gray-400 hover:text-red-500 transition-colors'
title='Remove offline data'
>
<Icon path={mdiDelete} size={0.7} />
</button>
)}
</div>
);
}
export default OfflineToggle;

View File

@ -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 (
<div className={`animate-pulse ${className}`}>
<div className='h-4 bg-gray-200 rounded w-32' />
</div>
);
}
// 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 (
<div
className={`flex items-center gap-2 ${className}`}
title={`Storage: ${formatSize(usage)} / ${formatSize(quota)}`}
>
<Icon path={mdiDatabase} size={0.6} className={textColor} />
<div className='w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden'>
<div
className={`h-full ${barColor} transition-all duration-300`}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
/>
</div>
<span className={`text-xs ${textColor}`}>
{Math.round(percentUsed)}%
</span>
</div>
);
}
return (
<div className={`space-y-2 ${className}`}>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Icon path={mdiDatabase} size={0.7} className='text-gray-500' />
<span className='text-sm font-medium text-gray-700'>Storage</span>
</div>
{showDetails && (
<span className={`text-xs ${textColor}`}>
{formatSize(usage)} / {formatSize(quota)}
</span>
)}
</div>
{/* Progress bar */}
<div className='w-full h-2 bg-gray-200 rounded-full overflow-hidden'>
<div
className={`h-full ${barColor} transition-all duration-300`}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
/>
</div>
{/* Status messages */}
{showDetails && (
<div className='flex items-center justify-between text-xs'>
<span className={textColor}>
{isCritical
? 'Storage critically low'
: isWarning
? 'Storage running low'
: `${Math.round(100 - percentUsed)}% available`}
</span>
{!isPersisted && (
<button
onClick={requestPersistence}
className='text-blue-500 hover:text-blue-700'
>
Make persistent
</button>
)}
{isPersisted && (
<span className='text-green-600'>Persistent storage enabled</span>
)}
</div>
)}
</div>
);
}
export default StorageUsageDisplay;

View File

@ -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';

View File

@ -89,7 +89,15 @@ const TablePage_elements: React.FC<TablePage_elementsProps> = ({
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 } };
}),
);

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<TablePermissionsProps> = ({
filters,
}) => {
return (
<GenericTable<Permission>
<GenericTable<PermissionEntity>
entityName='permissions'
sliceSelector={(state: RootState) => state.permissions}
fetchAction={fetch}

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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,

View File

@ -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('');
}

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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<GridColDef[]> => {
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',

View File

@ -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 (
<div id='profilEdit' className={className}>
{avatarImage === '#' ? (
{!avatarImage ? (
<BaseIcon
path={mdiAccountCircleOutline}
size={30}
@ -36,11 +31,15 @@ export default function UserAvatar({
}
/>
) : (
<img
src={avatarImage}
alt={username}
className='rounded-full block h-auto w-full max-w-full bg-gray-100 dark:bg-slate-800'
/>
<div className='relative w-full h-full'>
<Image
src={avatarImage}
alt={username}
fill
sizes='100vw'
className='rounded-full object-cover bg-gray-100 dark:bg-slate-800'
/>
</div>
)}
{children}
</div>

View File

@ -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<GridColDef[]> => {
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) => (
<ImageField
name={'Avatar'}
image={params?.row?.avatar}
@ -132,11 +129,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('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',

View File

@ -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;

View File

@ -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<string, number>,
variant: {
thumbnail: 50,
preview: 40,
webp: 35,
mp4_low: 20,
mp4_high: 10,
original: 5,
} as Record<string, number>,
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;

View File

@ -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<DownloadContextValue | null>(null);
// Helper to update a job in the array
const updateJobInArray = (
jobs: PreloadJob[],
jobId: string,
updates: Partial<PreloadJob>,
): 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<PreloadJob[]>([]);
// 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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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<DownloadContextValue>(() => {
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 (
<DownloadContext.Provider value={value}>
{children}
</DownloadContext.Provider>
);
}
/**
* 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);
}

View File

@ -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<T>(
);
case 'select':
// Dynamic import to avoid circular dependencies
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { SelectField } = require('../components/SelectField');
return (
<Field
name={name}
@ -256,8 +257,6 @@ function renderField<T>(
);
case 'selectMany':
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { SelectFieldMany } = require('../components/SelectFieldMany');
return (
<Field
name={name}
@ -270,13 +269,9 @@ function renderField<T>(
);
case 'switch':
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { SwitchField } = require('../components/SwitchField');
return <Field name={name} id={name} component={SwitchField} />;
case 'image':
// eslint-disable-next-line @typescript-eslint/no-var-requires
const FormImagePicker = require('../components/FormImagePicker').default;
return (
<Field
label={field.label}

View File

@ -1,75 +0,0 @@
import axios from 'axios';
export async function getPexelsImage() {
try {
const response = await axios.get(`/pexels/image`);
return response.data;
} catch (error) {
console.error('Error fetching image:', error);
return null;
}
}
export async function getPexelsVideo() {
try {
const response = await axios.get(`/pexels/video`);
return response.data;
} catch (error) {
console.error('Error fetching video:', error);
return null;
}
}
let localStorageLock = false;
export async function getMultiplePexelsImages(
queries = ['home', 'apple', 'pizza', 'mountains', 'cat'],
) {
const normalizeQuery = (query) =>
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;
}

View File

@ -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);
}

View File

@ -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<string, string[]>;
}
/**
* 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<string, unknown>, 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<string, unknown>, 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<string, string[]>();
// 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<string>();
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<string>();
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<string, AssetInfo>();
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,
};
}

View File

@ -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<NetworkInfo>(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(),
};
}

View File

@ -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<void>;
pauseDownload: () => void;
resumeDownload: () => void;
cancelDownload: () => void;
deleteOfflineData: () => Promise<void>;
checkForUpdates: () => Promise<boolean>;
// 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<OfflineProject | null>(null);
const [manifest, setManifest] = useState<OfflineManifest | null>(null);
const [status, setStatus] = useState<ProjectOfflineStatus>('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<string | null>(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<typeof downloadEventBus.on>[1],
);
}, [projectId]);
// Fetch manifest from backend
const fetchManifest =
useCallback(async (): Promise<OfflineManifest | null> => {
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<void> => {
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<boolean> => {
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,
};
}

View File

@ -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<string>;
queueLength: number;
preloadAsset: (url: string, priority?: number) => void;
clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>;
}
/**
* 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<boolean> => {
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<void> => {
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<void> => {
// 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<string>());
const [queueLength, setQueueLength] = useState(0);
const queueRef = useRef<PreloadQueueItem[]>([]);
const activeDownloadsRef = useRef(0);
const isProcessingRef = useRef(false);
const lastPreloadedPageRef = useRef<string | null>(null);
const lastPreloadedLinksCountRef = useRef<number>(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<string | null> => {
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<boolean> => {
// 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,
};
}

View File

@ -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>,
): 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<PreloadJob[]>([]);
// 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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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<typeof downloadEventBus.on>[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,
};
}

View File

@ -0,0 +1,239 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type RefObject,
} from 'react';
interface UseReversePlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>;
duration: number;
onComplete: () => void;
preloadedUrls?: Set<string>;
videoUrl?: string;
}
interface UseReversePlaybackResult {
startReverse: () => Promise<void>;
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<void> {
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<number | null>
): Promise<void> {
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<number | null>
): Promise<void> {
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<number | null>(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 };
}

View File

@ -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<void>;
requestPersistence: () => Promise<boolean>;
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<StorageQuotaInfo>({
usage: 0,
quota: Infinity,
percentUsed: 0,
available: Infinity,
canStore: () => true,
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<boolean> => {
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,
};
}

View File

@ -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 },
);
}
};

View File

@ -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)}`;
};

163
frontend/src/lib/logger.ts Normal file
View File

@ -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<LogLevel, number> = {
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 };

View File

@ -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<T> = (data: T) => void;
class DownloadEventBusClass {
private listeners: Map<string, Set<EventCallback<unknown>>> = new Map();
/**
* Subscribe to an event
*/
on<K extends keyof EventMap>(
event: K,
callback: EventCallback<EventMap[K]>,
): () => 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<unknown>);
}
// Return unsubscribe function
return () => this.off(event, callback);
}
/**
* Unsubscribe from an event
*/
off<K extends keyof EventMap>(
event: K,
callback: EventCallback<EventMap[K]>,
): void {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.delete(callback as EventCallback<unknown>);
}
}
/**
* Emit an event to all subscribers
*/
emit<K extends keyof EventMap>(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<K extends keyof EventMap>(
event: K,
callback: EventCallback<EventMap[K]>,
): () => void {
const wrappedCallback: EventCallback<EventMap[K]> = (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();

View File

@ -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<string, DownloadJob> = 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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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<StorageQuotaInfo> {
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<boolean> {
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<void> {
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<Blob | null> {
// 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<boolean> {
// 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<void> {
// 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<void> {
// 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<number> {
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<void> {
// 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<void> {
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);
});
}
}

View File

@ -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<void> {
await offlineDb.assets.put(asset);
}
/**
* Get an asset by ID
*/
static async getAsset(id: string): Promise<OfflineAsset | undefined> {
return offlineDb.assets.get(id);
}
/**
* Get an asset by URL
*/
static async getAssetByUrl(url: string): Promise<OfflineAsset | undefined> {
return offlineDb.assets.where('url').equals(url).first();
}
/**
* Get all assets for a project
*/
static async getProjectAssets(projectId: string): Promise<OfflineAsset[]> {
return offlineDb.assets.where('projectId').equals(projectId).toArray();
}
/**
* Delete an asset
*/
static async deleteAsset(id: string): Promise<void> {
await offlineDb.assets.delete(id);
}
/**
* Delete all assets for a project
*/
static async deleteProjectAssets(projectId: string): Promise<number> {
return offlineDb.assets.where('projectId').equals(projectId).delete();
}
/**
* Check if an asset exists
*/
static async hasAsset(id: string): Promise<boolean> {
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<boolean> {
const count = await offlineDb.assets.where('url').equals(url).count();
return count > 0;
}
/**
* Get total storage used by assets
*/
static async getTotalAssetsSize(): Promise<number> {
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<number> {
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<void> {
await offlineDb.projects.put(project);
}
/**
* Get project by ID
*/
static async getProject(id: string): Promise<OfflineProject | undefined> {
return offlineDb.projects.get(id);
}
/**
* Get project by slug
*/
static async getProjectBySlug(
slug: string,
): Promise<OfflineProject | undefined> {
return offlineDb.projects.where('slug').equals(slug).first();
}
/**
* Get all offline projects
*/
static async getAllProjects(): Promise<OfflineProject[]> {
return offlineDb.projects.toArray();
}
/**
* Update project status
*/
static async updateProjectStatus(
id: string,
status: ProjectOfflineStatus,
): Promise<void> {
await offlineDb.projects.update(id, { status });
}
/**
* Update project download progress
*/
static async updateProjectProgress(
id: string,
downloadedAssets: number,
downloadedSizeBytes: number,
): Promise<void> {
await offlineDb.projects.update(id, {
downloadedAssets,
downloadedSizeBytes,
});
}
/**
* Delete project and all its assets
*/
static async deleteProject(id: string): Promise<void> {
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<void> {
await offlineDb.downloadQueue.put(item);
}
/**
* Get queue item by ID
*/
static async getQueueItem(
id: string,
): Promise<DownloadQueueItem | undefined> {
return offlineDb.downloadQueue.get(id);
}
/**
* Get all queued items for a project
*/
static async getProjectQueue(
projectId: string,
): Promise<DownloadQueueItem[]> {
return offlineDb.downloadQueue
.where('projectId')
.equals(projectId)
.sortBy('priority');
}
/**
* Get pending queue items (sorted by priority)
*/
static async getPendingQueue(): Promise<DownloadQueueItem[]> {
return offlineDb.downloadQueue
.where('status')
.equals('queued')
.sortBy('priority');
}
/**
* Update queue item status
*/
static async updateQueueStatus(
id: string,
status: PreloadJobStatus,
error?: string,
): Promise<void> {
const updates: Partial<DownloadQueueItem> = {
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<void> {
await offlineDb.downloadQueue.update(id, { bytesLoaded, totalBytes });
}
/**
* Increment retry count
*/
static async incrementRetry(id: string): Promise<number> {
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<void> {
await offlineDb.downloadQueue.delete(id);
}
/**
* Clear project download queue
*/
static async clearProjectQueue(projectId: string): Promise<number> {
return offlineDb.downloadQueue
.where('projectId')
.equals(projectId)
.delete();
}
/**
* Clear entire download queue
*/
static async clearQueue(): Promise<void> {
await offlineDb.downloadQueue.clear();
}
/**
* Reset failed queue items to queued status
*/
static async resetFailedItems(projectId?: string): Promise<number> {
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<void> {
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 };
}
}

View File

@ -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<OfflineAsset, 'id'>;
projects!: EntityTable<OfflineProject, 'id'>;
downloadQueue!: EntityTable<DownloadQueueItem, 'id'>;
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 };

View File

@ -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',
},
{

View File

@ -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<string>([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 (
<Provider store={store}>
{getLayout(
<>
<Head>
<meta name='description' content={description} />
<DownloadProvider>
{getLayout(
<>
<Head>
<meta name='description' content={description} />
<meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:image' content={image} />
<meta property='og:image:type' content='image/png' />
<meta property='og:image:width' content={imageWidth} />
<meta property='og:image:height' content={imageHeight} />
<meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:title' content={title} />
<meta property='twitter:description' content={description} />
<meta property='twitter:image:src' content={image} />
<meta property='twitter:image:width' content={imageWidth} />
<meta property='twitter:image:height' content={imageHeight} />
<link rel='icon' href='/favicon.svg' />
<link rel='manifest' href='/manifest.json' />
<meta name='theme-color' content='#3B82F6' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta
name='apple-mobile-web-app-status-bar-style'
content='default'
/>
<meta name='apple-mobile-web-app-title' content='Tour Builder' />
</Head>
<meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:image' content={image} />
<meta property='og:image:type' content='image/png' />
<meta property='og:image:width' content={imageWidth} />
<meta property='og:image:height' content={imageHeight} />
<meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:title' content={title} />
<meta property='twitter:description' content={description} />
<meta property='twitter:image:src' content={image} />
<meta property='twitter:image:width' content={imageWidth} />
<meta property='twitter:image:height' content={imageHeight} />
<link rel='icon' href='/favicon.svg' />
</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide
steps={steps}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
</>,
)}
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide
steps={steps}
stepsName={stepName}
stepsEnabled={stepsEnabled}
onExit={handleExit}
/>
{(process.env.NODE_ENV === 'development' ||
(process.env.NODE_ENV as string) === 'dev_stage') && (
<DevModeBadge />
)}
</>,
)}
</DownloadProvider>
</Provider>
);
}

View File

@ -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<string, unknown>
)[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<AccessLog>,
}),
);
}
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(),
})
}
/>
</FormField>

View File

@ -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<AccessLog> }),
);
await router.push('/access_logs/access_logs-list');
};

View File

@ -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' },
{

View File

@ -26,7 +26,7 @@ import { useRouter } from 'next/router';
const initialValues = {
project: '',
environment: 'admin',
environment: 'admin' as 'admin' | 'stage' | 'production',
user: '',
path: '',
ip_address: '',

View File

@ -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<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' },

View File

@ -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 = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Project</p>
<p>{access_logs?.project?.name ?? 'No data'}</p>
<p>
{(accessLog?.project as { name?: string } | undefined)?.name ??
'No data'}
</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Environment</p>
<p>{access_logs?.environment ?? 'No data'}</p>
<p>{accessLog?.environment ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>User</p>
<p>{access_logs?.user?.firstName ?? 'No data'}</p>
<p>
{(accessLog?.user as { firstName?: string } | undefined)
?.firstName ?? 'No data'}
</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Path</p>
<p>{access_logs?.path}</p>
<p>{accessLog?.path}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>IPaddress</p>
<p>{access_logs?.ip_address}</p>
<p>{accessLog?.ip_address}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={access_logs?.user_agent}
value={accessLog?.user_agent ?? ''}
/>
</FormField>
<FormField label='Accessedat'>
{access_logs.accessed_at ? (
{accessLog?.accessed_at ? (
<DatePicker
dateFormat='yyyy-MM-dd hh:mm'
showTimeSelect
selected={
access_logs.accessed_at
accessLog.accessed_at
? new Date(
dayjs(access_logs.accessed_at).format(
'YYYY-MM-DD hh:mm',
),
dayjs(accessLog.accessed_at).format('YYYY-MM-DD hh:mm'),
)
: null
}

View File

@ -31,53 +31,64 @@ import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { AssetVariant } from '../../types/entities';
const initVals = {
asset: null as AssetVariant['asset'] | null,
variant_type: '',
cdn_url: '',
width_px: '',
height_px: '',
size_mb: '',
};
const EditAsset_variants = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
asset: null,
variant_type: '',
cdn_url: '',
width_px: '',
height_px: '',
size_mb: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const { asset_variants } = useAppSelector((state) => state.asset_variants);
const assetVariantsState = useAppSelector((state) => state.asset_variants);
const asset_variants = assetVariantsState.asset_variants as
| AssetVariant
| AssetVariant[]
| undefined;
const assetVariant = Array.isArray(asset_variants)
? asset_variants[0]
: asset_variants;
const { asset_variantsId } = router.query;
const asset_variantsId = Array.isArray(router.query.asset_variantsId)
? router.query.asset_variantsId[0]
: router.query.asset_variantsId;
useEffect(() => {
dispatch(fetch({ id: asset_variantsId }));
}, [asset_variantsId]);
useEffect(() => {
if (typeof asset_variants === 'object') {
setInitialValues(asset_variants);
if (asset_variantsId) {
dispatch(fetch({ id: asset_variantsId }));
}
}, [asset_variants]);
}, [asset_variantsId, dispatch]);
useEffect(() => {
if (typeof asset_variants === 'object') {
if (assetVariant && typeof assetVariant === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach(
(el) => (newInitialVal[el] = asset_variants[el]),
);
Object.keys(initVals).forEach((el) => {
if (el in assetVariant) {
(newInitialVal as Record<string, unknown>)[el] = (
assetVariant as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [asset_variants]);
}, [assetVariant]);
const handleSubmit = async (data) => {
await dispatch(update({ id: asset_variantsId, data }));
const handleSubmit = async (data: typeof initVals) => {
if (asset_variantsId) {
await dispatch(
update({
id: asset_variantsId,
data: data as unknown as Partial<AssetVariant>,
}),
);
}
await router.push('/asset_variants/asset_variants-list');
};

View File

@ -23,9 +23,10 @@ import { SelectField } from '../../components/SelectField';
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { AssetVariant } from '../../types/entities';
const initVals = {
asset: null,
asset: null as AssetVariant['asset'] | null,
variant_type: '',
cdn_url: '',
width_px: '',
@ -38,32 +39,47 @@ const EditAsset_variantsPage = () => {
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { asset_variants } = useAppSelector((state) => state.asset_variants);
const assetVariantsState = useAppSelector((state) => state.asset_variants);
const asset_variants = assetVariantsState.asset_variants as
| AssetVariant
| AssetVariant[]
| undefined;
const assetVariant = Array.isArray(asset_variants)
? asset_variants[0]
: asset_variants;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [id, dispatch]);
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof asset_variants === 'object' && asset_variants !== null) {
if (assetVariant && typeof assetVariant === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in asset_variants) {
newInitialVal[key] = asset_variants[key];
if (key in assetVariant) {
(newInitialVal as Record<string, unknown>)[key] = (
assetVariant as unknown as Record<string, unknown>
)[key];
}
});
setInitialValues(newInitialVal);
}
}, [asset_variants]);
}, [assetVariant]);
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/asset_variants/asset_variants-list');
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<AssetVariant> }),
);
await router.push('/asset_variants/asset_variants-list');
}
};
return (

View File

@ -9,11 +9,13 @@ import {
setRefetch,
} from '../../stores/asset_variants/asset_variantsSlice';
const filters = [
import { Filter } from '../../types/filters';
const filters: Filter[] = [
{ label: 'CDNURL', title: 'cdn_url' },
{ label: 'Width(px)', title: 'width_px', number: 'true' },
{ label: 'Height(px)', title: 'height_px', number: 'true' },
{ label: 'Size(MB)', title: 'size_mb', number: 'true' },
{ label: 'Width(px)', title: 'width_px', number: true },
{ label: 'Height(px)', title: 'height_px', number: true },
{ label: 'Size(MB)', title: 'size_mb', number: true },
{ label: 'Asset', title: 'asset' },
{
label: 'Varianttype',

View File

@ -23,9 +23,10 @@ import { SelectField } from '../../components/SelectField';
import { create } from '../../stores/asset_variants/asset_variantsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { AssetVariant } from '../../types/entities';
const initialValues = {
asset: '',
asset: null as AssetVariant['asset'] | null,
variant_type: 'thumbnail',
cdn_url: '',
width_px: '',
@ -38,7 +39,7 @@ const Asset_variantsNew = () => {
const dispatch = useAppDispatch();
const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data));
await dispatch(create(data as unknown as Partial<AssetVariant>));
await router.push('/asset_variants/asset_variants-list');
};

View File

@ -20,6 +20,7 @@ import {
} from '../../stores/asset_variants/asset_variantsSlice';
import { hasPermission } from '../../helpers/userPermissions';
import { Filter } from '../../types/filters';
const Asset_variantsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]);
@ -31,11 +32,11 @@ const Asset_variantsTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([
const [filters] = useState<Filter[]>([
{ label: 'CDNURL', title: 'cdn_url' },
{ label: 'Width(px)', title: 'width_px', number: 'true' },
{ label: 'Height(px)', title: 'height_px', number: 'true' },
{ label: 'Size(MB)', title: 'size_mb', number: 'true' },
{ label: 'Width(px)', title: 'width_px', number: true },
{ label: 'Height(px)', title: 'height_px', number: true },
{ label: 'Size(MB)', title: 'size_mb', number: true },
{ label: 'Asset', title: 'asset' },

View File

@ -19,20 +19,43 @@ import BaseDivider from '../../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js';
import { SwitchField } from '../../components/SwitchField';
import FormField from '../../components/FormField';
import type { AssetVariant } from '../../types/entities';
interface AssetVariantData {
asset?: { name?: string };
variant_type?: string;
cdn_url?: string;
width_px?: number;
height_px?: number;
size_mb?: number;
}
const Asset_variantsView = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { asset_variants } = useAppSelector((state) => state.asset_variants);
const assetVariantsState = useAppSelector((state) => state.asset_variants);
const asset_variants = assetVariantsState.asset_variants as
| AssetVariantData
| AssetVariantData[]
| undefined;
const { id } = router.query;
// Get the single entity (not array) for view page
const entity = Array.isArray(asset_variants)
? asset_variants[0]
: asset_variants;
function removeLastCharacter(str) {
const id = Array.isArray(router.query.id)
? router.query.id[0]
: router.query.id;
function removeLastCharacter(str: string) {
return str.slice(0, -1);
}
useEffect(() => {
dispatch(fetch({ id }));
if (id) {
dispatch(fetch({ id }));
}
}, [dispatch, id]);
return (
@ -56,32 +79,32 @@ const Asset_variantsView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Asset</p>
<p>{asset_variants?.asset?.name ?? 'No data'}</p>
<p>{entity?.asset?.name ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Varianttype</p>
<p>{asset_variants?.variant_type ?? 'No data'}</p>
<p>{entity?.variant_type ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CDNURL</p>
<p>{asset_variants?.cdn_url}</p>
<p>{entity?.cdn_url}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Width(px)</p>
<p>{asset_variants?.width_px || 'No data'}</p>
<p>{entity?.width_px || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Height(px)</p>
<p>{asset_variants?.height_px || 'No data'}</p>
<p>{entity?.height_px || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Size(MB)</p>
<p>{asset_variants?.size_mb || 'No data'}</p>
<p>{entity?.size_mb || 'No data'}</p>
</div>
<BaseDivider />

View File

@ -31,69 +31,65 @@ import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { Asset } from '../../types/entities';
const initVals = {
project: null as Asset['project'] | null,
name: '',
asset_type: 'image' as 'image' | 'video' | 'audio' | 'file',
type: 'general' as Asset['type'],
cdn_url: '',
storage_key: '',
mime_type: '',
size_mb: '',
width_px: '',
height_px: '',
duration_sec: '',
checksum: '',
is_public: false,
is_deleted: false,
deleted_at_time: new Date() as Date | null,
};
const EditAssets = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
project: null,
name: '',
asset_type: '',
type: 'general',
cdn_url: '',
storage_key: '',
mime_type: '',
size_mb: '',
width_px: '',
height_px: '',
duration_sec: '',
checksum: '',
is_public: false,
is_deleted: false,
deleted_at_time: new Date(),
};
const [initialValues, setInitialValues] = useState(initVals);
const { assets } = useAppSelector((state) => state.assets);
const assetsState = useAppSelector((state) => state.assets);
const assets = assetsState.assets as Asset | Asset[] | undefined;
const asset = Array.isArray(assets) ? assets[0] : assets;
const { assetsId } = router.query;
const idStr = Array.isArray(assetsId) ? assetsId[0] : assetsId;
useEffect(() => {
dispatch(fetch({ id: assetsId }));
}, [assetsId]);
useEffect(() => {
if (typeof assets === 'object') {
setInitialValues(assets);
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [assets]);
}, [idStr, dispatch]);
useEffect(() => {
if (typeof assets === 'object') {
if (asset && typeof asset === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => (newInitialVal[el] = assets[el]));
Object.keys(initVals).forEach((el) => {
if (el in asset) {
(newInitialVal as Record<string, unknown>)[el] = (
asset as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [assets]);
}, [asset]);
const handleSubmit = async (data) => {
await dispatch(update({ id: assetsId, data }));
await router.push('/assets/assets-list');
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<Asset> }),
);
await router.push('/assets/assets-list');
}
};
return (
@ -226,7 +222,7 @@ const EditAssets = () => {
)
: null
}
onChange={(date) =>
onChange={(date: Date | null) =>
setInitialValues({
...initialValues,
deleted_at_time: date,

View File

@ -22,12 +22,13 @@ import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/assets/assetsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { Asset } from '../../types/entities';
const initVals = {
project: null,
project: null as Asset['project'] | null,
name: '',
asset_type: '',
type: 'general',
asset_type: 'image' as 'image' | 'video' | 'audio' | 'file',
type: 'general' as Asset['type'],
cdn_url: '',
storage_key: '',
mime_type: '',
@ -38,7 +39,7 @@ const initVals = {
checksum: '',
is_public: false,
is_deleted: false,
deleted_at_time: new Date(),
deleted_at_time: new Date() as Date | null,
};
const EditAssetsPage = () => {
@ -46,35 +47,42 @@ const EditAssetsPage = () => {
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const { assets } = useAppSelector((state) => state.assets);
const assetsState = useAppSelector((state) => state.assets);
const assets = assetsState.assets as Asset | Asset[] | undefined;
const asset = Array.isArray(assets) ? assets[0] : assets;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch asset data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [id, dispatch]);
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof assets === 'object' && assets && !Array.isArray(assets)) {
if (asset && typeof asset === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => {
if (el in assets) {
if (el in asset) {
(newInitialVal as Record<string, unknown>)[el] = (
assets as Record<string, unknown>
asset as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [assets]);
}, [asset]);
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/assets/assets-list');
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<Asset> }),
);
await router.push('/assets/assets-list');
}
};
return (
@ -204,10 +212,10 @@ const EditAssetsPage = () => {
)
: null
}
onChange={(date) =>
onChange={(date: Date | null) =>
setInitialValues({
...initialValues,
deleted_at_time: date || new Date(),
deleted_at_time: date,
})
}
/>

View File

@ -1,6 +1,12 @@
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast, ToastContainer } from 'react-toastify';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
@ -8,10 +14,17 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { fetch as fetchAssets, deleteItem as deleteAsset } from '../../stores/assets/assetsSlice';
import AssetSectionCard, { Asset, AssetSection } from '../../components/Assets/AssetSectionCard';
import {
fetch as fetchAssets,
deleteItem as deleteAsset,
} from '../../stores/assets/assetsSlice';
import AssetSectionCard, {
Asset,
AssetSection,
} from '../../components/Assets/AssetSectionCard';
import { useProjectSelector } from '../../components/Assets/ProjectSelector';
import { useAssetUploader } from '../../components/Assets/useAssetUploader';
import { logger } from '../../lib/logger';
const ASSET_SECTIONS: AssetSection[] = [
{
@ -72,24 +85,22 @@ const AssetsTablesPage = () => {
const [deletingAssetId, setDeletingAssetId] = useState<string>('');
const {
selectedProjectId,
isLoadingProjects,
selectedProjectName,
} = useProjectSelector({ currentUser });
const { selectedProjectId, isLoadingProjects, selectedProjectName } =
useProjectSelector({ currentUser });
const loadAssets = useCallback((projectId: string) => {
if (!projectId) return;
dispatch(fetchAssets({
query: `?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
}));
}, [dispatch]);
const loadAssets = useCallback(
(projectId: string) => {
if (!projectId) return;
dispatch(
fetchAssets({
query: `?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
}),
);
},
[dispatch],
);
const {
uploadingSections,
uploadQueues,
runBatchUpload,
} = useAssetUploader({
const { uploadingSections, uploadQueues, runBatchUpload } = useAssetUploader({
selectedProjectId,
onUploadComplete: () => loadAssets(selectedProjectId),
});
@ -105,24 +116,31 @@ const AssetsTablesPage = () => {
currentUser && hasPermission(currentUser, 'DELETE_ASSETS'),
);
const handleDeleteAsset = useCallback(async (assetId: string) => {
setDeletingAssetId(assetId);
const handleDeleteAsset = useCallback(
async (assetId: string) => {
setDeletingAssetId(assetId);
try {
await dispatch(deleteAsset(assetId)).unwrap();
toast('Asset deleted', { type: 'success', position: 'bottom-center' });
loadAssets(selectedProjectId);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Failed to delete asset:', errorMessage);
toast('Failed to delete asset', {
type: 'error',
position: 'bottom-center',
});
} finally {
setDeletingAssetId('');
}
}, [dispatch, selectedProjectId, loadAssets]);
try {
await dispatch(deleteAsset(assetId)).unwrap();
toast('Asset deleted', { type: 'success', position: 'bottom-center' });
loadAssets(selectedProjectId);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
'Failed to delete asset:',
error instanceof Error ? error : { error: errorMessage },
);
toast('Failed to delete asset', {
type: 'error',
position: 'bottom-center',
});
} finally {
setDeletingAssetId('');
}
},
[dispatch, selectedProjectId, loadAssets],
);
const assetsBySection = useMemo(() => {
return ASSET_SECTIONS.reduce<Record<string, Asset[]>>((acc, section) => {

View File

@ -25,11 +25,13 @@ import { create } from '../../stores/assets/assetsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { Asset } from '../../types/entities';
const initialValues = {
project: '',
name: '',
asset_type: 'image',
type: 'general',
asset_type: 'image' as 'image' | 'video' | 'audio' | 'file',
type: 'general' as Asset['type'],
cdn_url: '',
storage_key: '',
mime_type: '',
@ -48,7 +50,7 @@ const AssetsNew = () => {
const dispatch = useAppDispatch();
const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data));
await dispatch(create(data as unknown as Partial<Asset>));
await router.push('/assets/assets-list');
};

View File

@ -17,6 +17,7 @@ import DragDropFilePicker from '../../components/DragDropFilePicker';
import { setRefetch, uploadCsv } from '../../stores/assets/assetsSlice';
import { hasPermission } from '../../helpers/userPermissions';
import { Filter } from '../../types/filters';
const AssetsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]);
@ -28,17 +29,17 @@ const AssetsTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([
const [filters] = useState<Filter[]>([
{ label: 'Name', title: 'name' },
{ label: 'CDNURL', title: 'cdn_url' },
{ label: 'Storagekey', title: 'storage_key' },
{ label: 'MIMEtype', title: 'mime_type' },
{ label: 'Checksum', title: 'checksum' },
{ label: 'Width(px)', title: 'width_px', number: 'true' },
{ label: 'Height(px)', title: 'height_px', number: 'true' },
{ label: 'Size(MB)', title: 'size_mb', number: 'true' },
{ label: 'Duration(sec)', title: 'duration_sec', number: 'true' },
{ label: 'Deletedat', title: 'deleted_at_time', date: 'true' },
{ label: 'Width(px)', title: 'width_px', number: true },
{ label: 'Height(px)', title: 'height_px', number: true },
{ label: 'Size(MB)', title: 'size_mb', number: true },
{ label: 'Duration(sec)', title: 'duration_sec', number: true },
{ label: 'Deletedat', title: 'deleted_at_time', date: true },
{ label: 'Project', title: 'project' },

View File

@ -19,21 +19,40 @@ import BaseDivider from '../../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js';
import { SwitchField } from '../../components/SwitchField';
import FormField from '../../components/FormField';
import type { Asset, AssetVariant } from '../../types/entities';
// Extended type to handle asset variants from API (includes size_mb not in base type)
interface AssetVariantWithSize extends AssetVariant {
size_mb?: number;
}
// Extended type to handle asset variants relation from API
interface AssetWithVariants extends Asset {
asset_variants_asset?: AssetVariantWithSize[];
}
const AssetsView = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { assets } = useAppSelector((state) => state.assets);
const assetsState = useAppSelector((state) => state.assets);
const assets = assetsState.assets as
| AssetWithVariants
| AssetWithVariants[]
| undefined;
const asset = Array.isArray(assets) ? assets[0] : assets;
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,66 +75,70 @@ const AssetsView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Project</p>
<p>{assets?.project?.name ?? 'No data'}</p>
<p>
{typeof asset?.project === 'object' && asset?.project
? asset.project.name
: 'No data'}
</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{assets?.name}</p>
<p>{asset?.name}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Asset format</p>
<p>{assets?.asset_type ?? 'No data'}</p>
<p>{asset?.asset_type ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Type</p>
<p>{assets?.type ?? 'No data'}</p>
<p>{asset?.type ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CDNURL</p>
<p>{assets?.cdn_url}</p>
<p>{asset?.cdn_url}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Storagekey</p>
<p>{assets?.storage_key}</p>
<p>{asset?.storage_key}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>MIMEtype</p>
<p>{assets?.mime_type}</p>
<p>{asset?.mime_type}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Size(MB)</p>
<p>{assets?.size_mb || 'No data'}</p>
<p>{asset?.size_mb || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Width(px)</p>
<p>{assets?.width_px || 'No data'}</p>
<p>{asset?.width_px || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Height(px)</p>
<p>{assets?.height_px || 'No data'}</p>
<p>{asset?.height_px || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Duration(sec)</p>
<p>{assets?.duration_sec || 'No data'}</p>
<p>{asset?.duration_sec || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Checksum</p>
<p>{assets?.checksum}</p>
<p>{asset?.checksum}</p>
</div>
<FormField label='Ispublic'>
<SwitchField
field={{ name: 'is_public', value: assets?.is_public }}
field={{ name: 'is_public', value: asset?.is_public }}
form={{ setFieldValue: () => null }}
disabled
/>
@ -123,23 +146,21 @@ const AssetsView = () => {
<FormField label='Isdeleted'>
<SwitchField
field={{ name: 'is_deleted', value: assets?.is_deleted }}
field={{ name: 'is_deleted', value: asset?.is_deleted }}
form={{ setFieldValue: () => null }}
disabled
/>
</FormField>
<FormField label='Deletedat'>
{assets.deleted_at_time ? (
{asset?.deleted_at_time ? (
<DatePicker
dateFormat='yyyy-MM-dd hh:mm'
showTimeSelect
selected={
assets.deleted_at_time
asset.deleted_at_time
? new Date(
dayjs(assets.deleted_at_time).format(
'YYYY-MM-DD hh:mm',
),
dayjs(asset.deleted_at_time).format('YYYY-MM-DD hh:mm'),
)
: null
}
@ -172,9 +193,9 @@ const AssetsView = () => {
</tr>
</thead>
<tbody>
{assets.asset_variants_asset &&
Array.isArray(assets.asset_variants_asset) &&
assets.asset_variants_asset.map((item: any) => (
{asset?.asset_variants_asset &&
Array.isArray(asset.asset_variants_asset) &&
asset.asset_variants_asset.map((item) => (
<tr
key={item.id}
onClick={() =>
@ -197,7 +218,7 @@ const AssetsView = () => {
</tbody>
</table>
</div>
{!assets?.asset_variants_asset?.length && (
{!asset?.asset_variants_asset?.length && (
<div className={'text-center py-4'}>No data</div>
)}
</CardBox>

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,10 @@ const Dashboard = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
loading: boolean;
};
async function loadData() {
const entities = [
@ -212,9 +215,12 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
path={
(icon as Record<string, string>)['mdiAccountGroup'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
@ -242,10 +248,13 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
icon.mdiShieldAccountVariantOutline || icon.mdiTable
(icon as Record<string, string>)[
'mdiShieldAccountVariantOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -274,9 +283,14 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
path={
(icon as Record<string, string>)[
'mdiShieldAccountOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
</div>
@ -304,12 +318,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiOfficeBuilding' in icon
? icon['mdiOfficeBuilding' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiOfficeBuilding'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -338,12 +351,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiAccountKey' in icon
? icon['mdiAccountKey' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiAccountKey'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -372,12 +384,13 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiFolderMultipleImage' in icon
? icon['mdiFolderMultipleImage' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)[
'mdiFolderMultipleImage'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -406,12 +419,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiImageMultiple' in icon
? icon['mdiImageMultiple' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiImageMultiple'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -440,12 +452,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiLinkLock' in icon
? icon['mdiLinkLock' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiLinkLock'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -474,12 +485,13 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiFileDocumentMultiple' in icon
? icon['mdiFileDocumentMultiple' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)[
'mdiFileDocumentMultiple'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -508,12 +520,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiViewDashboard' in icon
? icon['mdiViewDashboard' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiViewDashboard'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -542,12 +553,13 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiArrowRightCircle' in icon
? icon['mdiArrowRightCircle' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)[
'mdiArrowRightCircle'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -576,12 +588,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiSwapHorizontal' in icon
? icon['mdiSwapHorizontal' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiSwapHorizontal'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -610,12 +621,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiMusicNote' in icon
? icon['mdiMusicNote' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiMusicNote'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -644,12 +654,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiPublish' in icon
? icon['mdiPublish' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiPublish'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -678,12 +687,11 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiCellphoneLink' in icon
? icon['mdiCellphoneLink' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)['mdiCellphoneLink'] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>
@ -712,12 +720,13 @@ const Dashboard = () => {
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={
'mdiClipboardTextOutline' in icon
? icon['mdiClipboardTextOutline' as keyof typeof icon]
: icon.mdiTable || icon.mdiTable
(icon as Record<string, string>)[
'mdiClipboardTextOutline'
] ??
(icon as Record<string, string>)[
'mdiFormatListBulleted'
]
}
/>
</div>

View File

@ -13,6 +13,7 @@ import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import axios from 'axios';
import { logger } from '../lib/logger';
export default function Forgot() {
const [loading, setLoading] = React.useState(false);
@ -33,7 +34,10 @@ export default function Forgot() {
}, 3000);
} catch (error) {
setLoading(false);
console.error('Password reset request failed:', error);
logger.error(
'Password reset request failed:',
error instanceof Error ? error : { error },
);
notify('error', 'Something was wrong. Try again');
}
};

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
@ -8,19 +8,12 @@ import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from '../components/CardBoxComponentTitle';
import { getPexelsImage } from '../helpers/pexels';
import axios from 'axios';
import { logger } from '../lib/logger';
export default function Starter() {
const router = useRouter();
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
});
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Shimahara Visual';
useEffect(() => {
@ -45,19 +38,14 @@ export default function Starter() {
if (axios.isAxiosError(error) && error.response?.status === 503) {
return;
}
console.error('Failed to detect runtime mode:', error);
}
}
async function fetchData() {
const image = await getPexelsImage();
if (!isCancelled) {
setIllustrationImage(image);
logger.error(
'Failed to detect runtime mode:',
error instanceof Error ? error : { error },
);
}
}
loadRuntimeMode();
fetchData();
return () => {
isCancelled = true;
@ -67,11 +55,7 @@ export default function Starter() {
return (
<div
style={{
backgroundImage: illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))',
backgroundSize: 'cover',
backgroundPosition: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import BaseIcon from '../components/BaseIcon';
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
@ -12,13 +11,13 @@ import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import BaseButton from '../components/BaseButton';
import { useRouter } from 'next/router';
import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import { toast, ToastContainer } from 'react-toastify';
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
export default function Login() {
const router = useRouter();
@ -26,16 +25,6 @@ export default function Login() {
const textColor = useAppSelector((state) => state.style.linkColor);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const notify = (type, msg) => toast(msg, { type });
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
});
const [illustrationVideo, setIllustrationVideo] = useState({
video_files: [],
});
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('background');
const [showPassword, setShowPassword] = useState(false);
const {
currentUser,
@ -52,34 +41,27 @@ export default function Login() {
const title = 'Shimahara Visual';
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
// Fetch user data
useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/dashboard');
}
}, [currentUser?.id, router]);
// Show error message if there is one
useEffect(() => {
if (errorMessage) {
notify('error', errorMessage);
}
}, [errorMessage]);
// Show notification if there is one
useEffect(() => {
if (notifyState?.showNotification) {
@ -105,88 +87,14 @@ export default function Login() {
}));
};
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4' />
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>
);
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex min-h-screen w-full'>
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id='loginRoles' className='w-full md:w-3/5 lg:w-2/3'>
<h2 className='text-4xl font-semibold my-4'>{title}</h2>
@ -198,7 +106,7 @@ export default function Login() {
<code
className={`cursor-pointer ${textColor} `}
data-password='88dbeaf8'
onClick={(e) => setLogin(e.target)}
onClick={(e) => setLogin(e.currentTarget)}
>
admin@flatlogic.com
</code>
@ -212,7 +120,7 @@ export default function Login() {
<code
className={`cursor-pointer ${textColor} `}
data-password='c3baadeda5c6'
onClick={(e) => setLogin(e.target)}
onClick={(e) => setLogin(e.currentTarget)}
>
client@hello.com
</code>
@ -293,7 +201,7 @@ export default function Login() {
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
Don't have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>

View File

@ -31,66 +31,87 @@ import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { PageElement } from '../../types/entities';
interface FormValues {
page: unknown;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initVals: FormValues = {
page: null,
element_type: '',
name: '',
sort_order: '',
is_visible: false,
x_percent: '',
y_percent: '',
width_percent: '',
height_percent: '',
rotation_deg: '',
style_json: '',
content_json: '',
};
const EditPage_elements = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
page: null,
const [initialValues, setInitialValues] = useState<FormValues>(initVals);
element_type: '',
name: '',
sort_order: '',
is_visible: false,
x_percent: '',
y_percent: '',
width_percent: '',
height_percent: '',
rotation_deg: '',
style_json: '',
content_json: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const { page_elements } = useAppSelector((state) => state.page_elements);
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElement = Array.isArray(page_elements)
? page_elements[0]
: page_elements;
const { page_elementsId } = router.query;
const idStr = Array.isArray(page_elementsId)
? page_elementsId[0]
: page_elementsId;
useEffect(() => {
dispatch(fetch({ id: page_elementsId }));
}, [page_elementsId]);
useEffect(() => {
if (typeof page_elements === 'object') {
setInitialValues(page_elements);
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [page_elements]);
}, [idStr, dispatch]);
useEffect(() => {
if (typeof page_elements === 'object') {
if (pageElement && typeof pageElement === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach(
(el) => (newInitialVal[el] = page_elements[el]),
);
const pageElementRecord = pageElement as unknown as Record<
string,
unknown
>;
(Object.keys(initVals) as Array<keyof FormValues>).forEach((key) => {
if (key in pageElementRecord) {
(newInitialVal as Record<string, unknown>)[key] =
pageElementRecord[key];
}
});
setInitialValues(newInitialVal);
}
}, [page_elements]);
}, [pageElement]);
const handleSubmit = async (data) => {
await dispatch(update({ id: page_elementsId, data }));
await router.push('/page_elements/page_elements-list');
const handleSubmit = async (data: FormValues) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageElement> }),
);
await router.push('/page_elements/page_elements-list');
}
};
return (

View File

@ -24,8 +24,24 @@ import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/page_elements/page_elementsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { PageElement } from '../../types/entities';
const initVals = {
interface FormValues {
page: unknown;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initVals: FormValues = {
page: null,
element_type: '',
name: '',
@ -43,34 +59,52 @@ const initVals = {
const EditPage_elementsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const [initialValues, setInitialValues] = useState<FormValues>(initVals);
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElement = Array.isArray(page_elements)
? page_elements[0]
: page_elements;
const { page_elements } = useAppSelector((state) => state.page_elements);
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
// Fetch entity data
useEffect(() => {
if (id) {
dispatch(fetch({ id: id as string }));
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [id, dispatch]);
}, [idStr, dispatch]);
// Sync form values with fetched data (consolidated from redundant useEffects)
useEffect(() => {
if (typeof page_elements === 'object' && page_elements !== null) {
if (pageElement && typeof pageElement === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((key) => {
if (key in page_elements) {
newInitialVal[key] = page_elements[key];
const pageElementRecord = pageElement as unknown as Record<
string,
unknown
>;
(Object.keys(initVals) as Array<keyof FormValues>).forEach((key) => {
if (key in pageElementRecord) {
(newInitialVal as Record<string, unknown>)[key] =
pageElementRecord[key];
}
});
setInitialValues(newInitialVal);
}
}, [page_elements]);
}, [pageElement]);
const handleSubmit = async (data: typeof initVals) => {
await dispatch(update({ id: id as string, data }));
await router.push('/page_elements/page_elements-list');
const handleSubmit = async (data: FormValues) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageElement> }),
);
await router.push('/page_elements/page_elements-list');
}
};
return (

View File

@ -2,13 +2,20 @@ import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import axios from 'axios';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger';
type TourPage = {
id: string;
@ -53,7 +60,10 @@ const parseJsonObject = (value?: unknown): Record<string, any> => {
return {};
} catch (error) {
console.error('Failed to parse page schema JSON on pages elements list:', error);
logger.error(
'Failed to parse page schema JSON on pages elements list:',
error instanceof Error ? error : { error },
);
return {};
}
};
@ -65,7 +75,10 @@ const toElementLabel = (value: string) =>
.join(' ');
const getElementName = (element: ConstructorElement) => {
if (element.type === 'navigation_next' || element.type === 'navigation_prev') {
if (
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
return String(element.navLabel || '').trim();
}
@ -92,7 +105,9 @@ const PagesElementsListPage = () => {
const [isLoadingProject, setIsLoadingProject] = useState(false);
const [isLoadingElements, setIsLoadingElements] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [projectElements, setProjectElements] = useState<ProjectElementItem[]>([]);
const [projectElements, setProjectElements] = useState<ProjectElementItem[]>(
[],
);
const loadData = useCallback(async () => {
if (!routeProjectId) {
@ -123,7 +138,9 @@ const PagesElementsListPage = () => {
const items: ProjectElementItem[] = [];
pageRows.forEach((page, pageIndex) => {
const schema = parseJsonObject(page.ui_schema_json) as ConstructorSchema;
const schema = parseJsonObject(
page.ui_schema_json,
) as ConstructorSchema;
const elements = Array.isArray(schema.elements) ? schema.elements : [];
elements.forEach((element) => {
@ -148,7 +165,10 @@ const PagesElementsListPage = () => {
error?.message ||
'Failed to load pages elements.';
setErrorMessage(message);
console.error('Failed to load project elements from constructor pages:', error);
logger.error(
'Failed to load project elements from constructor pages:',
error instanceof Error ? error : { error },
);
setProjectName('');
setProjectElements([]);
} finally {
@ -198,7 +218,9 @@ const PagesElementsListPage = () => {
{isLoadingElements ? (
<p className='text-sm text-gray-500'>Loading elements...</p>
) : projectElements.length === 0 ? (
<p className='text-sm text-gray-500'>No constructor elements found yet.</p>
<p className='text-sm text-gray-500'>
No constructor elements found yet.
</p>
) : (
<div className='space-y-2'>
{projectElements.map((item) => (

View File

@ -24,8 +24,24 @@ import { SelectField } from '../../components/SelectField';
import { create } from '../../stores/page_elements/page_elementsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { PageElement } from '../../types/entities';
const initialValues = {
interface FormValues {
page: string;
element_type: string;
name: string;
sort_order: string;
is_visible: boolean;
x_percent: string;
y_percent: string;
width_percent: string;
height_percent: string;
rotation_deg: string;
style_json: string;
content_json: string;
}
const initialValues: FormValues = {
page: '',
element_type: 'nav_button',
name: '',
@ -44,8 +60,8 @@ const Page_elementsNew = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data));
const handleSubmit = async (data: FormValues) => {
await dispatch(create(data as unknown as Partial<PageElement>));
await router.push('/page_elements/page_elements-list');
};

View File

@ -20,6 +20,8 @@ import {
uploadCsv,
} from '../../stores/page_elements/page_elementsSlice';
import { hasPermission } from '../../helpers/userPermissions';
import { Filter } from '../../types/filters';
import { logger } from '../../lib/logger';
type ProjectPage = {
id: string;
@ -44,16 +46,16 @@ const Page_elementsTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([
const [filters] = useState<Filter[]>([
{ label: 'Name', title: 'name' },
{ label: 'StyleJSON', title: 'style_json' },
{ label: 'ContentJSON', title: 'content_json' },
{ label: 'Sortorder', title: 'sort_order', number: 'true' },
{ label: 'X(%)', title: 'x_percent', number: 'true' },
{ label: 'Y(%)', title: 'y_percent', number: 'true' },
{ label: 'Width(%)', title: 'width_percent', number: 'true' },
{ label: 'Height(%)', title: 'height_percent', number: 'true' },
{ label: 'Rotation(deg)', title: 'rotation_deg', number: 'true' },
{ label: 'Sortorder', title: 'sort_order', number: true },
{ label: 'X(%)', title: 'x_percent', number: true },
{ label: 'Y(%)', title: 'y_percent', number: true },
{ label: 'Width(%)', title: 'width_percent', number: true },
{ label: 'Height(%)', title: 'height_percent', number: true },
{ label: 'Rotation(deg)', title: 'rotation_deg', number: true },
{ label: 'Page', title: 'page' },
{
label: 'Elementtype',
@ -92,9 +94,9 @@ const Page_elementsTablesPage = () => {
: [];
setProjectPages(rows.map((row: { id: string }) => ({ id: row.id })));
} catch (error: any) {
console.error(
logger.error(
'Failed to load project pages for page elements scope:',
error,
error instanceof Error ? error : { error },
);
setProjectPages([]);
}
@ -115,7 +117,10 @@ const Page_elementsTablesPage = () => {
const response = await axios.get(`/projects/${routeProjectId}`);
setProjectName(response?.data?.name || '');
} catch (error: any) {
console.error('Failed to load project for page elements page:', error);
logger.error(
'Failed to load project for page elements page:',
error instanceof Error ? error : { error },
);
setProjectName('');
} finally {
setIsLoadingProject(false);

View File

@ -19,21 +19,35 @@ import BaseDivider from '../../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js';
import { SwitchField } from '../../components/SwitchField';
import FormField from '../../components/FormField';
import type { PageElement } from '../../types/entities';
const Page_elementsView = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { page_elements } = useAppSelector((state) => state.page_elements);
const pageElementsState = useAppSelector((state) => state.page_elements);
const page_elements_data = pageElementsState.page_elements as
| PageElement
| PageElement[]
| undefined;
const pageElementRaw = Array.isArray(page_elements_data)
? page_elements_data[0]
: page_elements_data;
const pageElement = pageElementRaw as unknown as
| Record<string, unknown>
| undefined;
const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
function removeLastCharacter(str) {
function removeLastCharacter(str: string): string {
return str.slice(0, -1);
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [dispatch, idStr]);
return (
<>
@ -49,34 +63,40 @@ const Page_elementsView = () => {
<BaseButton
color='info'
label='Edit'
href={`/page_elements/page_elements-edit/?id=${id}`}
href={`/page_elements/page_elements-edit/?id=${idStr}`}
/>
</SectionTitleLineWithButton>
<CardBox>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Page</p>
<p>{page_elements?.page?.name ?? 'No data'}</p>
<p>
{(pageElement?.page as { name?: string } | undefined)?.name ??
'No data'}
</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Elementtype</p>
<p>{page_elements?.element_type ?? 'No data'}</p>
<p>{(pageElement?.element_type as string) ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{page_elements?.name}</p>
<p>{pageElement?.name as string}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Sortorder</p>
<p>{page_elements?.sort_order || 'No data'}</p>
<p>{(pageElement?.sort_order as string | number) || 'No data'}</p>
</div>
<FormField label='Isvisible'>
<SwitchField
field={{ name: 'is_visible', value: page_elements?.is_visible }}
field={{
name: 'is_visible',
value: pageElement?.is_visible as boolean,
}}
form={{ setFieldValue: () => null }}
disabled
/>
@ -84,34 +104,34 @@ const Page_elementsView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>X(%)</p>
<p>{page_elements?.x_percent || 'No data'}</p>
<p>{(pageElement?.x_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Y(%)</p>
<p>{page_elements?.y_percent || 'No data'}</p>
<p>{(pageElement?.y_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Width(%)</p>
<p>{page_elements?.width_percent || 'No data'}</p>
<p>{(pageElement?.width_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Height(%)</p>
<p>{page_elements?.height_percent || 'No data'}</p>
<p>{(pageElement?.height_percent as string) || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Rotation(deg)</p>
<p>{page_elements?.rotation_deg || 'No data'}</p>
<p>{(pageElement?.rotation_deg as string) || 'No data'}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={page_elements?.style_json}
value={(pageElement?.style_json as string) ?? ''}
/>
</FormField>
@ -119,7 +139,7 @@ const Page_elementsView = () => {
<textarea
className={'w-full'}
disabled
value={page_elements?.content_json}
value={(pageElement?.content_json as string) ?? ''}
/>
</FormField>

View File

@ -31,56 +31,60 @@ import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import type { PageLink } from '../../types/entities';
const initVals = {
from_page: null as PageLink['source_page'] | null,
to_page: null as PageLink['target_page'] | null,
direction: '',
external_url: '',
transition: null as PageLink['transition'] | null,
is_active: false,
trigger_selector: '',
};
const EditPage_links = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
from_page: null,
to_page: null,
direction: '',
external_url: '',
transition: null,
is_active: false,
trigger_selector: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const { page_links } = useAppSelector((state) => state.page_links);
const pageLinksState = useAppSelector((state) => state.page_links);
const page_links = pageLinksState.page_links as
| PageLink
| PageLink[]
| undefined;
const pageLink = Array.isArray(page_links) ? page_links[0] : page_links;
const { page_linksId } = router.query;
const idStr = Array.isArray(page_linksId) ? page_linksId[0] : page_linksId;
useEffect(() => {
dispatch(fetch({ id: page_linksId }));
}, [page_linksId]);
useEffect(() => {
if (typeof page_links === 'object') {
setInitialValues(page_links);
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [page_links]);
}, [idStr, dispatch]);
useEffect(() => {
if (typeof page_links === 'object') {
if (pageLink && typeof pageLink === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach(
(el) => (newInitialVal[el] = page_links[el]),
);
Object.keys(initVals).forEach((el) => {
if (el in pageLink) {
(newInitialVal as Record<string, unknown>)[el] = (
pageLink as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [page_links]);
}, [pageLink]);
const handleSubmit = async (data) => {
await dispatch(update({ id: page_linksId, data }));
await router.push('/page_links/page_links-list');
const handleSubmit = async (data: typeof initVals) => {
if (idStr) {
await dispatch(
update({ id: idStr, data: data as unknown as Partial<PageLink> }),
);
await router.push('/page_links/page_links-list');
}
};
return (

Some files were not shown because too many files have changed in this diff Show More