revers transitions and preloading
This commit is contained in:
parent
e8f72cb390
commit
4c41205225
@ -10,6 +10,7 @@ module.exports = {
|
||||
'import'
|
||||
],
|
||||
rules: {
|
||||
'import/no-unresolved': 'error'
|
||||
'import/no-unresolved': 'error',
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
||||
}
|
||||
};
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
273
backend/src/services/pwa_manifest.js
Normal file
273
backend/src/services/pwa_manifest.js
Normal 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;
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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);
|
||||
@ -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"
|
||||
}
|
||||
|
||||
37
frontend/public/manifest.json
Normal file
37
frontend/public/manifest.json
Normal 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
@ -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,
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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'}>
|
||||
|
||||
@ -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('');
|
||||
|
||||
193
frontend/src/components/Offline/DownloadProgressPanel.tsx
Normal file
193
frontend/src/components/Offline/DownloadProgressPanel.tsx
Normal 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;
|
||||
61
frontend/src/components/Offline/OfflineStatusIndicator.tsx
Normal file
61
frontend/src/components/Offline/OfflineStatusIndicator.tsx
Normal 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;
|
||||
149
frontend/src/components/Offline/OfflineToggle.tsx
Normal file
149
frontend/src/components/Offline/OfflineToggle.tsx
Normal 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;
|
||||
124
frontend/src/components/Offline/StorageUsageDisplay.tsx
Normal file
124
frontend/src/components/Offline/StorageUsageDisplay.tsx
Normal 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;
|
||||
10
frontend/src/components/Offline/index.ts
Normal file
10
frontend/src/components/Offline/index.ts
Normal 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';
|
||||
@ -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 } };
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('');
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
52
frontend/src/config/offline.config.ts
Normal file
52
frontend/src/config/offline.config.ts
Normal 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;
|
||||
60
frontend/src/config/preload.config.ts
Normal file
60
frontend/src/config/preload.config.ts
Normal 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;
|
||||
255
frontend/src/context/DownloadContext.tsx
Normal file
255
frontend/src/context/DownloadContext.tsx
Normal 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);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
315
frontend/src/hooks/useNeighborGraph.ts
Normal file
315
frontend/src/hooks/useNeighborGraph.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
163
frontend/src/hooks/useNetworkAware.ts
Normal file
163
frontend/src/hooks/useNetworkAware.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
387
frontend/src/hooks/useOfflineMode.ts
Normal file
387
frontend/src/hooks/useOfflineMode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
533
frontend/src/hooks/usePreloadOrchestrator.ts
Normal file
533
frontend/src/hooks/usePreloadOrchestrator.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
220
frontend/src/hooks/usePreloadProgress.ts
Normal file
220
frontend/src/hooks/usePreloadProgress.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
239
frontend/src/hooks/useReversePlayback.ts
Normal file
239
frontend/src/hooks/useReversePlayback.ts
Normal 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 };
|
||||
}
|
||||
110
frontend/src/hooks/useStorageQuota.ts
Normal file
110
frontend/src/hooks/useStorageQuota.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
44
frontend/src/lib/assetUrl.ts
Normal file
44
frontend/src/lib/assetUrl.ts
Normal 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
163
frontend/src/lib/logger.ts
Normal 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 };
|
||||
179
frontend/src/lib/offline/DownloadEventBus.ts
Normal file
179
frontend/src/lib/offline/DownloadEventBus.ts
Normal 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();
|
||||
462
frontend/src/lib/offline/DownloadManager.ts
Normal file
462
frontend/src/lib/offline/DownloadManager.ts
Normal 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();
|
||||
281
frontend/src/lib/offline/StorageManager.ts
Normal file
281
frontend/src/lib/offline/StorageManager.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
342
frontend/src/lib/offlineDb/OfflineDbManager.ts
Normal file
342
frontend/src/lib/offlineDb/OfflineDbManager.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
41
frontend/src/lib/offlineDb/schema.ts
Normal file
41
frontend/src/lib/offlineDb/schema.ts
Normal 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 };
|
||||
@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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' },
|
||||
{
|
||||
|
||||
@ -26,7 +26,7 @@ import { useRouter } from 'next/router';
|
||||
|
||||
const initialValues = {
|
||||
project: '',
|
||||
environment: 'admin',
|
||||
environment: 'admin' as 'admin' | 'stage' | 'production',
|
||||
user: '',
|
||||
path: '',
|
||||
ip_address: '',
|
||||
|
||||
@ -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' },
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'}>
|
||||
Don’t have an account yet?{' '}
|
||||
Don't have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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');
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user