560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
/**
|
|
* useOfflineMode Hook
|
|
*
|
|
* 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 { 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 {
|
|
queuePresignedUrls,
|
|
resolveAssetPlaybackUrl,
|
|
isRelativeStoragePath,
|
|
} from '../lib/assetUrl';
|
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
|
import { discoverProjectAssets, type AssetToCache } from '../lib/assetCache';
|
|
import { logger } from '../lib/logger';
|
|
import type {
|
|
CachedAssetInfo,
|
|
OfflineProject,
|
|
ProjectOfflineStatus,
|
|
ProjectDownloadProgressEvent,
|
|
PreloadCompleteEvent,
|
|
PreloadErrorEvent,
|
|
} from '../types/offline';
|
|
import type { PreloadPage } from '../types/preload';
|
|
|
|
interface UseOfflineModeOptions {
|
|
projectId: string | null;
|
|
projectSlug?: string;
|
|
projectName?: string;
|
|
/** Pages data for frontend asset discovery (required for offline download) */
|
|
pages?: PreloadPage[];
|
|
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,
|
|
pages,
|
|
enabled = true,
|
|
} = options;
|
|
|
|
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
|
|
const [discoveredAssets, setDiscoveredAssets] = useState<AssetToCache[]>([]);
|
|
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);
|
|
|
|
// Track assets for event-driven progress
|
|
const assetsRef = useRef<AssetToCache[]>([]);
|
|
const downloadedCountRef = useRef(0);
|
|
const downloadedBytesRef = useRef(0);
|
|
|
|
// 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 individual asset complete events for progress tracking
|
|
useEffect(() => {
|
|
if (!projectId) return;
|
|
|
|
const handleComplete = (data: PreloadCompleteEvent) => {
|
|
// Only track if we have discovered assets
|
|
if (!assetsRef.current.length) return;
|
|
|
|
// Find the asset to get its size
|
|
const asset = assetsRef.current.find(
|
|
(a) =>
|
|
a.storageKey === data.storageKey ||
|
|
`offline-${a.storageKey}` === data.assetId,
|
|
);
|
|
const assetSize = asset?.sizeBytes || 0;
|
|
|
|
// Update counters
|
|
downloadedCountRef.current += 1;
|
|
downloadedBytesRef.current += assetSize;
|
|
|
|
const downloaded = downloadedCountRef.current;
|
|
const dlBytes = downloadedBytesRef.current;
|
|
const total = assetsRef.current.length;
|
|
const totalSize = assetsRef.current.reduce(
|
|
(sum, a) => sum + (a.sizeBytes || 0),
|
|
0,
|
|
);
|
|
|
|
// Update state
|
|
setDownloadedAssets(downloaded);
|
|
setDownloadedBytes(dlBytes);
|
|
|
|
const prog = Math.round((downloaded / total) * 100);
|
|
setProgress(prog);
|
|
|
|
// Emit project progress event
|
|
downloadEventBus.emitProjectProgress({
|
|
projectId,
|
|
progress: prog,
|
|
downloadedAssets: downloaded,
|
|
totalAssets: total,
|
|
downloadedBytes: dlBytes,
|
|
totalBytes: totalSize,
|
|
});
|
|
|
|
// Update IndexedDB
|
|
OfflineDbManager.updateProjectProgress(projectId, downloaded, dlBytes);
|
|
|
|
// Check if complete
|
|
if (downloaded >= total) {
|
|
setStatus('downloaded');
|
|
OfflineDbManager.updateProjectStatus(projectId, 'downloaded');
|
|
downloadEventBus.emitProjectComplete({ projectId });
|
|
logger.info('[useOfflineMode] Download complete', { projectId });
|
|
}
|
|
};
|
|
|
|
const handleError = (data: PreloadErrorEvent) => {
|
|
logger.error('[useOfflineMode] Asset download error', {
|
|
assetId: data.assetId,
|
|
error: data.error,
|
|
});
|
|
};
|
|
|
|
const unsubComplete = downloadEventBus.on(
|
|
OFFLINE_CONFIG.events.preloadComplete as Parameters<
|
|
typeof downloadEventBus.on
|
|
>[0],
|
|
handleComplete as Parameters<typeof downloadEventBus.on>[1],
|
|
);
|
|
const unsubError = downloadEventBus.on(
|
|
OFFLINE_CONFIG.events.preloadError as Parameters<
|
|
typeof downloadEventBus.on
|
|
>[0],
|
|
handleError as Parameters<typeof downloadEventBus.on>[1],
|
|
);
|
|
|
|
return () => {
|
|
unsubComplete();
|
|
unsubError();
|
|
};
|
|
}, [projectId]);
|
|
|
|
// Listen for project progress events (allows external components to sync state)
|
|
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]);
|
|
|
|
// Discover assets using frontend logic (same as online preloading)
|
|
const discoverAssets = useCallback((): AssetToCache[] => {
|
|
if (!pages || pages.length === 0) {
|
|
logger.warn('[useOfflineMode] No pages provided for asset discovery');
|
|
return [];
|
|
}
|
|
|
|
// Extract pageLinks and elements from all pages
|
|
const { pageLinks, preloadElements } = extractPageLinksAndElements(pages);
|
|
|
|
// Use shared asset discovery (same as online preload)
|
|
const assets = discoverProjectAssets(pages, pageLinks, preloadElements);
|
|
|
|
logger.info('[useOfflineMode] Discovered assets from pages', {
|
|
pageCount: pages.length,
|
|
linkCount: pageLinks.length,
|
|
elementCount: preloadElements.length,
|
|
assetCount: assets.length,
|
|
});
|
|
|
|
return assets;
|
|
}, [pages]);
|
|
|
|
// Start download
|
|
const startDownload = useCallback(async (): Promise<void> => {
|
|
if (!projectId || !enabled) return;
|
|
|
|
setError(null);
|
|
setStatus('downloading');
|
|
setIsPaused(false);
|
|
|
|
try {
|
|
// Discover assets from pages (frontend-only, no backend call)
|
|
const assets = discoverAssets();
|
|
if (assets.length === 0) {
|
|
throw new Error(
|
|
'No assets discovered. Make sure pages data is provided.',
|
|
);
|
|
}
|
|
|
|
// Store assets for event-driven progress tracking
|
|
assetsRef.current = assets;
|
|
setDiscoveredAssets(assets);
|
|
setTotalAssets(assets.length);
|
|
|
|
// 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
|
|
const projectRecord: OfflineProject = {
|
|
id: projectId,
|
|
slug: projectSlug || '',
|
|
name: projectName || '',
|
|
status: 'downloading',
|
|
totalAssets: assets.length,
|
|
downloadedAssets: 0,
|
|
totalSizeBytes: estimatedTotalSize,
|
|
downloadedSizeBytes: 0,
|
|
version: `v${Date.now()}`,
|
|
};
|
|
await OfflineDbManager.upsertProject(projectRecord);
|
|
setProjectInfo(projectRecord);
|
|
|
|
// Check storage quota
|
|
const quota = await StorageManager.getStorageQuota();
|
|
if (estimatedTotalSize > 0 && !quota.canStore(estimatedTotalSize)) {
|
|
throw new Error('Insufficient storage space');
|
|
}
|
|
|
|
// Reset progress counters
|
|
let downloadedCount = 0;
|
|
let downloadedSize = 0;
|
|
|
|
// First, check which assets are already fully cached (not partial)
|
|
// Partial downloads from online preload need to be re-downloaded fully for offline
|
|
const assetsToDownload: AssetToCache[] = [];
|
|
for (const asset of assets) {
|
|
const assetInfo: CachedAssetInfo | null =
|
|
await StorageManager.getAssetInfo(asset.storageKey);
|
|
|
|
if (assetInfo?.exists && !assetInfo.isPartial) {
|
|
// Fully cached, skip
|
|
downloadedCount++;
|
|
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 {
|
|
// Not cached at all
|
|
assetsToDownload.push(asset);
|
|
}
|
|
}
|
|
|
|
// Initialize counters for event-driven progress
|
|
downloadedCountRef.current = downloadedCount;
|
|
downloadedBytesRef.current = downloadedSize;
|
|
|
|
logger.info('[useOfflineMode] Assets to download:', {
|
|
total: assets.length,
|
|
alreadyCached: downloadedCount,
|
|
toDownload: assetsToDownload.length,
|
|
});
|
|
|
|
// Update initial progress
|
|
setDownloadedAssets(downloadedCount);
|
|
setDownloadedBytes(downloadedSize);
|
|
|
|
if (downloadedCount === assets.length) {
|
|
// All already downloaded
|
|
setStatus('downloaded');
|
|
setProgress(100);
|
|
await OfflineDbManager.updateProjectStatus(projectId, 'downloaded');
|
|
logger.info('[useOfflineMode] All assets already cached', {
|
|
projectId,
|
|
});
|
|
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
|
|
// DownloadManager handles concurrency internally
|
|
// Progress is tracked via event subscriptions (see useEffect above)
|
|
for (const asset of assetsToDownload) {
|
|
// Resolve download URL - prefer presigned, fallback to proxy
|
|
const downloadUrl =
|
|
presignedUrls[asset.storageKey] ||
|
|
resolveAssetPlaybackUrl(asset.storageKey);
|
|
|
|
downloadManager
|
|
.addJob({
|
|
assetId: `offline-${asset.storageKey}`,
|
|
projectId,
|
|
url: downloadUrl,
|
|
filename: asset.storageKey.split('/').pop() || 'asset',
|
|
variantType: 'original',
|
|
assetType: asset.assetType,
|
|
priority:
|
|
asset.assetType === 'image'
|
|
? 100
|
|
: asset.assetType === 'video'
|
|
? 50
|
|
: 75,
|
|
storageKey: asset.storageKey,
|
|
createBlobUrl: true, // Create blob URL for instant display
|
|
persist: true, // Persist for resume after page refresh
|
|
})
|
|
.catch((err) => {
|
|
// Errors handled by DownloadManager retry logic and events
|
|
logger.error('[useOfflineMode] Asset download failed', {
|
|
storageKey: asset.storageKey.slice(-50),
|
|
error: err?.message,
|
|
});
|
|
});
|
|
}
|
|
|
|
logger.info('[useOfflineMode] Downloads queued, progress via events', {
|
|
projectId,
|
|
queued: assetsToDownload.length,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Download failed';
|
|
setError(message);
|
|
setStatus('error');
|
|
await OfflineDbManager.updateProjectStatus(projectId, 'error');
|
|
}
|
|
}, [projectId, projectSlug, projectName, enabled, discoverAssets]);
|
|
|
|
// 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);
|
|
|
|
// Reset refs
|
|
assetsRef.current = [];
|
|
downloadedCountRef.current = 0;
|
|
downloadedBytesRef.current = 0;
|
|
|
|
OfflineDbManager.deleteProject(projectId);
|
|
setProjectInfo(null);
|
|
}, [projectId]);
|
|
|
|
// Delete offline data
|
|
const deleteOfflineData = useCallback(async () => {
|
|
if (!projectId) return;
|
|
|
|
// Get storage keys before deleting (to clear in-memory blob URLs)
|
|
const assets = discoverAssets();
|
|
const storageKeys = assets.map((a) => a.storageKey);
|
|
|
|
// Clear in-memory blob URLs for this project's assets
|
|
downloadManager.clearBlobUrlsForKeys(storageKeys);
|
|
|
|
// Delete from persistent storage
|
|
await StorageManager.deleteProjectAssets(projectId);
|
|
await OfflineDbManager.deleteProject(projectId);
|
|
|
|
// Reset refs used for progress tracking
|
|
assetsRef.current = [];
|
|
downloadedCountRef.current = 0;
|
|
downloadedBytesRef.current = 0;
|
|
|
|
// Reset state
|
|
setDiscoveredAssets([]);
|
|
setProjectInfo(null);
|
|
setStatus('not_downloaded');
|
|
setProgress(0);
|
|
setDownloadedAssets(0);
|
|
setTotalAssets(0);
|
|
setDownloadedBytes(0);
|
|
setTotalBytes(0);
|
|
}, [projectId, discoverAssets]);
|
|
|
|
// Check for updates by comparing discovered assets with stored project
|
|
const checkForUpdates = useCallback(async (): Promise<boolean> => {
|
|
if (!projectId || !projectInfo || !pages) return false;
|
|
|
|
try {
|
|
const currentAssets = discoverAssets();
|
|
|
|
// Simple check: if asset count changed, there are updates
|
|
if (currentAssets.length !== projectInfo.totalAssets) {
|
|
setStatus('outdated');
|
|
await OfflineDbManager.updateProjectStatus(projectId, 'outdated');
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [projectId, projectInfo, pages, discoverAssets]);
|
|
|
|
// Computed values
|
|
const isDownloaded = status === 'downloaded';
|
|
const isDownloading = status === 'downloading' && !isPaused;
|
|
const estimatedSize =
|
|
discoveredAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0) ||
|
|
totalBytes;
|
|
|
|
return {
|
|
isOfflineCapable,
|
|
isDownloaded,
|
|
isDownloading,
|
|
status,
|
|
progress,
|
|
downloadedAssets,
|
|
totalAssets,
|
|
downloadedBytes,
|
|
totalBytes,
|
|
error,
|
|
startDownload,
|
|
pauseDownload,
|
|
resumeDownload,
|
|
cancelDownload,
|
|
deleteOfflineData,
|
|
checkForUpdates,
|
|
projectInfo,
|
|
estimatedSize,
|
|
formatSize: formatBytes,
|
|
};
|
|
}
|