offline mode improvements
This commit is contained in:
parent
42cc3456eb
commit
f8c3bb4a07
@ -33,30 +33,4 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom endpoint: Get offline manifest for PWA download
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Build base URL for asset proxy endpoints
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
const baseUrl = `${protocol}://${host}`;
|
|
||||||
|
|
||||||
const manifest = await PWAManifestService.generateManifest(
|
|
||||||
req.params.id,
|
|
||||||
variant,
|
|
||||||
baseUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(200).json(manifest);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -1,359 +0,0 @@
|
|||||||
/**
|
|
||||||
* PWA Manifest Service
|
|
||||||
*
|
|
||||||
* Generates offline manifests for PWA asset downloads.
|
|
||||||
*
|
|
||||||
* SIMPLIFIED: Elements are now stored in ui_schema_json within tour_pages,
|
|
||||||
* and transitions are stored as transitionVideoUrl in element content.
|
|
||||||
* No need to query separate page_elements or transitions tables.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const AssetsDBApi = require('../db/api/assets');
|
|
||||||
const AssetVariantsDBApi = require('../db/api/asset_variants');
|
|
||||||
const TourPagesDBApi = require('../db/api/tour_pages');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = [
|
|
||||||
'iconUrl',
|
|
||||||
'imageUrl',
|
|
||||||
'mediaUrl',
|
|
||||||
'videoUrl',
|
|
||||||
'audioUrl',
|
|
||||||
'transitionVideoUrl',
|
|
||||||
'backgroundImageUrl',
|
|
||||||
'reverseVideoUrl',
|
|
||||||
'carouselPrevIconUrl',
|
|
||||||
'carouselNextIconUrl',
|
|
||||||
'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' && value && urlFields.includes(key)) {
|
|
||||||
if (value.startsWith('http') || value.startsWith('/')) {
|
|
||||||
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)
|
|
||||||
* @param {string} baseUrl - Base URL for proxy endpoints (e.g., 'http://localhost:8080')
|
|
||||||
* @returns {Object} Offline manifest
|
|
||||||
*/
|
|
||||||
static async generateManifest(projectId, deviceType = 'desktop', baseUrl = '') {
|
|
||||||
// Fetch all project data
|
|
||||||
const [assetsResult, pagesResult] = await Promise.all([
|
|
||||||
AssetsDBApi.findAll({ project: projectId }, {}),
|
|
||||||
TourPagesDBApi.findAll({ project: projectId }, {}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const assets = assetsResult?.rows || [];
|
|
||||||
const pages = pagesResult?.rows || [];
|
|
||||||
|
|
||||||
// Build asset manifest entries
|
|
||||||
const manifestAssets = [];
|
|
||||||
const seenUrls = new Set();
|
|
||||||
|
|
||||||
// Helper to convert size_mb to bytes
|
|
||||||
const mbToBytes = (sizeMb) => {
|
|
||||||
if (!sizeMb || isNaN(sizeMb)) return 0;
|
|
||||||
return Math.round(parseFloat(sizeMb) * 1024 * 1024);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to convert URL to proxy URL (avoids CORS issues with S3)
|
|
||||||
// Extracts storage key from full URLs for backend proxy
|
|
||||||
const toProxyUrl = (url) => {
|
|
||||||
if (!url) return url;
|
|
||||||
|
|
||||||
let storageKey = url;
|
|
||||||
|
|
||||||
// If it's a full S3 URL, extract the path after the bucket/hash
|
|
||||||
if (url.includes('.s3.') || url.includes('s3.amazonaws.com')) {
|
|
||||||
// URL format: https://bucket.s3.amazonaws.com/hash/path/to/file
|
|
||||||
// or: https://s3.region.amazonaws.com/bucket/hash/path/to/file
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
||||||
// Skip the hash prefix (first path segment after bucket)
|
|
||||||
if (pathParts.length > 1) {
|
|
||||||
storageKey = pathParts.slice(1).join('/');
|
|
||||||
} else {
|
|
||||||
storageKey = pathParts.join('/');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If URL parsing fails, use as-is
|
|
||||||
}
|
|
||||||
} else if (url.includes('storage.googleapis.com')) {
|
|
||||||
// GCloud URL format: https://storage.googleapis.com/bucket/hash/path/to/file
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
||||||
// Skip bucket and hash (first two segments)
|
|
||||||
if (pathParts.length > 2) {
|
|
||||||
storageKey = pathParts.slice(2).join('/');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If URL parsing fails, use as-is
|
|
||||||
}
|
|
||||||
} else if (url.startsWith('http')) {
|
|
||||||
// Other external URL - return as-is (can't proxy)
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseUrl}/api/file/download?privateUrl=${encodeURIComponent(storageKey)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to add an asset to the manifest
|
|
||||||
const addAsset = (
|
|
||||||
id,
|
|
||||||
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: toProxyUrl(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 || mbToBytes(variant.size_mb),
|
|
||||||
asset.pages?.map((p) => p.id) || [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no variants, add original (use cdn_url as primary, fall back to storage_key)
|
|
||||||
if (
|
|
||||||
selectedVariants.length === 0 &&
|
|
||||||
(asset.cdn_url || asset.storage_key)
|
|
||||||
) {
|
|
||||||
addAsset(
|
|
||||||
asset.id,
|
|
||||||
asset.cdn_url || asset.storage_key,
|
|
||||||
asset.name || asset.filename,
|
|
||||||
'original',
|
|
||||||
asset.type || getAssetType(asset.mime_type, asset.name),
|
|
||||||
asset.mime_type,
|
|
||||||
mbToBytes(asset.size_mb),
|
|
||||||
asset.pages?.map((p) => p.id) || [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add page background images/videos and extract element URLs from ui_schema_json
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (page.background_audio_url) {
|
|
||||||
addAsset(
|
|
||||||
`page-audio-${page.id}`,
|
|
||||||
page.background_audio_url,
|
|
||||||
`page-${page.slug}-audio.mp3`,
|
|
||||||
'original',
|
|
||||||
'audio',
|
|
||||||
'audio/mpeg',
|
|
||||||
0,
|
|
||||||
[page.id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract URLs from ui_schema_json elements
|
|
||||||
try {
|
|
||||||
const uiSchema =
|
|
||||||
typeof page.ui_schema_json === 'string'
|
|
||||||
? JSON.parse(page.ui_schema_json)
|
|
||||||
: page.ui_schema_json;
|
|
||||||
|
|
||||||
const elements = Array.isArray(uiSchema?.elements)
|
|
||||||
? uiSchema.elements
|
|
||||||
: [];
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
const contentUrls = extractUrlsFromContent(element);
|
|
||||||
for (const { url, fieldType } of contentUrls) {
|
|
||||||
const assetType =
|
|
||||||
fieldType.includes('video') || fieldType.includes('transition')
|
|
||||||
? 'video'
|
|
||||||
: fieldType.includes('audio')
|
|
||||||
? 'audio'
|
|
||||||
: 'image';
|
|
||||||
addAsset(
|
|
||||||
`element-${page.id}-${element.id || fieldType}`,
|
|
||||||
url,
|
|
||||||
url.split('/').pop() || 'unknown',
|
|
||||||
'original',
|
|
||||||
assetType,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
[page.id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip pages with invalid ui_schema_json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
File diff suppressed because one or more lines are too long
@ -18,12 +18,15 @@ import BaseButton from '../BaseButton';
|
|||||||
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||||||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||||||
import type { ProjectOfflineStatus } from '../../types/offline';
|
import type { ProjectOfflineStatus } from '../../types/offline';
|
||||||
|
import type { PreloadPage } from '../../types/preload';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
|
||||||
interface OfflineToggleProps {
|
interface OfflineToggleProps {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
projectSlug?: string;
|
projectSlug?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
|
/** Pages data for frontend asset discovery (required for offline download) */
|
||||||
|
pages?: PreloadPage[];
|
||||||
className?: string;
|
className?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
@ -33,6 +36,7 @@ export function OfflineToggle({
|
|||||||
projectId,
|
projectId,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
projectName,
|
projectName,
|
||||||
|
pages,
|
||||||
className = '',
|
className = '',
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
@ -54,6 +58,7 @@ export function OfflineToggle({
|
|||||||
projectId,
|
projectId,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
projectName,
|
projectName,
|
||||||
|
pages,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { canStore, isWarning, isCritical } = useStorageQuota();
|
const { canStore, isWarning, isCritical } = useStorageQuota();
|
||||||
|
|||||||
@ -601,6 +601,7 @@ export default function RuntimePresentation({
|
|||||||
projectId={project?.id || null}
|
projectId={project?.id || null}
|
||||||
projectSlug={projectSlug}
|
projectSlug={projectSlug}
|
||||||
projectName={project?.name}
|
projectName={project?.name}
|
||||||
|
pages={pages}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
size='small'
|
size='small'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,10 +3,18 @@
|
|||||||
*
|
*
|
||||||
* Builds a navigation graph from page_links to determine which pages
|
* Builds a navigation graph from page_links to determine which pages
|
||||||
* are neighbors and should have their assets preloaded.
|
* are neighbors and should have their assets preloaded.
|
||||||
|
*
|
||||||
|
* Uses shared asset discovery from lib/assetCache for consistent extraction.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||||
|
import {
|
||||||
|
extractElementAssets,
|
||||||
|
extractPageBackgroundAssets,
|
||||||
|
extractTransitionAssets,
|
||||||
|
toPreloadAssetInfo,
|
||||||
|
} from '../lib/assetCache';
|
||||||
import type {
|
import type {
|
||||||
PreloadPage,
|
PreloadPage,
|
||||||
PreloadPageLink,
|
PreloadPageLink,
|
||||||
@ -50,62 +58,6 @@ interface NeighborGraphResult {
|
|||||||
adjacencyList: Map<string, string[]>;
|
adjacencyList: Map<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse content_json to extract asset URLs
|
|
||||||
*/
|
|
||||||
function extractAssetsFromContent(
|
|
||||||
contentJson: string | undefined,
|
|
||||||
pageId: string,
|
|
||||||
): PreloadAssetInfo[] {
|
|
||||||
if (!contentJson) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content =
|
|
||||||
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
|
||||||
|
|
||||||
const assets: PreloadAssetInfo[] = [];
|
|
||||||
|
|
||||||
// Asset URL fields in element content_json
|
|
||||||
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
// Classify asset type based on field name
|
|
||||||
const lowerKey = key.toLowerCase();
|
|
||||||
|
|
||||||
let assetType: 'video' | 'audio' | 'image' | 'transition';
|
|
||||||
if (lowerKey.includes('transition')) {
|
|
||||||
assetType = 'transition';
|
|
||||||
} else if (lowerKey.includes('video')) {
|
|
||||||
assetType = 'video';
|
|
||||||
} else if (lowerKey.includes('audio')) {
|
|
||||||
assetType = 'audio';
|
|
||||||
} else {
|
|
||||||
assetType = '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(
|
export function useNeighborGraph(
|
||||||
options: UseNeighborGraphOptions,
|
options: UseNeighborGraphOptions,
|
||||||
): NeighborGraphResult {
|
): NeighborGraphResult {
|
||||||
@ -177,7 +129,7 @@ export function useNeighborGraph(
|
|||||||
};
|
};
|
||||||
}, [adjacencyList, maxDepth]);
|
}, [adjacencyList, maxDepth]);
|
||||||
|
|
||||||
// Get assets for a set of pages
|
// Get assets for a set of pages - uses shared extraction from assetDiscovery
|
||||||
const getAssetsForPages = useMemo(() => {
|
const getAssetsForPages = useMemo(() => {
|
||||||
return (pageIds: string[]): PreloadAssetInfo[] => {
|
return (pageIds: string[]): PreloadAssetInfo[] => {
|
||||||
const assets: PreloadAssetInfo[] = [];
|
const assets: PreloadAssetInfo[] = [];
|
||||||
@ -187,74 +139,36 @@ export function useNeighborGraph(
|
|||||||
// Find the page to get its background assets
|
// Find the page to get its background assets
|
||||||
const page = pages.find((p) => p.id === pageId);
|
const page = pages.find((p) => p.id === pageId);
|
||||||
if (page) {
|
if (page) {
|
||||||
// Add page background image (highest priority for page display)
|
// Use shared extraction for page backgrounds
|
||||||
if (page.background_image_url && !seenUrls.has(page.background_image_url)) {
|
const bgAssets = extractPageBackgroundAssets(page);
|
||||||
seenUrls.add(page.background_image_url);
|
bgAssets.forEach((asset) => {
|
||||||
assets.push({
|
if (!seenUrls.has(asset.originalUrl)) {
|
||||||
url: page.background_image_url,
|
seenUrls.add(asset.originalUrl);
|
||||||
pageId,
|
assets.push(toPreloadAssetInfo(asset));
|
||||||
assetType: 'image',
|
|
||||||
priority: 0, // Will be calculated later
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Add page background video
|
|
||||||
if (page.background_video_url && !seenUrls.has(page.background_video_url)) {
|
|
||||||
seenUrls.add(page.background_video_url);
|
|
||||||
assets.push({
|
|
||||||
url: page.background_video_url,
|
|
||||||
pageId,
|
|
||||||
assetType: 'video',
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Add page background audio
|
|
||||||
if (page.background_audio_url && !seenUrls.has(page.background_audio_url)) {
|
|
||||||
seenUrls.add(page.background_audio_url);
|
|
||||||
assets.push({
|
|
||||||
url: page.background_audio_url,
|
|
||||||
pageId,
|
|
||||||
assetType: 'audio',
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get elements for this page and use shared extraction
|
||||||
|
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||||
|
const elementAssets = extractElementAssets(pageElements, pageId);
|
||||||
|
elementAssets.forEach((asset) => {
|
||||||
|
if (!seenUrls.has(asset.originalUrl)) {
|
||||||
|
seenUrls.add(asset.originalUrl);
|
||||||
|
assets.push(toPreloadAssetInfo(asset));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract transition videos from page_links for preloading
|
// Extract transition videos using shared extraction
|
||||||
const matchingLinks = pageLinks.filter(
|
pageIds.forEach((pageId) => {
|
||||||
(link) =>
|
const transitionAssets = extractTransitionAssets(pageLinks, pageId);
|
||||||
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
transitionAssets.forEach((asset) => {
|
||||||
);
|
if (!seenUrls.has(asset.originalUrl)) {
|
||||||
|
seenUrls.add(asset.originalUrl);
|
||||||
matchingLinks.forEach((link) => {
|
assets.push(toPreloadAssetInfo(asset));
|
||||||
// Extract transition video URL from link.transition
|
}
|
||||||
const transition = link.transition as { video_url?: string } | undefined;
|
});
|
||||||
if (transition?.video_url && !seenUrls.has(transition.video_url)) {
|
|
||||||
seenUrls.add(transition.video_url);
|
|
||||||
assets.push({
|
|
||||||
url: transition.video_url,
|
|
||||||
pageId: link.from_pageId || '',
|
|
||||||
assetType: 'transition',
|
|
||||||
priority: 0, // Will be calculated later with transition priority
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return assets;
|
return assets;
|
||||||
|
|||||||
@ -2,30 +2,40 @@
|
|||||||
* useOfflineMode Hook
|
* useOfflineMode Hook
|
||||||
*
|
*
|
||||||
* Manages offline mode state and project download functionality.
|
* Manages offline mode state and project download functionality.
|
||||||
|
* Uses frontend asset discovery (same as online preloading) for consistent behavior.
|
||||||
|
* No backend manifest dependency - single source of truth in frontend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||||
import { StorageManager } from '../lib/offline/StorageManager';
|
import { StorageManager } from '../lib/offline/StorageManager';
|
||||||
import { OfflineDbManager } from '../lib/offlineDb/OfflineDbManager';
|
import { OfflineDbManager } from '../lib/offlineDb/OfflineDbManager';
|
||||||
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
||||||
import { OFFLINE_CONFIG } from '../config/offline.config';
|
import { OFFLINE_CONFIG } from '../config/offline.config';
|
||||||
import { extractStoragePath } from '../lib/assetUrl';
|
import {
|
||||||
|
queuePresignedUrls,
|
||||||
|
resolveAssetPlaybackUrl,
|
||||||
|
isRelativeStoragePath,
|
||||||
|
} from '../lib/assetUrl';
|
||||||
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||||
|
import { discoverProjectAssets, type AssetToCache } from '../lib/assetCache';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import type {
|
import type {
|
||||||
|
CachedAssetInfo,
|
||||||
OfflineProject,
|
OfflineProject,
|
||||||
OfflineManifest,
|
|
||||||
ProjectOfflineStatus,
|
ProjectOfflineStatus,
|
||||||
ProjectDownloadProgressEvent,
|
ProjectDownloadProgressEvent,
|
||||||
PreloadCompleteEvent,
|
PreloadCompleteEvent,
|
||||||
PreloadErrorEvent,
|
PreloadErrorEvent,
|
||||||
} from '../types/offline';
|
} from '../types/offline';
|
||||||
|
import type { PreloadPage } from '../types/preload';
|
||||||
|
|
||||||
interface UseOfflineModeOptions {
|
interface UseOfflineModeOptions {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
projectSlug?: string;
|
projectSlug?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
|
/** Pages data for frontend asset discovery (required for offline download) */
|
||||||
|
pages?: PreloadPage[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,10 +82,10 @@ const formatBytes = (bytes: number): string => {
|
|||||||
export function useOfflineMode(
|
export function useOfflineMode(
|
||||||
options: UseOfflineModeOptions,
|
options: UseOfflineModeOptions,
|
||||||
): UseOfflineModeResult {
|
): UseOfflineModeResult {
|
||||||
const { projectId, projectSlug, projectName, enabled = true } = options;
|
const { projectId, projectSlug, projectName, pages, enabled = true } = options;
|
||||||
|
|
||||||
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
|
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
|
||||||
const [manifest, setManifest] = useState<OfflineManifest | null>(null);
|
const [discoveredAssets, setDiscoveredAssets] = useState<AssetToCache[]>([]);
|
||||||
const [status, setStatus] = useState<ProjectOfflineStatus>('not_downloaded');
|
const [status, setStatus] = useState<ProjectOfflineStatus>('not_downloaded');
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [downloadedAssets, setDownloadedAssets] = useState(0);
|
const [downloadedAssets, setDownloadedAssets] = useState(0);
|
||||||
@ -85,8 +95,8 @@ export function useOfflineMode(
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|
||||||
// Track manifest for event-driven progress
|
// Track assets for event-driven progress
|
||||||
const manifestRef = useRef<OfflineManifest | null>(null);
|
const assetsRef = useRef<AssetToCache[]>([]);
|
||||||
const downloadedCountRef = useRef(0);
|
const downloadedCountRef = useRef(0);
|
||||||
const downloadedBytesRef = useRef(0);
|
const downloadedBytesRef = useRef(0);
|
||||||
|
|
||||||
@ -126,12 +136,12 @@ export function useOfflineMode(
|
|||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
const handleComplete = (data: PreloadCompleteEvent) => {
|
const handleComplete = (data: PreloadCompleteEvent) => {
|
||||||
// Only track if we have manifest data
|
// Only track if we have discovered assets
|
||||||
if (!manifestRef.current) return;
|
if (!assetsRef.current.length) return;
|
||||||
|
|
||||||
// Find the asset in manifest to get its size
|
// Find the asset to get its size
|
||||||
const asset = manifestRef.current.assets.find(
|
const asset = assetsRef.current.find(
|
||||||
(a) => a.id === data.assetId,
|
(a) => a.storageKey === data.storageKey || `offline-${a.storageKey}` === data.assetId,
|
||||||
);
|
);
|
||||||
const assetSize = asset?.sizeBytes || 0;
|
const assetSize = asset?.sizeBytes || 0;
|
||||||
|
|
||||||
@ -141,8 +151,8 @@ export function useOfflineMode(
|
|||||||
|
|
||||||
const downloaded = downloadedCountRef.current;
|
const downloaded = downloadedCountRef.current;
|
||||||
const dlBytes = downloadedBytesRef.current;
|
const dlBytes = downloadedBytesRef.current;
|
||||||
const total = manifestRef.current.assets.length;
|
const total = assetsRef.current.length;
|
||||||
const totalSize = manifestRef.current.totalSizeBytes;
|
const totalSize = assetsRef.current.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
setDownloadedAssets(downloaded);
|
setDownloadedAssets(downloaded);
|
||||||
@ -199,7 +209,7 @@ export function useOfflineMode(
|
|||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Also listen for legacy project progress events (for compatibility)
|
// Listen for project progress events (allows external components to sync state)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
@ -221,24 +231,28 @@ export function useOfflineMode(
|
|||||||
);
|
);
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Fetch manifest from backend
|
// Discover assets using frontend logic (same as online preloading)
|
||||||
const fetchManifest =
|
const discoverAssets = useCallback((): AssetToCache[] => {
|
||||||
useCallback(async (): Promise<OfflineManifest | null> => {
|
if (!pages || pages.length === 0) {
|
||||||
if (!projectId) return null;
|
logger.warn('[useOfflineMode] No pages provided for asset discovery');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Extract pageLinks and elements from all pages
|
||||||
const response = await axios.get(
|
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
|
||||||
`/projects/${projectId}/offline-manifest`,
|
|
||||||
);
|
// Use shared asset discovery (same as online preload)
|
||||||
return response.data;
|
const assets = discoverProjectAssets(pages, pageLinks, preloadElements);
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
logger.info('[useOfflineMode] Discovered assets from pages', {
|
||||||
'[useOfflineMode] Failed to fetch manifest:',
|
pageCount: pages.length,
|
||||||
err instanceof Error ? err : { error: err },
|
linkCount: pageLinks.length,
|
||||||
);
|
elementCount: preloadElements.length,
|
||||||
return null;
|
assetCount: assets.length,
|
||||||
}
|
});
|
||||||
}, [projectId]);
|
|
||||||
|
return assets;
|
||||||
|
}, [pages]);
|
||||||
|
|
||||||
// Start download
|
// Start download
|
||||||
const startDownload = useCallback(async (): Promise<void> => {
|
const startDownload = useCallback(async (): Promise<void> => {
|
||||||
@ -249,17 +263,20 @@ export function useOfflineMode(
|
|||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch manifest
|
// Discover assets from pages (frontend-only, no backend call)
|
||||||
const manifestData = await fetchManifest();
|
const assets = discoverAssets();
|
||||||
if (!manifestData) {
|
if (assets.length === 0) {
|
||||||
throw new Error('Failed to fetch offline manifest');
|
throw new Error('No assets discovered. Make sure pages data is provided.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store manifest for event-driven progress tracking
|
// Store assets for event-driven progress tracking
|
||||||
manifestRef.current = manifestData;
|
assetsRef.current = assets;
|
||||||
setManifest(manifestData);
|
setDiscoveredAssets(assets);
|
||||||
setTotalAssets(manifestData.assets.length);
|
setTotalAssets(assets.length);
|
||||||
setTotalBytes(manifestData.totalSizeBytes);
|
|
||||||
|
// Estimate total size (may not have exact sizes for all assets)
|
||||||
|
const estimatedTotalSize = assets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
|
||||||
|
setTotalBytes(estimatedTotalSize);
|
||||||
|
|
||||||
// Create or update project record
|
// Create or update project record
|
||||||
const projectRecord: OfflineProject = {
|
const projectRecord: OfflineProject = {
|
||||||
@ -267,18 +284,18 @@ export function useOfflineMode(
|
|||||||
slug: projectSlug || '',
|
slug: projectSlug || '',
|
||||||
name: projectName || '',
|
name: projectName || '',
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
totalAssets: manifestData.assets.length,
|
totalAssets: assets.length,
|
||||||
downloadedAssets: 0,
|
downloadedAssets: 0,
|
||||||
totalSizeBytes: manifestData.totalSizeBytes,
|
totalSizeBytes: estimatedTotalSize,
|
||||||
downloadedSizeBytes: 0,
|
downloadedSizeBytes: 0,
|
||||||
version: manifestData.version,
|
version: `v${Date.now()}`,
|
||||||
};
|
};
|
||||||
await OfflineDbManager.upsertProject(projectRecord);
|
await OfflineDbManager.upsertProject(projectRecord);
|
||||||
setProjectInfo(projectRecord);
|
setProjectInfo(projectRecord);
|
||||||
|
|
||||||
// Check storage quota
|
// Check storage quota
|
||||||
const quota = await StorageManager.getStorageQuota();
|
const quota = await StorageManager.getStorageQuota();
|
||||||
if (!quota.canStore(manifestData.totalSizeBytes)) {
|
if (estimatedTotalSize > 0 && !quota.canStore(estimatedTotalSize)) {
|
||||||
throw new Error('Insufficient storage space');
|
throw new Error('Insufficient storage space');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,16 +303,28 @@ export function useOfflineMode(
|
|||||||
let downloadedCount = 0;
|
let downloadedCount = 0;
|
||||||
let downloadedSize = 0;
|
let downloadedSize = 0;
|
||||||
|
|
||||||
// First, check which assets are already cached
|
// First, check which assets are already fully cached (not partial)
|
||||||
const assetsToDownload: typeof manifestData.assets = [];
|
// Partial downloads from online preload need to be re-downloaded fully for offline
|
||||||
for (const asset of manifestData.assets) {
|
const assetsToDownload: AssetToCache[] = [];
|
||||||
// Use canonical storage key for checking
|
for (const asset of assets) {
|
||||||
const storageKey = extractStoragePath(asset.url);
|
const assetInfo: CachedAssetInfo | null = await StorageManager.getAssetInfo(asset.storageKey);
|
||||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
|
||||||
if (hasAsset) {
|
if (assetInfo?.exists && !assetInfo.isPartial) {
|
||||||
|
// Fully cached, skip
|
||||||
downloadedCount++;
|
downloadedCount++;
|
||||||
downloadedSize += asset.sizeBytes;
|
downloadedSize += asset.sizeBytes || 0;
|
||||||
|
logger.info('[useOfflineMode] Asset already fully cached', {
|
||||||
|
storageKey: asset.storageKey.slice(-50),
|
||||||
|
});
|
||||||
|
} else if (assetInfo?.exists && assetInfo.isPartial) {
|
||||||
|
// Partial download - need full download for offline
|
||||||
|
logger.info('[useOfflineMode] Upgrading partial download for offline', {
|
||||||
|
storageKey: asset.storageKey.slice(-50),
|
||||||
|
partialSize: assetInfo.sizeBytes,
|
||||||
|
});
|
||||||
|
assetsToDownload.push(asset);
|
||||||
} else {
|
} else {
|
||||||
|
// Not cached at all
|
||||||
assetsToDownload.push(asset);
|
assetsToDownload.push(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,7 +334,7 @@ export function useOfflineMode(
|
|||||||
downloadedBytesRef.current = downloadedSize;
|
downloadedBytesRef.current = downloadedSize;
|
||||||
|
|
||||||
logger.info('[useOfflineMode] Assets to download:', {
|
logger.info('[useOfflineMode] Assets to download:', {
|
||||||
total: manifestData.assets.length,
|
total: assets.length,
|
||||||
alreadyCached: downloadedCount,
|
alreadyCached: downloadedCount,
|
||||||
toDownload: assetsToDownload.length,
|
toDownload: assetsToDownload.length,
|
||||||
});
|
});
|
||||||
@ -314,7 +343,7 @@ export function useOfflineMode(
|
|||||||
setDownloadedAssets(downloadedCount);
|
setDownloadedAssets(downloadedCount);
|
||||||
setDownloadedBytes(downloadedSize);
|
setDownloadedBytes(downloadedSize);
|
||||||
|
|
||||||
if (downloadedCount === manifestData.assets.length) {
|
if (downloadedCount === assets.length) {
|
||||||
// All already downloaded
|
// All already downloaded
|
||||||
setStatus('downloaded');
|
setStatus('downloaded');
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
@ -325,19 +354,39 @@ export function useOfflineMode(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch presigned URLs for assets that need them
|
||||||
|
const storagePaths = assetsToDownload
|
||||||
|
.filter((a) => isRelativeStoragePath(a.originalUrl))
|
||||||
|
.map((a) => a.storageKey);
|
||||||
|
|
||||||
|
let presignedUrls: Record<string, string> = {};
|
||||||
|
if (storagePaths.length > 0) {
|
||||||
|
try {
|
||||||
|
presignedUrls = await queuePresignedUrls(storagePaths);
|
||||||
|
logger.info('[useOfflineMode] Fetched presigned URLs', {
|
||||||
|
count: Object.keys(presignedUrls).length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[useOfflineMode] Failed to fetch presigned URLs, using proxy', {
|
||||||
|
error: err instanceof Error ? err.message : 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Queue all remaining assets for parallel download
|
// Queue all remaining assets for parallel download
|
||||||
// DownloadManager handles concurrency internally
|
// DownloadManager handles concurrency internally
|
||||||
// Progress is tracked via event subscriptions (see useEffect above)
|
// Progress is tracked via event subscriptions (see useEffect above)
|
||||||
for (const asset of assetsToDownload) {
|
for (const asset of assetsToDownload) {
|
||||||
const storageKey = extractStoragePath(asset.url);
|
// Resolve download URL - prefer presigned, fallback to proxy
|
||||||
|
const downloadUrl = presignedUrls[asset.storageKey] || resolveAssetPlaybackUrl(asset.storageKey);
|
||||||
|
|
||||||
downloadManager
|
downloadManager
|
||||||
.addJob({
|
.addJob({
|
||||||
assetId: asset.id,
|
assetId: `offline-${asset.storageKey}`,
|
||||||
projectId,
|
projectId,
|
||||||
url: asset.url,
|
url: downloadUrl,
|
||||||
filename: asset.filename,
|
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||||
variantType: asset.variantType,
|
variantType: 'original',
|
||||||
assetType: asset.assetType,
|
assetType: asset.assetType,
|
||||||
priority:
|
priority:
|
||||||
asset.assetType === 'image'
|
asset.assetType === 'image'
|
||||||
@ -345,14 +394,14 @@ export function useOfflineMode(
|
|||||||
: asset.assetType === 'video'
|
: asset.assetType === 'video'
|
||||||
? 50
|
? 50
|
||||||
: 75,
|
: 75,
|
||||||
storageKey,
|
storageKey: asset.storageKey,
|
||||||
createBlobUrl: true, // Create blob URL for instant display
|
createBlobUrl: true, // Create blob URL for instant display
|
||||||
persist: true, // Persist for resume after page refresh
|
persist: true, // Persist for resume after page refresh
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
// Errors handled by DownloadManager retry logic and events
|
// Errors handled by DownloadManager retry logic and events
|
||||||
logger.error('[useOfflineMode] Asset download failed', {
|
logger.error('[useOfflineMode] Asset download failed', {
|
||||||
assetId: asset.id,
|
storageKey: asset.storageKey.slice(-50),
|
||||||
error: err?.message,
|
error: err?.message,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -368,7 +417,7 @@ export function useOfflineMode(
|
|||||||
setStatus('error');
|
setStatus('error');
|
||||||
await OfflineDbManager.updateProjectStatus(projectId, 'error');
|
await OfflineDbManager.updateProjectStatus(projectId, 'error');
|
||||||
}
|
}
|
||||||
}, [projectId, projectSlug, projectName, enabled, fetchManifest]);
|
}, [projectId, projectSlug, projectName, enabled, discoverAssets]);
|
||||||
|
|
||||||
// Pause download
|
// Pause download
|
||||||
const pauseDownload = useCallback(() => {
|
const pauseDownload = useCallback(() => {
|
||||||
@ -395,7 +444,7 @@ export function useOfflineMode(
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Reset refs
|
// Reset refs
|
||||||
manifestRef.current = null;
|
assetsRef.current = [];
|
||||||
downloadedCountRef.current = 0;
|
downloadedCountRef.current = 0;
|
||||||
downloadedBytesRef.current = 0;
|
downloadedBytesRef.current = 0;
|
||||||
|
|
||||||
@ -419,15 +468,15 @@ export function useOfflineMode(
|
|||||||
setTotalBytes(0);
|
setTotalBytes(0);
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Check for updates
|
// Check for updates by comparing discovered assets with stored project
|
||||||
const checkForUpdates = useCallback(async (): Promise<boolean> => {
|
const checkForUpdates = useCallback(async (): Promise<boolean> => {
|
||||||
if (!projectId || !projectInfo) return false;
|
if (!projectId || !projectInfo || !pages) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const latestManifest = await fetchManifest();
|
const currentAssets = discoverAssets();
|
||||||
if (!latestManifest) return false;
|
|
||||||
|
|
||||||
if (latestManifest.version !== projectInfo.version) {
|
// Simple check: if asset count changed, there are updates
|
||||||
|
if (currentAssets.length !== projectInfo.totalAssets) {
|
||||||
setStatus('outdated');
|
setStatus('outdated');
|
||||||
await OfflineDbManager.updateProjectStatus(projectId, 'outdated');
|
await OfflineDbManager.updateProjectStatus(projectId, 'outdated');
|
||||||
return true;
|
return true;
|
||||||
@ -437,12 +486,12 @@ export function useOfflineMode(
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [projectId, projectInfo, fetchManifest]);
|
}, [projectId, projectInfo, pages, discoverAssets]);
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
const isDownloaded = status === 'downloaded';
|
const isDownloaded = status === 'downloaded';
|
||||||
const isDownloading = status === 'downloading' && !isPaused;
|
const isDownloading = status === 'downloading' && !isPaused;
|
||||||
const estimatedSize = manifest?.totalSizeBytes || totalBytes;
|
const estimatedSize = discoveredAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0) || totalBytes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOfflineCapable,
|
isOfflineCapable,
|
||||||
|
|||||||
363
frontend/src/lib/assetCache/AssetCacheService.ts
Normal file
363
frontend/src/lib/assetCache/AssetCacheService.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* Asset Cache Service
|
||||||
|
*
|
||||||
|
* Unified service for asset caching across online preload and offline download modes.
|
||||||
|
* This service provides a single entry point for:
|
||||||
|
* - Discovering assets from pages/elements/links
|
||||||
|
* - Checking cache status (fully cached, partially cached, or missing)
|
||||||
|
* - Queueing downloads with appropriate parameters
|
||||||
|
* - Managing storage keys consistently
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StorageManager } from '../offline/StorageManager';
|
||||||
|
import { downloadManager } from '../offline/DownloadManager';
|
||||||
|
import {
|
||||||
|
discoverProjectAssets,
|
||||||
|
getPrioritizedAssets,
|
||||||
|
type AssetToCache,
|
||||||
|
type AssetDiscoveryOptions,
|
||||||
|
} from './assetDiscovery';
|
||||||
|
import {
|
||||||
|
queuePresignedUrls,
|
||||||
|
resolveAssetPlaybackUrl,
|
||||||
|
isPresignedUrl,
|
||||||
|
markPresignedUrlFailed,
|
||||||
|
markPresignedUrlsVerified,
|
||||||
|
buildProxyUrl,
|
||||||
|
isRelativeStoragePath,
|
||||||
|
} from '../assetUrl';
|
||||||
|
import { PRELOAD_CONFIG } from '../../config/preload.config';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import type {
|
||||||
|
PreloadPage,
|
||||||
|
PreloadPageLink,
|
||||||
|
PreloadElement,
|
||||||
|
} from '../../types/preload';
|
||||||
|
import type { AssetType, AssetVariantType, CachedAssetInfo } from '../../types/offline';
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export type { CachedAssetInfo };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download progress callback
|
||||||
|
*/
|
||||||
|
export interface DownloadProgress {
|
||||||
|
totalAssets: number;
|
||||||
|
downloadedAssets: number;
|
||||||
|
totalBytes: number;
|
||||||
|
downloadedBytes: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for queueing downloads
|
||||||
|
*/
|
||||||
|
export interface QueueDownloadOptions {
|
||||||
|
mode: 'online' | 'offline';
|
||||||
|
projectId?: string;
|
||||||
|
presignedUrls?: Record<string, string>;
|
||||||
|
onProgress?: (progress: DownloadProgress) => void;
|
||||||
|
currentPageId?: string; // For online mode: current page assets get full downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AssetCacheService - Unified entry point for asset caching
|
||||||
|
*/
|
||||||
|
export class AssetCacheService {
|
||||||
|
/**
|
||||||
|
* Discover all assets for a project from page data
|
||||||
|
* Single source of truth for both online and offline
|
||||||
|
*/
|
||||||
|
static discoverProjectAssets(
|
||||||
|
pages: PreloadPage[],
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
elements: PreloadElement[],
|
||||||
|
options?: AssetDiscoveryOptions,
|
||||||
|
): AssetToCache[] {
|
||||||
|
return discoverProjectAssets(pages, pageLinks, elements, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prioritized assets for preloading based on current page
|
||||||
|
*/
|
||||||
|
static getPrioritizedAssets(
|
||||||
|
currentPageId: string,
|
||||||
|
pages: PreloadPage[],
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
elements: PreloadElement[],
|
||||||
|
neighborPageIds: Array<{ pageId: string; distance: number }>,
|
||||||
|
options?: AssetDiscoveryOptions,
|
||||||
|
): AssetToCache[] {
|
||||||
|
return getPrioritizedAssets(
|
||||||
|
currentPageId,
|
||||||
|
pages,
|
||||||
|
pageLinks,
|
||||||
|
elements,
|
||||||
|
neighborPageIds,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache status for a single asset
|
||||||
|
* Returns whether it exists, if it's partial, and size info
|
||||||
|
*/
|
||||||
|
static async getAssetInfo(storageKey: string): Promise<CachedAssetInfo | null> {
|
||||||
|
return StorageManager.getAssetInfo(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache status for multiple assets efficiently
|
||||||
|
* Returns a map of storageKey -> CachedAssetInfo
|
||||||
|
*/
|
||||||
|
static async checkCacheStatus(
|
||||||
|
storageKeys: string[],
|
||||||
|
): Promise<Map<string, CachedAssetInfo>> {
|
||||||
|
const results = new Map<string, CachedAssetInfo>();
|
||||||
|
|
||||||
|
// Process in parallel with a concurrency limit
|
||||||
|
const BATCH_SIZE = 20;
|
||||||
|
for (let i = 0; i < storageKeys.length; i += BATCH_SIZE) {
|
||||||
|
const batch = storageKeys.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(async (storageKey) => {
|
||||||
|
const info = await StorageManager.getAssetInfo(storageKey);
|
||||||
|
return { storageKey, info };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
batchResults.forEach(({ storageKey, info }) => {
|
||||||
|
if (info) {
|
||||||
|
results.set(storageKey, info);
|
||||||
|
} else {
|
||||||
|
results.set(storageKey, {
|
||||||
|
storageKey,
|
||||||
|
exists: false,
|
||||||
|
isPartial: false,
|
||||||
|
sizeBytes: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter assets to only those needing download
|
||||||
|
* - Not cached at all -> needs download
|
||||||
|
* - Partially cached + offline mode -> needs full download
|
||||||
|
* - Fully cached -> skip
|
||||||
|
*/
|
||||||
|
static async filterAssetsNeedingDownload(
|
||||||
|
assets: AssetToCache[],
|
||||||
|
mode: 'online' | 'offline',
|
||||||
|
): Promise<AssetToCache[]> {
|
||||||
|
const storageKeys = assets.map((a) => a.storageKey);
|
||||||
|
const cacheStatus = await this.checkCacheStatus(storageKeys);
|
||||||
|
|
||||||
|
return assets.filter((asset) => {
|
||||||
|
const status = cacheStatus.get(asset.storageKey);
|
||||||
|
if (!status) return true; // Not in cache, needs download
|
||||||
|
|
||||||
|
if (!status.exists) return true; // Not cached, needs download
|
||||||
|
|
||||||
|
// In offline mode, partial downloads need to be fully downloaded
|
||||||
|
if (mode === 'offline' && status.isPartial) return true;
|
||||||
|
|
||||||
|
// Fully cached, skip
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve download URL for an asset
|
||||||
|
* Prefers presigned URL if available, falls back to proxy
|
||||||
|
*/
|
||||||
|
static resolveDownloadUrl(
|
||||||
|
storageKey: string,
|
||||||
|
presignedUrls?: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
// Check presigned URL cache first
|
||||||
|
if (presignedUrls && presignedUrls[storageKey]) {
|
||||||
|
return presignedUrls[storageKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use resolveAssetPlaybackUrl for fallback
|
||||||
|
return resolveAssetPlaybackUrl(storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue assets for download with unified parameters
|
||||||
|
* Handles both online (partial) and offline (full) modes
|
||||||
|
*/
|
||||||
|
static async queueDownloads(
|
||||||
|
assets: AssetToCache[],
|
||||||
|
options: QueueDownloadOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const { mode, projectId = '', presignedUrls = {}, currentPageId } = options;
|
||||||
|
|
||||||
|
// Fetch presigned URLs for assets that need them
|
||||||
|
const storagePaths = assets
|
||||||
|
.filter((a) => isRelativeStoragePath(a.originalUrl))
|
||||||
|
.map((a) => a.storageKey);
|
||||||
|
|
||||||
|
let resolvedPresignedUrls = { ...presignedUrls };
|
||||||
|
if (storagePaths.length > 0 && mode === 'online') {
|
||||||
|
try {
|
||||||
|
const fetchedUrls = await queuePresignedUrls(storagePaths);
|
||||||
|
resolvedPresignedUrls = { ...resolvedPresignedUrls, ...fetchedUrls };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[AssetCacheService] Failed to fetch presigned URLs', {
|
||||||
|
error: error instanceof Error ? error.message : 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue each asset for download
|
||||||
|
for (const asset of assets) {
|
||||||
|
const downloadUrl = this.resolveDownloadUrl(
|
||||||
|
asset.storageKey,
|
||||||
|
resolvedPresignedUrls,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!downloadUrl) continue;
|
||||||
|
|
||||||
|
// Determine download parameters based on mode
|
||||||
|
const isOnlineMode = mode === 'online';
|
||||||
|
const isCurrentPageAsset = currentPageId && asset.pageId === currentPageId;
|
||||||
|
|
||||||
|
// Online mode: use partial downloads for neighbor page media
|
||||||
|
// Offline mode: always full downloads
|
||||||
|
const maxBytes = this.getMaxBytesForAsset(
|
||||||
|
asset.assetType,
|
||||||
|
isOnlineMode,
|
||||||
|
!isCurrentPageAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create blob URL for images (instant navigation) and full downloads
|
||||||
|
const createBlobUrl =
|
||||||
|
asset.assetType === 'image' || maxBytes === undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadManager
|
||||||
|
.addJob({
|
||||||
|
assetId: `${mode}-${asset.storageKey}`,
|
||||||
|
projectId,
|
||||||
|
url: downloadUrl,
|
||||||
|
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||||
|
variantType: 'original' as AssetVariantType,
|
||||||
|
assetType: asset.assetType,
|
||||||
|
priority: asset.priority,
|
||||||
|
storageKey: asset.storageKey,
|
||||||
|
createBlobUrl,
|
||||||
|
persist: mode === 'offline', // Only persist for offline mode
|
||||||
|
maxBytes,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (isPresignedUrl(downloadUrl)) {
|
||||||
|
markPresignedUrlsVerified();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
logger.error('[AssetCacheService] Download failed', {
|
||||||
|
storageKey: asset.storageKey.slice(-50),
|
||||||
|
error: err?.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry with proxy if presigned URL failed
|
||||||
|
if (isPresignedUrl(downloadUrl)) {
|
||||||
|
markPresignedUrlFailed(asset.storageKey);
|
||||||
|
const proxyUrl = buildProxyUrl(asset.storageKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadManager.addJob({
|
||||||
|
assetId: `${mode}-retry-${asset.storageKey}`,
|
||||||
|
projectId,
|
||||||
|
url: proxyUrl,
|
||||||
|
filename: asset.storageKey.split('/').pop() || 'asset',
|
||||||
|
variantType: 'original' as AssetVariantType,
|
||||||
|
assetType: asset.assetType,
|
||||||
|
priority: asset.priority,
|
||||||
|
storageKey: asset.storageKey,
|
||||||
|
createBlobUrl,
|
||||||
|
persist: mode === 'offline',
|
||||||
|
maxBytes,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore retry failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[AssetCacheService] Failed to queue download', {
|
||||||
|
storageKey: asset.storageKey.slice(-50),
|
||||||
|
error: error instanceof Error ? error.message : 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[AssetCacheService] Downloads queued', {
|
||||||
|
mode,
|
||||||
|
count: assets.length,
|
||||||
|
projectId: projectId || 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine max bytes for partial preload (online mode only)
|
||||||
|
*/
|
||||||
|
private static getMaxBytesForAsset(
|
||||||
|
assetType: AssetType,
|
||||||
|
isOnlineMode: boolean,
|
||||||
|
isNeighborPage: boolean,
|
||||||
|
): number | undefined {
|
||||||
|
// Offline mode: always full downloads
|
||||||
|
if (!isOnlineMode) return undefined;
|
||||||
|
|
||||||
|
// Partial preload disabled
|
||||||
|
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
|
||||||
|
|
||||||
|
// Transitions always use partial preload
|
||||||
|
if (assetType === 'transition') {
|
||||||
|
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current page assets should be fully downloaded
|
||||||
|
if (!isNeighborPage) return undefined;
|
||||||
|
|
||||||
|
// Neighbor page media uses partial preload
|
||||||
|
switch (assetType) {
|
||||||
|
case 'video':
|
||||||
|
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
|
||||||
|
case 'audio':
|
||||||
|
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
|
||||||
|
default:
|
||||||
|
return undefined; // Images need full download for display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear project cache
|
||||||
|
*/
|
||||||
|
static async clearProjectCache(projectId: string): Promise<void> {
|
||||||
|
await StorageManager.deleteProjectAssets(projectId);
|
||||||
|
logger.info('[AssetCacheService] Project cache cleared', { projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ready blob URL for instant display (O(1) lookup)
|
||||||
|
*/
|
||||||
|
static getReadyBlobUrl(url: string): string | null {
|
||||||
|
return downloadManager.getReadyBlobUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if asset is preloaded (in memory blob URL cache)
|
||||||
|
*/
|
||||||
|
static hasReadyBlobUrl(url: string): boolean {
|
||||||
|
return downloadManager.getReadyBlobUrl(url) !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetCacheService;
|
||||||
408
frontend/src/lib/assetCache/assetDiscovery.ts
Normal file
408
frontend/src/lib/assetCache/assetDiscovery.ts
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Asset Discovery Module
|
||||||
|
*
|
||||||
|
* Shared asset extraction logic for both online preload and offline download systems.
|
||||||
|
* This module provides a single source of truth for discovering assets from pages,
|
||||||
|
* elements, and navigation links.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PRELOAD_CONFIG } from '../../config/preload.config';
|
||||||
|
import { extractStoragePath } from '../assetUrl';
|
||||||
|
import type {
|
||||||
|
PreloadPage,
|
||||||
|
PreloadPageLink,
|
||||||
|
PreloadElement,
|
||||||
|
PreloadAssetInfo,
|
||||||
|
} from '../../types/preload';
|
||||||
|
import type { AssetType } from '../../types/offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset information for caching
|
||||||
|
*/
|
||||||
|
export interface AssetToCache {
|
||||||
|
storageKey: string;
|
||||||
|
originalUrl: string;
|
||||||
|
assetType: AssetType;
|
||||||
|
pageId: string;
|
||||||
|
priority: number;
|
||||||
|
sizeBytes?: number;
|
||||||
|
isTransition?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for asset discovery
|
||||||
|
*/
|
||||||
|
export interface AssetDiscoveryOptions {
|
||||||
|
includeTransitions?: boolean;
|
||||||
|
includeBackgrounds?: boolean;
|
||||||
|
includeElementAssets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: AssetDiscoveryOptions = {
|
||||||
|
includeTransitions: true,
|
||||||
|
includeBackgrounds: true,
|
||||||
|
includeElementAssets: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify asset type based on field name
|
||||||
|
*/
|
||||||
|
export function classifyAssetType(
|
||||||
|
fieldName: string,
|
||||||
|
url: string,
|
||||||
|
): AssetType {
|
||||||
|
const lowerField = fieldName.toLowerCase();
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
|
||||||
|
// Check field name first (more reliable)
|
||||||
|
if (lowerField.includes('transition')) {
|
||||||
|
return 'transition';
|
||||||
|
}
|
||||||
|
if (lowerField.includes('video')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (lowerField.includes('audio')) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lowerField.includes('image') ||
|
||||||
|
lowerField.includes('icon') ||
|
||||||
|
lowerField.includes('poster') ||
|
||||||
|
lowerField.includes('thumbnail') ||
|
||||||
|
lowerField.includes('src')
|
||||||
|
) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check URL extension
|
||||||
|
if (lowerUrl.match(/\.(mp4|webm|mov|avi|mkv)/)) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (lowerUrl.match(/\.(mp3|wav|ogg|aac|m4a)/)) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
if (lowerUrl.match(/\.(jpg|jpeg|png|gif|webp|avif|svg)/)) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract assets from element content_json
|
||||||
|
*/
|
||||||
|
export function extractElementAssets(
|
||||||
|
elements: PreloadElement[],
|
||||||
|
pageId?: string,
|
||||||
|
): AssetToCache[] {
|
||||||
|
const assets: AssetToCache[] = [];
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
|
||||||
|
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||||
|
|
||||||
|
const processElement = (element: PreloadElement) => {
|
||||||
|
const elementPageId = pageId || element.pageId || '';
|
||||||
|
if (!element.content_json) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content =
|
||||||
|
typeof element.content_json === 'string'
|
||||||
|
? JSON.parse(element.content_json)
|
||||||
|
: element.content_json;
|
||||||
|
|
||||||
|
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 storageKey = extractStoragePath(value);
|
||||||
|
if (storageKey && !seenUrls.has(storageKey)) {
|
||||||
|
seenUrls.add(storageKey);
|
||||||
|
const assetType = classifyAssetType(key, value);
|
||||||
|
assets.push({
|
||||||
|
storageKey,
|
||||||
|
originalUrl: value,
|
||||||
|
assetType,
|
||||||
|
pageId: elementPageId,
|
||||||
|
priority: 0, // Will be calculated later
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
checkObject(value as Record<string, unknown>, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkObject(content);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.forEach(processElement);
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract background assets from a page
|
||||||
|
*/
|
||||||
|
export function extractPageBackgroundAssets(page: PreloadPage): AssetToCache[] {
|
||||||
|
const assets: AssetToCache[] = [];
|
||||||
|
|
||||||
|
if (page.background_image_url) {
|
||||||
|
const storageKey = extractStoragePath(page.background_image_url);
|
||||||
|
if (storageKey) {
|
||||||
|
assets.push({
|
||||||
|
storageKey,
|
||||||
|
originalUrl: page.background_image_url,
|
||||||
|
assetType: 'image',
|
||||||
|
pageId: page.id,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.background_video_url) {
|
||||||
|
const storageKey = extractStoragePath(page.background_video_url);
|
||||||
|
if (storageKey) {
|
||||||
|
assets.push({
|
||||||
|
storageKey,
|
||||||
|
originalUrl: page.background_video_url,
|
||||||
|
assetType: 'video',
|
||||||
|
pageId: page.id,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.background_audio_url) {
|
||||||
|
const storageKey = extractStoragePath(page.background_audio_url);
|
||||||
|
if (storageKey) {
|
||||||
|
assets.push({
|
||||||
|
storageKey,
|
||||||
|
originalUrl: page.background_audio_url,
|
||||||
|
assetType: 'audio',
|
||||||
|
pageId: page.id,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract transition video assets from page links
|
||||||
|
*/
|
||||||
|
export function extractTransitionAssets(
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
fromPageId?: string,
|
||||||
|
): AssetToCache[] {
|
||||||
|
const assets: AssetToCache[] = [];
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
|
||||||
|
const activeLinks = pageLinks.filter((link) => {
|
||||||
|
if (link.is_active === false) return false;
|
||||||
|
if (fromPageId && link.from_pageId !== fromPageId) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
activeLinks.forEach((link) => {
|
||||||
|
const transition = link.transition as { video_url?: string } | undefined;
|
||||||
|
if (transition?.video_url) {
|
||||||
|
const storageKey = extractStoragePath(transition.video_url);
|
||||||
|
if (storageKey && !seenUrls.has(storageKey)) {
|
||||||
|
seenUrls.add(storageKey);
|
||||||
|
assets.push({
|
||||||
|
storageKey,
|
||||||
|
originalUrl: transition.video_url,
|
||||||
|
assetType: 'transition',
|
||||||
|
pageId: link.from_pageId || '',
|
||||||
|
priority: 0,
|
||||||
|
isTransition: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate priority based on asset type and page relationship
|
||||||
|
*/
|
||||||
|
export function calculateAssetPriority(
|
||||||
|
assetType: AssetType,
|
||||||
|
isCurrentPage: boolean,
|
||||||
|
distance = 0,
|
||||||
|
): number {
|
||||||
|
const typePriority = PRELOAD_CONFIG.priority.assetType[assetType] || 0;
|
||||||
|
|
||||||
|
if (isCurrentPage) {
|
||||||
|
return PRELOAD_CONFIG.priority.currentPage + typePriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For neighbor pages, priority decreases with distance
|
||||||
|
const basePriority =
|
||||||
|
distance > 0 ? PRELOAD_CONFIG.priority.neighborBase / distance : 0;
|
||||||
|
|
||||||
|
return basePriority + typePriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all assets for a set of pages
|
||||||
|
* This is the main entry point for asset discovery
|
||||||
|
*/
|
||||||
|
export function discoverPageAssets(
|
||||||
|
pageIds: string[],
|
||||||
|
pages: PreloadPage[],
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
elements: PreloadElement[],
|
||||||
|
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||||
|
): AssetToCache[] {
|
||||||
|
const assets: AssetToCache[] = [];
|
||||||
|
const seenStorageKeys = new Set<string>();
|
||||||
|
|
||||||
|
const addAsset = (asset: AssetToCache) => {
|
||||||
|
if (!seenStorageKeys.has(asset.storageKey)) {
|
||||||
|
seenStorageKeys.add(asset.storageKey);
|
||||||
|
assets.push(asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pageIds.forEach((pageId) => {
|
||||||
|
const page = pages.find((p) => p.id === pageId);
|
||||||
|
|
||||||
|
// Extract background assets
|
||||||
|
if (options.includeBackgrounds && page) {
|
||||||
|
extractPageBackgroundAssets(page).forEach(addAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract element assets
|
||||||
|
if (options.includeElementAssets) {
|
||||||
|
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||||
|
extractElementAssets(pageElements, pageId).forEach(addAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract transition assets
|
||||||
|
if (options.includeTransitions) {
|
||||||
|
extractTransitionAssets(pageLinks, pageId).forEach(addAsset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all assets for a project (used by offline download)
|
||||||
|
* Includes all pages, all elements, and all transitions
|
||||||
|
*/
|
||||||
|
export function discoverProjectAssets(
|
||||||
|
pages: PreloadPage[],
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
elements: PreloadElement[],
|
||||||
|
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||||
|
): AssetToCache[] {
|
||||||
|
const pageIds = pages.map((p) => p.id);
|
||||||
|
return discoverPageAssets(pageIds, pages, pageLinks, elements, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prioritized assets for preloading based on current page and neighbor depth
|
||||||
|
*/
|
||||||
|
export function getPrioritizedAssets(
|
||||||
|
currentPageId: string,
|
||||||
|
pages: PreloadPage[],
|
||||||
|
pageLinks: PreloadPageLink[],
|
||||||
|
elements: PreloadElement[],
|
||||||
|
neighborPageIds: Array<{ pageId: string; distance: number }>,
|
||||||
|
options: AssetDiscoveryOptions = DEFAULT_OPTIONS,
|
||||||
|
): AssetToCache[] {
|
||||||
|
const assets: AssetToCache[] = [];
|
||||||
|
const seenStorageKeys = new Set<string>();
|
||||||
|
|
||||||
|
const addAssetWithPriority = (
|
||||||
|
asset: AssetToCache,
|
||||||
|
isCurrentPage: boolean,
|
||||||
|
distance: number,
|
||||||
|
) => {
|
||||||
|
if (seenStorageKeys.has(asset.storageKey)) return;
|
||||||
|
seenStorageKeys.add(asset.storageKey);
|
||||||
|
|
||||||
|
assets.push({
|
||||||
|
...asset,
|
||||||
|
priority: calculateAssetPriority(asset.assetType, isCurrentPage, distance),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current page assets (highest priority)
|
||||||
|
const currentPage = pages.find((p) => p.id === currentPageId);
|
||||||
|
if (currentPage) {
|
||||||
|
if (options.includeBackgrounds) {
|
||||||
|
extractPageBackgroundAssets(currentPage).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, true, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeElementAssets) {
|
||||||
|
const currentPageElements = elements.filter(
|
||||||
|
(el) => el.pageId === currentPageId,
|
||||||
|
);
|
||||||
|
extractElementAssets(currentPageElements, currentPageId).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, true, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeTransitions) {
|
||||||
|
extractTransitionAssets(pageLinks, currentPageId).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, true, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbor page assets (decreasing priority with distance)
|
||||||
|
neighborPageIds.forEach(({ pageId, distance }) => {
|
||||||
|
const page = pages.find((p) => p.id === pageId);
|
||||||
|
if (page) {
|
||||||
|
if (options.includeBackgrounds) {
|
||||||
|
extractPageBackgroundAssets(page).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, false, distance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeElementAssets) {
|
||||||
|
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||||
|
extractElementAssets(pageElements, pageId).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, false, distance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.includeTransitions) {
|
||||||
|
extractTransitionAssets(pageLinks, pageId).forEach((asset) =>
|
||||||
|
addAssetWithPriority(asset, false, distance),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by priority (highest first)
|
||||||
|
return assets.sort((a, b) => b.priority - a.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AssetToCache to PreloadAssetInfo for backward compatibility
|
||||||
|
*/
|
||||||
|
export function toPreloadAssetInfo(asset: AssetToCache): PreloadAssetInfo {
|
||||||
|
// Map 'transition' to appropriate type for PreloadAssetInfo
|
||||||
|
const assetType =
|
||||||
|
asset.assetType === 'transition' || asset.assetType === 'other'
|
||||||
|
? 'other'
|
||||||
|
: asset.assetType;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: asset.originalUrl,
|
||||||
|
pageId: asset.pageId,
|
||||||
|
assetType: assetType as 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||||
|
priority: asset.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
frontend/src/lib/assetCache/index.ts
Normal file
22
frontend/src/lib/assetCache/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Asset Cache Module
|
||||||
|
*
|
||||||
|
* Unified asset caching for online preload and offline download.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { AssetCacheService, type DownloadProgress, type QueueDownloadOptions } from './AssetCacheService';
|
||||||
|
export type { CachedAssetInfo } from '../../types/offline';
|
||||||
|
|
||||||
|
export {
|
||||||
|
discoverProjectAssets,
|
||||||
|
discoverPageAssets,
|
||||||
|
getPrioritizedAssets,
|
||||||
|
extractElementAssets,
|
||||||
|
extractPageBackgroundAssets,
|
||||||
|
extractTransitionAssets,
|
||||||
|
calculateAssetPriority,
|
||||||
|
classifyAssetType,
|
||||||
|
toPreloadAssetInfo,
|
||||||
|
type AssetToCache,
|
||||||
|
type AssetDiscoveryOptions,
|
||||||
|
} from './assetDiscovery';
|
||||||
@ -84,7 +84,7 @@ class DownloadManagerClass {
|
|||||||
const storageKey = params.storageKey || extractStoragePath(params.url);
|
const storageKey = params.storageKey || extractStoragePath(params.url);
|
||||||
const isPartialDownload = params.maxBytes !== undefined;
|
const isPartialDownload = params.maxBytes !== undefined;
|
||||||
|
|
||||||
// For partial downloads, check session cache (not persisted to storage)
|
// For partial downloads, check session cache first (fast path)
|
||||||
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
|
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
|
||||||
logger.info('[DownloadManager] Partial download already ready (session)', {
|
logger.info('[DownloadManager] Partial download already ready (session)', {
|
||||||
storageKey: storageKey.slice(-50),
|
storageKey: storageKey.slice(-50),
|
||||||
@ -92,16 +92,29 @@ class DownloadManagerClass {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already downloaded using canonical key (full downloads only)
|
// Check cache status using getAssetInfo for smart handling
|
||||||
if (!isPartialDownload) {
|
const assetInfo = await StorageManager.getAssetInfo(storageKey);
|
||||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
|
||||||
if (hasAsset) {
|
if (assetInfo?.exists) {
|
||||||
// Already cached - create blob URL if requested
|
if (isPartialDownload) {
|
||||||
|
// For partial downloads, any cached version is sufficient
|
||||||
|
this.partialDownloadsReady.add(storageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assetInfo.isPartial) {
|
||||||
|
// Fully cached - create blob URL if requested
|
||||||
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
|
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
|
||||||
await this.createBlobUrlFromCache(storageKey);
|
await this.createBlobUrlFromCache(storageKey);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asset exists but is partial - full download requested
|
||||||
|
// Continue to download the full asset
|
||||||
|
logger.info('[DownloadManager] Upgrading partial to full download', {
|
||||||
|
storageKey: storageKey.slice(-50),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already in queue (use storageKey for deduplication)
|
// Check if already in queue (use storageKey for deduplication)
|
||||||
@ -339,23 +352,18 @@ class DownloadManagerClass {
|
|||||||
job.progress = 100;
|
job.progress = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For partial downloads, don't store to cache (not useful for offline)
|
// Store the asset using canonical storage key
|
||||||
// Full downloads are stored for offline access
|
// Partial downloads are now stored with isPartial: true for offline mode awareness
|
||||||
if (!job.isPartial) {
|
await StorageManager.storeAsset(job.storageKey, blob, {
|
||||||
// Store the asset using canonical storage key
|
id: job.assetId,
|
||||||
await StorageManager.storeAsset(job.storageKey, blob, {
|
projectId: job.projectId,
|
||||||
id: job.assetId,
|
filename: job.filename,
|
||||||
projectId: job.projectId,
|
variantType: job.variantType,
|
||||||
filename: job.filename,
|
assetType: job.assetType,
|
||||||
variantType: job.variantType,
|
isPartial: job.isPartial || false,
|
||||||
assetType: job.assetType,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Create blob URL if requested
|
if (job.isPartial) {
|
||||||
if (job.createBlobUrl) {
|
|
||||||
await this.createBlobUrlFromCache(job.storageKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Mark partial download as ready in session cache
|
// Mark partial download as ready in session cache
|
||||||
this.partialDownloadsReady.add(job.storageKey);
|
this.partialDownloadsReady.add(job.storageKey);
|
||||||
|
|
||||||
@ -363,10 +371,15 @@ class DownloadManagerClass {
|
|||||||
// When the browser fetches the full media, SW will cache it using the storage key
|
// When the browser fetches the full media, SW will cache it using the storage key
|
||||||
this.registerUrlForCaching(job.url, job.storageKey);
|
this.registerUrlForCaching(job.url, job.storageKey);
|
||||||
|
|
||||||
logger.info('[DownloadManager] Partial download complete', {
|
logger.info('[DownloadManager] Partial download complete (stored)', {
|
||||||
storageKey: job.storageKey.slice(-50),
|
storageKey: job.storageKey.slice(-50),
|
||||||
bytesLoaded: job.bytesLoaded,
|
bytesLoaded: job.bytesLoaded,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Create blob URL if requested (full downloads only)
|
||||||
|
if (job.createBlobUrl) {
|
||||||
|
await this.createBlobUrlFromCache(job.storageKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as completed
|
// Mark as completed
|
||||||
@ -378,6 +391,7 @@ class DownloadManagerClass {
|
|||||||
downloadEventBus.emitPreloadComplete({
|
downloadEventBus.emitPreloadComplete({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
assetId: job.assetId,
|
assetId: job.assetId,
|
||||||
|
storageKey: job.storageKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
job.resolve?.();
|
job.resolve?.();
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
AssetVariantType,
|
AssetVariantType,
|
||||||
AssetType,
|
AssetType,
|
||||||
StorageQuotaInfo,
|
StorageQuotaInfo,
|
||||||
|
CachedAssetInfo,
|
||||||
} from '../../types/offline';
|
} from '../../types/offline';
|
||||||
|
|
||||||
export class StorageManager {
|
export class StorageManager {
|
||||||
@ -97,6 +98,7 @@ export class StorageManager {
|
|||||||
filename: string;
|
filename: string;
|
||||||
variantType: AssetVariantType;
|
variantType: AssetVariantType;
|
||||||
assetType: AssetType;
|
assetType: AssetType;
|
||||||
|
isPartial?: boolean; // True if this is a partial download
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Normalize URL to canonical storage key
|
// Normalize URL to canonical storage key
|
||||||
@ -116,6 +118,7 @@ export class StorageManager {
|
|||||||
sizeBytes,
|
sizeBytes,
|
||||||
blob,
|
blob,
|
||||||
downloadedAt: Date.now(),
|
downloadedAt: Date.now(),
|
||||||
|
isPartial: metadata.isPartial || false,
|
||||||
};
|
};
|
||||||
await OfflineDbManager.storeAsset(asset);
|
await OfflineDbManager.storeAsset(asset);
|
||||||
} else {
|
} else {
|
||||||
@ -127,6 +130,8 @@ export class StorageManager {
|
|||||||
'Content-Length': String(sizeBytes),
|
'Content-Length': String(sizeBytes),
|
||||||
'X-Asset-Id': metadata.id,
|
'X-Asset-Id': metadata.id,
|
||||||
'X-Project-Id': metadata.projectId,
|
'X-Project-Id': metadata.projectId,
|
||||||
|
'X-Is-Partial': metadata.isPartial ? 'true' : 'false',
|
||||||
|
'X-Downloaded-At': String(Date.now()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await cache.put(storageKey, response); // Use normalized storage key
|
await cache.put(storageKey, response); // Use normalized storage key
|
||||||
@ -179,6 +184,56 @@ export class StorageManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a cached asset
|
||||||
|
* Returns null if asset is not cached
|
||||||
|
*/
|
||||||
|
static async getAssetInfo(url: string): Promise<CachedAssetInfo | null> {
|
||||||
|
// Normalize URL to canonical storage key
|
||||||
|
const storageKey = extractStoragePath(url);
|
||||||
|
|
||||||
|
// Check IndexedDB first (for large files)
|
||||||
|
const dbAsset = await OfflineDbManager.getAssetByUrl(storageKey);
|
||||||
|
if (dbAsset) {
|
||||||
|
return {
|
||||||
|
storageKey,
|
||||||
|
exists: true,
|
||||||
|
isPartial: dbAsset.isPartial || false,
|
||||||
|
sizeBytes: dbAsset.sizeBytes,
|
||||||
|
downloadedAt: dbAsset.downloadedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Cache API
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
|
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||||
|
const response = await cache.match(storageKey);
|
||||||
|
if (response) {
|
||||||
|
const isPartial = response.headers.get('X-Is-Partial') === 'true';
|
||||||
|
const downloadedAt = response.headers.get('X-Downloaded-At');
|
||||||
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageKey,
|
||||||
|
exists: true,
|
||||||
|
isPartial,
|
||||||
|
sizeBytes: contentLength ? parseInt(contentLength, 10) : 0,
|
||||||
|
downloadedAt: downloadedAt ? parseInt(downloadedAt, 10) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an asset is fully cached (not partial)
|
||||||
|
*/
|
||||||
|
static async hasFullAsset(url: string): Promise<boolean> {
|
||||||
|
const info = await this.getAssetInfo(url);
|
||||||
|
return info !== null && info.exists && !info.isPartial;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an asset from all storage locations (by normalized storage key)
|
* Delete an asset from all storage locations (by normalized storage key)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -76,6 +76,16 @@ export interface OfflineAsset {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
downloadedAt: number;
|
downloadedAt: number;
|
||||||
|
isPartial?: boolean; // True if this is a partial download (only first N bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache status information for an asset
|
||||||
|
export interface CachedAssetInfo {
|
||||||
|
storageKey: string;
|
||||||
|
exists: boolean;
|
||||||
|
isPartial: boolean;
|
||||||
|
sizeBytes: number;
|
||||||
|
downloadedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download queue item for persistence
|
// Download queue item for persistence
|
||||||
@ -113,27 +123,6 @@ export interface StorageQuotaInfo {
|
|||||||
canStore: (bytes: number) => boolean;
|
canStore: (bytes: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline manifest from backend
|
|
||||||
export interface OfflineManifest {
|
|
||||||
version: string;
|
|
||||||
projectId: string;
|
|
||||||
projectSlug: string;
|
|
||||||
assets: ManifestAsset[];
|
|
||||||
totalSizeBytes: number;
|
|
||||||
generatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ManifestAsset {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
filename: string;
|
|
||||||
variantType: AssetVariantType;
|
|
||||||
assetType: AssetType;
|
|
||||||
mimeType: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
pageIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preload orchestrator state
|
// Preload orchestrator state
|
||||||
export interface PreloadState {
|
export interface PreloadState {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@ -172,6 +161,7 @@ export interface PreloadProgressEvent {
|
|||||||
export interface PreloadCompleteEvent {
|
export interface PreloadCompleteEvent {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
storageKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreloadErrorEvent {
|
export interface PreloadErrorEvent {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user